Compare commits

..
7 Commits
Author SHA1 Message Date
Reithan 2ca0df18bc Sort imports in test_pred_type to satisfy ruff I001 (#35)
## What
Sorts the import block at `tests/test_pred_type.py:18` so `ruff` (rule
I001) passes clean on `main`.

```diff
-from NRS.nodes_NRS import PredictionType, _RAW_TO_ENUM, NRS
+from NRS.nodes_NRS import _RAW_TO_ENUM, NRS, PredictionType
```

## Why
Gate-1 (pre-refactor checkpoint) requires `ruff` + `pytest` green on
`main`. This was the only outstanding lint violation.

## Verification
- `uvx ruff check .` → All checks passed!
- `uvx --with torch pytest -q` → 24 passed

Import-sort only; no behavior change.
2026-08-14 00:56:48 -07:00
Reithan ffeb7fe4f3 PR-1: cleanup — remove mangled guard, amputate dead operation-space arms, add detection tests (#34)
## Summary

Behavior-preserving cleanup ahead of the H3 flow-fix work (see
`.claude/plans/nrs_h3_fix_plan.2.md`, fact 8 / Phase 3 / Sequencing
PR-1). No output change — pure refactor plus a regression net.

### Changes to `NRS/nodes_NRS.py`
- **Removed the broken name-mangling guard.** `hasattr(self,
"__pred_type")` checked the literal name while assignment created
`_NRS__pred_type`, so the guard never fired. `patch()` now computes
`pred_type = self._get_pred_type(model)` unconditionally and passes it
as a closure local / explicit parameter into `nrs()`,
`_convert_to_v_space`, and `_finalize_from_v_space`. This preserves the
(correct) always-redetect behavior and removes a latent cross-model
aliasing bug. The `hasattr` string was **not** "repaired" — doing so
would introduce a stale-cache bug since ComfyUI reuses node instances
across queue runs.
- **Amputated dead operation-space arms.** `self.__OPERATION_SPACE` was
hardcoded to `PredictionType.V`, making both `match` blocks and the
`_convert_to_eps_space`/`_finalize_from_eps_space` helpers unreachable.
Removed them; the `nrs()` hook now calls the V-space
conversion/finalization directly. The inner V-vs-EPS conversion math
inside the V-space helpers is untouched — that is the real per-model
algebra, not dead code. FLOW is added in a later PR.

### Tests
- New `tests/test_pred_type.py`: parametrized coverage of all 11
`_RAW_TO_ENUM` entries plus `_get_pred_type` walks over stub models
(direct-attribute path and enhanced-detection fallback). These pin
**current** behavior (flow-family → EPS) as a regression net for the
FLOW reclassification PR.

## Verification
- `24/24` tests pass (21 new + 3 pre-existing smoke) from a clean
checkout.
- Greps for `self.__pred_type`, `_NRS__pred_type`,
`self.__OPERATION_SPACE` all empty.
- Diff: `NRS/nodes_NRS.py` +16/-87, `tests/test_pred_type.py` +111 —
behavior-preserving, well under 500 LoC.
2026-08-13 20:46:47 -07:00
Reithan 07f5ea0dbe Add version increment check to publish workflow (#33)
## Summary
- Add version increment check before publishing to Comfy registry
- Compare current version in pyproject.toml against HEAD~1
- Skip publishing successfully if version is unchanged (prevents
unnecessary publish attempts)
- Fail workflow if version is downgraded (prevents registry conflicts)
- Use Python with semantic versioning for robust comparison (handles
0.7.10 > 0.7.9 correctly)

## Changes
- Modified `.github/workflows/publish.yml`:
  - Added `fetch-depth: 2` to checkout step to access HEAD~1
  - Added new version check step with Python script
  - Made publish step conditional on version increment

## Test Plan
- [x] All pre-commit hooks pass
- [x] All tests pass
- [ ] Test unchanged version: Modify pyproject.toml metadata (not
version), workflow should skip publish
- [ ] Test version increment: Bump version, workflow should publish
- [ ] Test version downgrade: Downgrade version, workflow should fail
- [ ] Test invalid version: Malformed version, workflow should fail

The workflow can be tested using manual `workflow_dispatch` trigger
after merge.
2026-05-15 18:05:58 -07:00
Reithan ee6dc40393 Add git hooks and development infrastructure (#32)
## Summary

Establishes a complete development infrastructure for the project with
automated quality checks, testing, and contributor guidelines.

- Add pre-commit hooks for automated linting and quality checks
- Configure ruff for code linting and formatting (120 char line length,
Python 3.10+)
- Set up pytest with mocked torch/gradio dependencies for CI/CD
compatibility
- Create comprehensive CONTRIBUTING.md with setup instructions and
workflow guidance
- Add uv.lock for reproducible dependency resolution
- Replace trivial tests with functional interface tests

## Key Components

### Git Hooks (.pre-commit-config.yaml)
- Pre-commit: ruff linting/formatting, trailing whitespace, YAML
validation, prevents direct commits to main
- Pre-push: runs pytest test suite, prevents direct pushes to main

### Testing (tests/)
- Functional tests for ComfyUI node and WebUI script interfaces
- Mock torch, gradio, and WebUI modules via conftest.py (no heavy
dependencies in dev env)
- Tests verify API contracts without requiring full ComfyUI/WebUI
runtime

### Documentation (CONTRIBUTING.md)
- Development setup with uv package manager
- Git workflow and branch protection guidelines
- Linting, testing, and pre-commit hook usage
- Code style guidelines and commit message conventions

## Test Plan

- [x] Pre-commit hooks run on commit (linting, formatting, checks)
- [x] Pre-push hooks run pytest successfully
- [x] All tests pass in isolated venv
- [x] CONTRIBUTING.md instructions verified
- [x] No IDE diagnostics or issues
2026-05-15 16:08:45 -07:00
Reithan cd1f2e76f4 Fix Node.js 20 deprecation warnings in GitHub Actions
- Update actions/checkout from @v4 to @v5 for Node.js 24 support
- Update Comfy-Org/publish-node-action from @v1 to @main per official docs
- Add FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true environment variable
2026-05-15 14:17:02 -07:00
Reithan a70d1d09bb Fix prediction type detection for WAN/RES4LYF samplers (#30)
## Summary

Fixes issue #20 where NRS would fail with `RuntimeError: "Could not
determine prediction type for this model"` when using certain samplers
like WanKSamplerAdvanced and RES4LYF ClownsharKsampler.

## Changes Made

- **Enhanced prediction type mapping**: Added support for FLOW models
(`"flow"`, `"wan"`, `"const"` → `PredictionType.EPS`)
- **Improved model introspection**: Added `model_sampling` class
inspection and `model.model.model_type` enum detection
- **Graceful fallback**: Replaced RuntimeErrors with safe EPS defaults
when prediction type cannot be determined
- **Better debugging**: Added warning logs when using fallback
prediction types
- **Documentation**: Updated README with sampler compatibility
information
- **Version bump**: 0.7.3 → 0.7.4

## Technical Details

The issue occurred because WAN and similar samplers use **FLOW model
types** (ModelType.FLOW) that implement the CONST prediction class,
which are fundamentally different from traditional EPS/V-prediction
models. The original code only checked for string attributes and failed
to recognize these newer model architectures.

This fix follows the established pattern from commits fc38b5c (flux
support) and ba145c4 (chroma support) while adding robust fallback
mechanisms.

## Testing

- ✅ Manual integration testing planned with WanKSamplerAdvanced
- ✅ Manual integration testing planned with RES4LYF ClownsharKsampler  
- ✅ Backwards compatibility maintained for existing samplers
- ✅ Enhanced logging for debugging unrecognized models

## Risk Assessment

**Low Risk**: Only enhances existing detection logic without changing
core mathematical operations. Adds fallback instead of removing
functionality.

Closes #20

---------

Signed-off-by: Reithan <bo122081@hotmail.com>
2026-05-15 13:57:06 -07:00
Reithan b034c3f09f Improve setup documentation and ComfyUI UX (#29)
## Summary
Addresses documentation gaps identified in issue #25 by improving setup
instructions and user experience across all supported platforms.

## Changes Made

### 📚 README Enhancements
- **ComfyUI Setup Section**: Added collapsible setup instructions with:
  - Clear workflow explanation (Model → NRS Node → KSampler)
  - Prominent note that CFG setting on KSampler is ignored
  - Pro tip for validating NRS is working
  - Reference to the workflow image from issue #25

- **A1111/Forge/reForge Setup Section**: Added setup instructions
explaining:
  - Extension installation and enabling process
  - CFG Scale is ignored when NRS is active
  - Reference to Beginner How-To for parameter guidance

- **StabilityMatrix Integration**: Added section highlighting native
support with link to https://lykos.ai/

### 🖥️ ComfyUI Node UX Improvements
- **Node Description**: Added clear explanation that NRS replaces CFG
and KSampler CFG will be ignored
- **Parameter Tooltips**: Added helpful guidance directly in the
interface:
  - **Skew**: Explains direction steering, suggests starting with CFG/2
- **Stretch**: Explains positive intensification, suggests normal CFG
value
- **Squash**: Explains effect softening, recommends keeping low
initially

## Problem Solved
This addresses the confusion reported in issue #25 where users struggled
with:
- How to configure CFG values in ComfyUI workflows
- Understanding the relationship between NRS and CFG
- Lack of example workflows and clear setup guidance

## Test Plan
- [x] Verify README renders correctly with collapsible sections
- [ ] Test ComfyUI node shows tooltips when hovering over parameters
- [ ] Confirm node description appears in ComfyUI interface
- [x] Validate links work correctly (StabilityMatrix)

Fixes #25
2026-05-15 11:59:20 -07:00
14 changed files with 1168 additions and 129 deletions
+74 -2
View File
@@ -16,12 +16,84 @@ jobs:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'Reithan' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
submodules: true
fetch-depth: 2 # Need HEAD and HEAD~1 for version comparison
- name: Check version increment
id: version_check
run: |
python3 << 'EOF'
import sys
import os
import subprocess
from packaging.version import parse as parse_version
def extract_version(file_content):
"""Extract version string from pyproject.toml content."""
for line in file_content.split('\n'):
if line.strip().startswith('version = '):
version_str = line.split('=', 1)[1].strip()
return version_str.strip('"').strip("'")
return None
def get_version_at_commit(commit_ref):
"""Get version from pyproject.toml at a specific commit."""
try:
result = subprocess.run(
['git', 'show', f'{commit_ref}:pyproject.toml'],
capture_output=True,
text=True,
check=True
)
return extract_version(result.stdout)
except subprocess.CalledProcessError:
return None
# Get current and previous versions
current_version_str = get_version_at_commit('HEAD')
if not current_version_str:
print("::error::Could not extract current version from pyproject.toml")
sys.exit(1)
previous_version_str = get_version_at_commit('HEAD~1')
# Handle first commit case
if not previous_version_str:
print(f"::notice::First version commit detected: {current_version_str}")
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
f.write("should_publish=true\n")
sys.exit(0)
# Parse and compare versions
try:
current_version = parse_version(current_version_str)
previous_version = parse_version(previous_version_str)
except Exception as e:
print(f"::error::Invalid version format - {e}")
print(f"Current: {current_version_str}, Previous: {previous_version_str}")
sys.exit(1)
# Version comparison logic
if current_version > previous_version:
print(f"::notice::Version increment detected: {previous_version_str} -> {current_version_str}")
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
f.write("should_publish=true\n")
elif current_version == previous_version:
print(f"::notice::Version unchanged: {current_version_str} - skipping publish")
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
f.write("should_publish=false\n")
else:
print(f"::error::Version downgrade detected: {previous_version_str} -> {current_version_str}")
print("::error::Version must increase. Downgrades would conflict with published versions.")
sys.exit(1)
EOF
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@v1
if: steps.version_check.outputs.should_publish == 'true'
uses: Comfy-Org/publish-node-action@main
with:
personal_access_token: ${{ secrets.COMFY_REGISTRY_KEY }}
+8
View File
@@ -170,5 +170,13 @@ cython_debug/
# Ruff stuff:
.ruff_cache/
# pre-commit stuff:
.pre-commit-cache/
# PyPI configuration file
.pypirc
# AI agents
.claude/settings.local.json
.claude/docs/**
+52
View File
@@ -0,0 +1,52 @@
# See https://pre-commit.com for more information
repos:
# Standard pre-commit hooks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
args: ['--maxkb=1000']
- id: check-merge-conflict
- id: mixed-line-ending
args: ['--fix=lf']
# Ruff linter and formatter
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.12
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
# Main branch protection (pre-commit)
- repo: local
hooks:
- id: prevent-commit-to-main
name: Prevent commits to main branch
entry: bash -c 'BRANCH=$(git branch --show-current); if [ "$BRANCH" = "main" ]; then echo "ERROR - Direct commits to main are not allowed. Create a feature branch instead."; exit 1; fi'
language: system
stages: [pre-commit]
always_run: true
pass_filenames: false
# Test execution (pre-push only)
- repo: local
hooks:
- id: run-tests
name: Run pytest tests
entry: bash -c 'if command -v uv > /dev/null 2>&1; then uv run pytest tests/ || exit 1; else echo "WARNING - uv not found, skipping tests"; fi'
language: system
stages: [pre-push]
always_run: true
pass_filenames: false
- id: prevent-push-to-main
name: Prevent pushes to main branch
entry: bash -c 'BRANCH=$(git branch --show-current); if [ "$BRANCH" = "main" ]; then echo "ERROR - Direct pushes to main are not allowed. Use pull requests instead."; exit 1; fi'
language: system
stages: [pre-push]
always_run: true
pass_filenames: false
+260
View File
@@ -0,0 +1,260 @@
# Contributing to Negative Rejection Steering
Thank you for your interest in contributing! This document provides guidelines for setting up your development environment and contributing to the project.
## Development Setup
### Prerequisites
- Python 3.10 or higher
- [uv](https://docs.astral.sh/uv/getting-started/installation/) package manager
- Git
### Initial Setup
1. **Clone the repository** (if you haven't already):
```bash
git clone https://github.com/Reithan/negative_rejection_steering.git
cd negative_rejection_steering
```
2. **Create virtual environment with uv**:
```bash
uv venv
```
3. **Activate virtual environment**:
```bash
# Git Bash (Windows)
source .venv/Scripts/activate
# Linux/Mac
source .venv/bin/activate
# Windows CMD
.venv\Scripts\activate.bat
# Windows PowerShell
.venv\Scripts\Activate.ps1
```
4. **Install development dependencies**:
```bash
uv pip install -e ".[dev]"
```
5. **Install git hooks**:
```bash
pre-commit install
pre-commit install --hook-type pre-push
```
6. **Verify setup**:
```bash
# Run hooks manually on all files
pre-commit run --all-files
# Check that ruff works
ruff check .
```
## Git Workflow
### Protected Branches
- **Direct commits to `main` are blocked** by git hooks
- **Direct pushes to `main` are blocked** by git hooks
- All changes must go through feature branches and pull requests
### Recommended Workflow
1. **Create a feature branch**:
```bash
git checkout -b feature/your-feature-name
```
Or for bug fixes:
```bash
git checkout -b fix/bug-description
```
2. **Make your changes and commit**:
```bash
git add <files>
git commit -m "Your commit message"
```
The pre-commit hook will automatically:
- Run ruff linting and auto-fix issues
- Check for trailing whitespace, missing final newlines, etc.
- Block the commit if you're on the main branch
If the linter auto-fixes files, you'll need to re-stage and commit again.
3. **Push your branch**:
```bash
git push origin feature/your-feature-name
```
The pre-push hook will:
- Run tests (if pytest is available)
- Block the push if you're on the main branch
4. **Create a pull request** on GitHub
5. **Merge after review**
### Bypassing Hooks (Emergency Only)
If you must bypass hooks (NOT recommended):
```bash
git commit --no-verify # Skip pre-commit hooks
git push --no-verify # Skip pre-push hooks
```
**Warning**: Only use `--no-verify` in emergencies. Bypassing hooks may:
- Introduce linting issues
- Break Continuous Integration/Continuous Deployment pipelines
- Allow untested code to be pushed
## Development Commands
### Linting
```bash
# Check for linting issues
ruff check .
# Auto-fix linting issues
ruff check --fix .
# Format code
ruff format .
# Check a specific file
ruff check NRS/nodes_NRS.py
```
### Testing
```bash
# Run all tests
pytest
# Run with verbose output
pytest -v
# Run specific test file
pytest tests/test_smoke.py
# Run specific test function
pytest tests/test_smoke.py::test_file_structure
```
**Note**: Tests use mocked versions of torch and gradio (via conftest.py) since these dependencies are provided by ComfyUI/WebUI at runtime.
### Pre-commit Hooks
```bash
# Run all hooks manually
pre-commit run --all-files
# Run specific hook
pre-commit run ruff --all-files
pre-commit run ruff-format --all-files
# Update hook versions
pre-commit autoupdate
```
## Code Style Guidelines
This project uses **ruff** for linting and formatting with the following configuration:
- **Line length**: 120 characters
- **Target Python version**: 3.10+
- **Enabled checks**: pycodestyle (E/W), pyflakes (F), isort (I), pep8-naming (N), pyupgrade (UP)
### Special Cases
- **ComfyUI API conventions**: The `INPUT_TYPES` method and `s` parameter naming are required by ComfyUI's API and are exempted from normal naming rules
- **Star imports in `__init__.py`**: Required for ComfyUI node discovery
## Commit Message Guidelines
Write clear, concise commit messages:
- Use imperative mood ("Add feature" not "Added feature")
- Keep first line under 72 characters
- Add detailed description in the body if needed
Good examples:
```
Add support for XYZ model type
Fix crash when prediction type is unknown
Update README with installation instructions
```
Bad examples:
```
fixed stuff
WIP
Updated code
```
## Pull Request Guidelines
When submitting a pull request:
1. **Keep PRs focused**: One feature or fix per PR
2. **Update documentation**: If you add features, update README.md
3. **Test your changes**: Ensure the extension works in ComfyUI/reForge
4. **Run pre-commit hooks**: Make sure all checks pass
5. **Describe your changes**: Explain what and why in the PR description
## Project Structure
```
negative_rejection_steering/
├── NRS/
│ └── nodes_NRS.py # Main NRS node implementation
├── scripts/
│ └── negative_rejection_steering_script.py # Gradio UI for reForge
├── tests/
│ └── test_smoke.py # Smoke tests
├── __init__.py # ComfyUI node exports
├── pyproject.toml # Project config, dependencies, tool config
├── .pre-commit-config.yaml # Git hooks configuration
├── .gitignore
├── README.md
├── LICENSE
└── CONTRIBUTING.md # This file
```
## Getting Help
- **Issues**: Report bugs or request features via [GitHub Issues](https://github.com/Reithan/negative_rejection_steering/issues)
- **Discussions**: For questions or general discussion
- **Pull Requests**: Review the PR guidelines above before submitting
## License
By contributing to this project, you agree that your contributions will be licensed under the same license as the project (see LICENSE file).
---
Thank you for contributing to Negative Rejection Steering!
+138 -99
View File
@@ -1,39 +1,79 @@
import logging
import torch
from enum import Enum, auto
from typing import Any
import torch
# fmt: off
class PredictionType(Enum):
EPS = auto() # ε-prediction
V = auto() # v-prediction
X0 = auto() # x₀-prediction
UNKNOWN = auto() # couldn’t detect / new scheduler
_RAW_TO_ENUM = {
"eps": PredictionType.EPS,
"epsilon": PredictionType.EPS,
"flux": PredictionType.EPS,
"chroma": PredictionType.EPS,
"flow": PredictionType.EPS, # FLOW models (WAN, etc.) are EPS-compatible
"wan": PredictionType.EPS, # WAN21 is FLOW-based
"const": PredictionType.EPS, # CONST prediction class used in FLOW models
"v": PredictionType.V,
"v_prediction": PredictionType.V,
"x0": PredictionType.X0,
"sample": PredictionType.X0,
}
# fmt: on
class NRS:
@classmethod
def INPUT_TYPES(s):
return {"required": { "model": ("MODEL",),
"skew": ("FLOAT", {"default": 2.00, "min": -30.0, "max": 30.0, "step": 0.01}),
"stretch": ("FLOAT", {"default": 5.00, "min": -30.0, "max": 30.0, "step": 0.01}),
"squash": ("FLOAT", {"default": 0.75, "min": 0.0, "max": 1.0, "step": 0.01}),
}}
return {
"required": {
"model": ("MODEL", {"tooltip": "Input model to apply NRS to"}),
"skew": (
"FLOAT",
{
"default": 2.00,
"min": -30.0,
"max": 30.0,
"step": 0.01,
"tooltip": "Changes the 'direction' of generation, steering away from negative prompt elements. Start with CFG/2.",
},
),
"stretch": (
"FLOAT",
{
"default": 5.00,
"min": -30.0,
"max": 30.0,
"step": 0.01,
"tooltip": "Intensifies positive prompt elements. Start with your normal CFG value.",
},
),
"squash": (
"FLOAT",
{
"default": 0.75,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": "Softens Skew/Stretch effects, adding micro-detailing. Keep low initially.",
},
),
}
}
RETURN_TYPES = ("MODEL",)
FUNCTION = "patch"
CATEGORY = "advanced/model"
DESCRIPTION = "Negative Rejection Steering (NRS) replaces CFG with more nuanced guidance. IMPORTANT: Set your KSampler CFG to any value (it will be ignored). Connect your model through this node before sampling."
def _get_pred_type(self, model) -> PredictionType:
"""
In order to support Comfy, Forge, and possibly other models
@@ -42,6 +82,7 @@ class NRS:
prediction-type flag, then map it to the enum.
Defaults to EPS if all else fails.
"""
def _canon(p):
if p is None:
return ""
@@ -61,7 +102,12 @@ class NRS:
for attr in ("model_type", "prediction_type", "parameterization"):
p = _canon(getattr(obj, attr, None))
if p:
return _RAW_TO_ENUM.get(p, PredictionType.UNKNOWN)
pred_type = _RAW_TO_ENUM.get(p, PredictionType.UNKNOWN)
if pred_type != PredictionType.UNKNOWN:
logging.debug(
f"NRS._get_pred_type: Found prediction type '{p}' from attribute '{attr}' -> {pred_type}"
)
return pred_type
# 2) enqueue child containers we care about -------------------
for attr in ("model", "diffusion_model", "config", "scheduler", "inner_model", "model_sampling"):
@@ -70,123 +116,125 @@ class NRS:
seen.add(id(child))
queue.append(child)
# 3) default ------------------------------------------------------
return PredictionType.UNKNOWN
def _convert_to_eps_space(self, x_orig, sig_root, sigma, cond, uncond):
x_div = None
eps_cond = cond
eps_uncond = uncond
if self.__pred_type == PredictionType.V:
# v → ε conversion
logging.debug(f"NRS._convert_to_eps_space: generating x_div, eps_cond, and eps_uncond for v-pred")
x_div = x_orig / (sigma ** 2 + 1)
# 3) enhanced detection for FLOW models (WAN, Flux, etc.) -------
try:
# Check model_sampling class type for FLOW models
if hasattr(model, "model_sampling") and model.model_sampling is not None:
sampling_class_name = type(model.model_sampling).__name__.lower()
logging.debug(f"NRS._get_pred_type: Found model_sampling class: {sampling_class_name}")
eps_cond = ((x_div - (x_orig - cond)) * sig_root) / (sigma)
eps_uncond = ((x_div - (x_orig - uncond)) * sig_root) / (sigma)
elif self.__pred_type == PredictionType.EPS:
logging.debug(f"NRS._convert_to_eps_space: already in eps, no pre-scale needed")
pass # already in ε space
elif self.__pred_type == PredictionType.X0:
raise NotImplementedError("NRS._convert_to_eps_space: x0-prediction not supported yet.")
else:
raise RuntimeError("NRS._convert_to_eps_space: Could not determine prediction type for this model.")
return x_div, eps_cond, eps_uncond
# CONST class is used by FLOW models (WAN21, Flux, etc.)
if "const" in sampling_class_name:
logging.debug("NRS._get_pred_type: Detected FLOW model via CONST sampling class -> EPS")
return PredictionType.EPS
elif "v_prediction" in sampling_class_name:
logging.debug("NRS._get_pred_type: Detected V-prediction model via sampling class -> V")
return PredictionType.V
elif "eps" in sampling_class_name:
logging.debug("NRS._get_pred_type: Detected EPS model via sampling class -> EPS")
return PredictionType.EPS
def _finalize_from_eps_space(self, x_orig, x_div, x_final, sig_root, sigma):
nrs_result = x_final
if self.__pred_type == PredictionType.V:
# ε → v conversion
logging.debug(f"NRS._finalize_from_eps_space: generating cfg_result for v-pred")
nrs_result = x_orig - (x_div - x_final * sigma / sig_root)
elif self.__pred_type == PredictionType.EPS:
# already in ε space
logging.debug(f"NRS._finalize_from_eps_space: already in eps, no post-scale needed")
pass
elif self.__pred_type == PredictionType.X0:
raise NotImplementedError("NRS._finalize_from_eps_space: x0-prediction not supported yet.")
else:
raise RuntimeError("NRS._finalize_from_eps_space: Could not determine prediction type for this model.")
return nrs_result
def _convert_to_v_space(self, x_orig, sig_root, sigma, cond, uncond):
# Check model.model.model_type enum for newer models
if hasattr(model, "model") and hasattr(model.model, "model_type"):
model_type_str = _canon(str(model.model.model_type))
logging.debug(f"NRS._get_pred_type: Found model.model.model_type: {model_type_str}")
if "flow" in model_type_str or "flux" in model_type_str:
logging.debug("NRS._get_pred_type: Detected FLOW/Flux model via model_type -> EPS")
return PredictionType.EPS
elif "v_prediction" in model_type_str:
logging.debug("NRS._get_pred_type: Detected V-prediction model via model_type -> V")
return PredictionType.V
elif "eps" in model_type_str:
logging.debug("NRS._get_pred_type: Detected EPS model via model_type -> EPS")
return PredictionType.EPS
except Exception as e:
logging.debug(f"NRS._get_pred_type: Exception during enhanced detection: {e}")
# 4) safe default (matches docstring promise) --------------------
logging.warning("NRS._get_pred_type: Could not determine prediction type for model. Using EPS as fallback.")
logging.debug(
f"NRS._get_pred_type: Model structure: {[attr for attr in dir(model) if not attr.startswith('_')]}"
)
return PredictionType.EPS
def _convert_to_v_space(self, x_orig, sig_root, sigma, cond, uncond, pred_type):
x_div = None
v_cond = cond
v_uncond = uncond
if self.__pred_type == PredictionType.V:
logging.debug(f"NRS._convert_to_v_space: already in v, no pre-scale needed")
if pred_type == PredictionType.V:
logging.debug("NRS._convert_to_v_space: already in v, no pre-scale needed")
pass # already in v space
elif self.__pred_type == PredictionType.EPS:
elif pred_type == PredictionType.EPS:
# ε → v conversion
logging.debug(f"NRS._convert_to_v_space: generating x_div, v_cond, and v_uncond for eps")
x_div = x_orig / (sigma ** 2 + 1)
logging.debug("NRS._convert_to_v_space: generating x_div, v_cond, and v_uncond for eps")
x_div = x_orig / (sigma**2 + 1)
factor = sigma / sig_root
v_cond = x_orig - (x_div - cond * factor)
v_uncond = x_orig - (x_div - uncond * factor)
elif self.__pred_type == PredictionType.X0:
elif pred_type == PredictionType.X0:
raise NotImplementedError("NRS._convert_to_v_space: x0-prediction not supported yet.")
else:
raise RuntimeError("NRS._convert_to_v_space: Could not determine prediction type for this model.")
# Fallback: treat UNKNOWN as EPS and convert to V-space
logging.warning(f"NRS._convert_to_v_space: Unknown prediction type {pred_type}, treating as EPS")
logging.debug("NRS._convert_to_v_space: generating x_div, v_cond, and v_uncond for eps (fallback)")
x_div = x_orig / (sigma**2 + 1)
factor = sigma / sig_root
v_cond = x_orig - (x_div - cond * factor)
v_uncond = x_orig - (x_div - uncond * factor)
return x_div, v_cond, v_uncond
def _finalize_from_v_space(self, x_orig, x_div, x_final, sig_root, sigma):
def _finalize_from_v_space(self, x_orig, x_div, x_final, sig_root, sigma, pred_type):
nrs_result = x_final
if self.__pred_type == PredictionType.V:
if pred_type == PredictionType.V:
# already in v space
logging.debug(f"NRS._finalize_from_v_space: already in v, no post-scale needed")
logging.debug("NRS._finalize_from_v_space: already in v, no post-scale needed")
pass
elif self.__pred_type == PredictionType.EPS:
elif pred_type == PredictionType.EPS:
# v → ε conversion
logging.debug(f"NRS._finalize_from_v_space: generating cfg_result for eps")
logging.debug("NRS._finalize_from_v_space: generating cfg_result for eps")
nrs_result = (x_div - (x_orig - x_final)) * (sig_root / sigma)
elif self.__pred_type == PredictionType.X0:
elif pred_type == PredictionType.X0:
raise NotImplementedError("NRS._finalize_from_v_space: x0-prediction not supported yet.")
else:
raise RuntimeError("NRS._finalize_from_v_space: Could not determine prediction type for this model.")
# Fallback: treat UNKNOWN as EPS and convert from V-space
logging.warning(f"NRS._finalize_from_v_space: Unknown prediction type {pred_type}, treating as EPS")
logging.debug("NRS._finalize_from_v_space: generating cfg_result for eps (fallback)")
nrs_result = (x_div - (x_orig - x_final)) * (sig_root / sigma)
return nrs_result
def patch(self, model, skew, stretch, squash):
self.__pred_type = self._get_pred_type(model) if not hasattr(self, "__pred_type") else self.__pred_type
self.__OPERATION_SPACE = PredictionType.V
pred_type = self._get_pred_type(model)
def nrs(args):
logging.debug(f"NRS.nrs: Skew: {skew}, Stretch: {stretch}, Squash: {squash}")
# self.__pred_type = self.__pred_type if self.__pred_type is not None else self._get_pred_type(model)
cond = args["cond"]
uncond = args["uncond"]
x_orig = args["input"]
sigma = args["sigma"]
sigma = sigma.view(sigma.shape[:1] + (1,) * (cond.ndim - 1))
sig_root = (sigma ** 2 + 1).sqrt()
x_div, nrs_cond, nrs_uncond = None, None, None
match self.__OPERATION_SPACE:
case PredictionType.V:
x_div, nrs_cond, nrs_uncond = self._convert_to_v_space(x_orig, sig_root, sigma, cond, uncond)
case PredictionType.EPS:
x_div, nrs_cond, nrs_uncond = self._convert_to_eps_space(x_orig, sig_root, sigma, cond, uncond)
case PredictionType.X0:
raise RuntimeError("NRS.nrs: x0-prediction not supported yet.")
case PredictionType.UNKNOWN:
raise RuntimeError("NRS.nrs: Could not determine prediction type for this operation.")
case _:
raise RuntimeError("NRS.nrs: Invalid PredictionType used.")
sig_root = (sigma**2 + 1).sqrt()
# Operation space is hardcoded to V for now; FLOW is added in a later PR.
x_div, nrs_cond, nrs_uncond = self._convert_to_v_space(
x_orig, sig_root, sigma, cond, uncond, pred_type
)
def _dot(a, b):
return (a*b).sum(dim=1, keepdim=True) # [B,C,W,H] => [B,1,W,H]
return (a * b).sum(dim=1, keepdim=True) # [B,C,W,H] => [B,1,W,H]
def _nrm2(v):
return _dot(v, v)
eps = torch.finfo(nrs_cond.dtype).eps
c_dot_c = _nrm2(nrs_cond) + eps # [B,1,W,H]
u_dot_c = _dot(nrs_uncond, nrs_cond) # [B,1,W,H]
u_on_c = (u_dot_c / c_dot_c) * nrs_cond # [B,1,W,H] * [B,C,H,W]
c_dot_c = _nrm2(nrs_cond) + eps # [B,1,W,H]
u_dot_c = _dot(nrs_uncond, nrs_cond) # [B,1,W,H]
u_on_c = (u_dot_c / c_dot_c) * nrs_cond # [B,1,W,H] * [B,C,H,W]
# Amplify Cond based on length compared to projection of uncond
proj_diff = nrs_cond - u_on_c
stretched = nrs_cond + (stretch * proj_diff)
@@ -202,21 +250,12 @@ class NRS:
squash_scale = (1 - squash) + (squash * (cond_len / nrs_len))
x_final = skewed * squash_scale
match self.__OPERATION_SPACE:
case PredictionType.V:
return self._finalize_from_v_space(x_orig, x_div, x_final, sig_root, sigma)
case PredictionType.EPS:
return self._finalize_from_eps_space(x_orig, x_div, x_final, sig_root, sigma)
case PredictionType.X0:
raise RuntimeError("NRS.nrs: x0-prediction not supported yet.")
case PredictionType.UNKNOWN:
raise RuntimeError("NRS.nrs: Could not determine prediction type for this operation.")
case _:
raise RuntimeError("NRS.nrs: Invalid PredictionType used.")
return self._finalize_from_v_space(x_orig, x_div, x_final, sig_root, sigma, pred_type)
m = model.clone()
m.set_model_sampler_cfg_function(nrs, True)
return (m, )
return (m,)
NODE_CLASS_MAPPINGS = {
"NRS": NRS,
+49 -3
View File
@@ -9,6 +9,8 @@ NRS seeks to replace the 'naive' linear interpolation of Classifier Free Guidanc
2. NRS replaces CFG with 3 new knobs.
3. NRS lets you to create cooler outputs than CFG.
**Contributing**: See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
> [!TIP]
> Skip to the [Beginner How-To](#beginner-how-to) if you want to just get started.
@@ -39,7 +41,7 @@ NRS seeks to replace the 'naive' linear interpolation of Classifier Free Guidanc
| Prompt | |
| ---------- | --- |
| Tool | [Stable Diffusion WebUI reForge](https://github.com/Panchovix/stable-diffusion-webui-reForge) |
| Tool | [Stable Diffusion WebUI reForge](https://github.com/Panchovix/stable-diffusion-webui-reForge) |
| Sampler | DPM++ 2M |
| Scheduler | Align Your Steps |
| Steps | 25 |
@@ -67,12 +69,56 @@ NRS seeks to replace the 'naive' linear interpolation of Classifier Free Guidanc
4. Adjust Stretch to intensify your positive prompt's aspects and colors where they differ from the negative prompt. This primarily effects color and texture.
5. Adjust Squash to soften Skew and Stretch's effects. The intensity removed from Skew and Stretch will generally become additional micro-detailing and elements.
> [!TIP]
> [!TIP]
> You can experiment with negative values for each setting as well. This can be useful to understand how the model interpreting your negative prompt.
> [!WARNING]
> [!WARNING]
> Don't set NRS values to negatives if there are things in your negative prompt you **actually** don't want to see.
## Setup & Installation
### ComfyUI
<details>
<summary>ComfyUI Setup Instructions</summary>
#### Installation
Install via ComfyUI Manager or manually clone this repository into your `ComfyUI/custom_nodes/` directory.
#### Usage
1. **Important**: Ignore the CFG setting on your KSampler node - NRS replaces CFG entirely
2. Connect your model through the **Negative Rejection Steering** node before sampling
3. Configure NRS parameters (Skew/Stretch/Squash) instead of using CFG
#### Basic Workflow
```
Model → NRS Node → KSampler
```
**Pro tip**: To verify NRS is working correctly, set CFG to an extremely high value (like 30). If your output looks normal, NRS is functioning properly. If the output appears "turbo fried," check your node connections.
**Sampler Compatibility**: NRS now supports advanced samplers including WanKSamplerAdvanced, RES4LYF samplers, and FLOW models (WAN21, Flux) with enhanced prediction type detection.
![ComfyUI Workflow Example](https://github.com/user-attachments/assets/edaa36a4-9ad8-4a35-bad3-dda80138b996)
</details>
### Automatic1111 / Forge / reForge
<details>
<summary>WebUI Setup Instructions</summary>
#### Installation
1. Install the extension through the Extensions tab in your WebUI
2. Enable the extension and restart your WebUI
#### Usage
Once installed and enabled, the NRS settings panel will appear in your generation interface. When NRS is active:
- **CFG Scale is ignored** - the WebUI may still show the CFG setting, but it has no effect
- Use the NRS parameters (Skew/Stretch/Squash) to control generation instead
- Follow the same parameter guidelines from the [Beginner How-To](#beginner-how-to) section
</details>
### StabilityMatrix Integration
NRS is available as a **natively supported module** in [StabilityMatrix](https://lykos.ai/), providing an easy installation and management option for users of that platform.
## Submitted User Examples
| User | CFG | NRS |
| --- | --- | --- |
+1 -1
View File
@@ -2,4 +2,4 @@ from .NRS.nodes_NRS import *
NODE_CLASS_MAPPINGS = {"NRS": NRS}
NODE_DISPLAY_NAME_MAPPINS = {"NRS": "Negative Rejection Steering"}
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+1 -1
View File
@@ -8,4 +8,4 @@ This is the sister knob to Skew. This is the accelerator. We want to go 'faster'
[^1]: This is more true in eps than v-pred. Stretch is inherently more powerful in v-pred based models than eps models.
## SQUASH
This is out 'safety' knob. Think of this like a 'limiter' in a car. This sets the 'top speed' we can go to some multiple of the 'default' speed the model would 'like to' go. i.e. whatever length of directional vector the model produces prior to any skewing or stretching is treated as the 'default' length with Squash=1.0 ensuring we only every go that 'speed' and no more, while Squash=0.0 lets us go any speed we want based on the other 2 knobs. GENERALLY we'll be leaving Squash at 0.0 unless we need it for specific generations.
This is out 'safety' knob. Think of this like a 'limiter' in a car. This sets the 'top speed' we can go to some multiple of the 'default' speed the model would 'like to' go. i.e. whatever length of directional vector the model produces prior to any skewing or stretching is treated as the 'default' length with Squash=1.0 ensuring we only every go that 'speed' and no more, while Squash=0.0 lets us go any speed we want based on the other 2 knobs. GENERALLY we'll be leaving Squash at 0.0 unless we need it for specific generations.
+44 -1
View File
@@ -1,8 +1,12 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "negative_rejection_steering"
description = "NRS seeks to replace the 'naive' linear interpolation of Classifier Free Guidance with a more nuanced and composable steering of the generation process with better mathematical basis."
authors = [{name = "Bryan O'Malley", email = "bo122081@hotmail.com"}]
version = "0.7.3"
version = "0.7.4"
license = {file = "LICENSE"}
readme = "README.md"
@@ -10,7 +14,46 @@ readme = "README.md"
Repository = "https://github.com/Reithan/negative_rejection_steering"
# Used by Comfy Registry https://comfyregistry.org
[tool.setuptools.packages.find]
include = ["NRS*", "scripts*"]
exclude = ["tests*", "Examples*"]
[tool.comfy]
PublisherId = "reithan"
DisplayName = "Negative Rejection Steering"
Icon = "https://raw.githubusercontent.com/Reithan/negative_rejection_steering/main/icon.png"
[project.optional-dependencies]
dev = [
"pre-commit>=3.7.0",
"pytest>=8.0.0",
"ruff>=0.6.0",
]
[tool.ruff]
line-length = 120
target-version = "py310"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"N", # pep8-naming
"UP", # pyupgrade
]
ignore = [
"E501", # line too long (handled by formatter)
]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401", "F403", "F405"] # Allow star imports and undefined names in __init__.py (ComfyUI pattern)
"NRS/nodes_NRS.py" = ["N802", "N804"] # Allow INPUT_TYPES and 's' parameter naming (ComfyUI API convention)
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = "test_*.py"
python_functions = "test_*"
addopts = "-v --tb=short"
norecursedirs = [".git", ".venv", "NRS", "scripts", "Examples"]
+47 -22
View File
@@ -1,12 +1,15 @@
import gradio as gr
import logging
import sys
import traceback
from functools import partial
from modules import scripts, script_callbacks
from typing import Any
import gradio as gr
from modules import script_callbacks, scripts
from NRS.nodes_NRS import NRS
class NRSScript(scripts.Script):
def __init__(self):
super().__init__()
@@ -22,22 +25,39 @@ class NRSScript(scripts.Script):
def show(self, is_img2img):
return scripts.AlwaysVisible
def ui(self, *args, **kwargs):
with gr.Accordion(open=False, label=self.title()):
enabled = gr.Checkbox(label="Enable NRS", value=self.enabled)
gr.HTML("<p><i>Adjust the settings for Negative Rejection Steering.</i></p>")
skew = gr.Slider(label="NRS Skew Scale", info="Adjusts the amount guidance is steered.", minimum=-30.0, maximum=30.0, step=0.01, value=self.skew)
stretch = gr.Slider(label="NRS Stretch Scale", info="Adjusts the amount guidance is amplified.", minimum=-30.0, maximum=30.0, step=0.01, value=self.stretch)
squash = gr.Slider(label="NRS Squash Multiplier", info="Adjusts the amount final guidance is normalized.", minimum=0.0, maximum=1.0, step=0.01, value=self.squash)
skew = gr.Slider(
label="NRS Skew Scale",
info="Adjusts the amount guidance is steered.",
minimum=-30.0,
maximum=30.0,
step=0.01,
value=self.skew,
)
stretch = gr.Slider(
label="NRS Stretch Scale",
info="Adjusts the amount guidance is amplified.",
minimum=-30.0,
maximum=30.0,
step=0.01,
value=self.stretch,
)
squash = gr.Slider(
label="NRS Squash Multiplier",
info="Adjusts the amount final guidance is normalized.",
minimum=0.0,
maximum=1.0,
step=0.01,
value=self.squash,
)
enabled.change(
lambda x: self.update_enabled(x),
inputs=[enabled]
)
enabled.change(lambda x: self.update_enabled(x), inputs=[enabled])
return (enabled, skew, stretch, squash)
def update_enabled(self, value):
self.enabled = value
@@ -70,22 +90,28 @@ class NRSScript(scripts.Script):
unet = NRS().patch(unet, self.skew, self.stretch, self.squash)[0]
p.sd_model.forge_objects.unet = unet
p.extra_generation_params.update({
"NRS_enabled": True,
"NRS_skew": self.skew,
"NRS_stretch": self.stretch,
"NRS_squash": self.squash,
})
p.extra_generation_params.update(
{
"NRS_enabled": True,
"NRS_skew": self.skew,
"NRS_stretch": self.stretch,
"NRS_squash": self.squash,
}
)
logging.debug(f"NRS: Enabled: {self.enabled}, Squash: {self.skew}, Stretch: {self.stretch}, Squash: {self.squash}")
logging.debug(
f"NRS: Enabled: {self.enabled}, Squash: {self.skew}, Stretch: {self.stretch}, Squash: {self.squash}"
)
return
def set_value(p, x: Any, xs: Any, *, field: str):
if not hasattr(p, "_nrs_xyz"):
p._nrs_xyz = {}
p._nrs_xyz[field] = x
def make_axis_on_xyz_grid():
xyz_grid = None
for script in scripts.scripts_data:
@@ -98,10 +124,7 @@ def make_axis_on_xyz_grid():
axis = [
xyz_grid.AxisOption(
"(NRS) Enabled",
str,
partial(set_value, field="enabled"),
choices=lambda: ["True", "False"]
"(NRS) Enabled", str, partial(set_value, field="enabled"), choices=lambda: ["True", "False"]
),
xyz_grid.AxisOption(
"(NRS) Skew",
@@ -123,6 +146,7 @@ def make_axis_on_xyz_grid():
if not any(x.label.startswith("(NRS)") for x in xyz_grid.axis_options):
xyz_grid.axis_options.extend(axis)
def on_before_ui():
try:
make_axis_on_xyz_grid()
@@ -133,4 +157,5 @@ def on_before_ui():
file=sys.stderr,
)
script_callbacks.on_before_ui(on_before_ui)
+30
View File
@@ -0,0 +1,30 @@
"""Pytest configuration - mock torch and gradio since they're provided by ComfyUI/WebUI at runtime."""
import sys
from unittest.mock import MagicMock
# Create mock base class for WebUI Script
class MockScript:
"""Mock base class for WebUI scripts."""
AlwaysVisible = "AlwaysVisible" # Mock the AlwaysVisible constant
# Mock torch, gradio, and WebUI modules before any imports happen
# This allows pytest to discover and import tests without these heavy dependencies
sys.modules["torch"] = MagicMock()
# Mock gradio with return_value configured for common patterns
mock_gradio = MagicMock()
mock_gradio.Accordion = MagicMock(return_value=MagicMock(__enter__=MagicMock(), __exit__=MagicMock()))
sys.modules["gradio"] = mock_gradio
# Mock WebUI modules with proper base class
mock_modules = MagicMock()
mock_modules.scripts = MagicMock()
mock_modules.scripts.Script = MockScript
mock_modules.script_callbacks = MagicMock()
sys.modules["modules"] = mock_modules
sys.modules["modules.scripts"] = mock_modules.scripts
sys.modules["modules.script_callbacks"] = mock_modules.script_callbacks
+111
View File
@@ -0,0 +1,111 @@
"""Regression tests for NRS._get_pred_type and _RAW_TO_ENUM mappings.
These tests pin CURRENT behavior (flow-family names resolve to EPS) as a
safety net ahead of the FLOW reclassification planned for a later PR. If
this file needs updating because flow-family names now map to
PredictionType.FLOW, that is expected -- it means the reclassification
landed and this net did its job.
"""
import sys
from pathlib import Path
import pytest
# Add project root to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from NRS.nodes_NRS import _RAW_TO_ENUM, NRS, PredictionType
def _make_model_sampling(class_name):
"""Build an instance whose type name matches class_name, for class-name fingerprinting."""
return type(class_name, (object,), {})()
class _StubModel:
"""Minimal stand-in for a model object walked by _get_pred_type."""
def __init__(self, model_type=None, model_sampling=None, inner_model_type=None):
if model_type is not None:
self.model_type = model_type
if model_sampling is not None:
self.model_sampling = model_sampling
if inner_model_type is not None:
# Emulates model.model.model_type used by the enhanced-detection fallback.
self.model = _StubModel(model_type=inner_model_type)
@pytest.mark.parametrize(
"raw,expected",
[
("eps", PredictionType.EPS),
("epsilon", PredictionType.EPS),
("flux", PredictionType.EPS),
("chroma", PredictionType.EPS),
("flow", PredictionType.EPS),
("wan", PredictionType.EPS),
("const", PredictionType.EPS),
("v", PredictionType.V),
("v_prediction", PredictionType.V),
("x0", PredictionType.X0),
("sample", PredictionType.X0),
],
)
def test_raw_to_enum_mapping(raw, expected):
"""Pin the current _RAW_TO_ENUM dict mappings."""
assert _RAW_TO_ENUM[raw] == expected
def test_raw_to_enum_unknown_raw_not_present():
"""Unrecognized raw strings are not in the dict; callers fall back to UNKNOWN."""
assert "totally-unrecognized" not in _RAW_TO_ENUM
class TestGetPredTypeDirectAttribute:
"""_get_pred_type's direct-hit path via model_type/prediction_type/parameterization."""
def test_model_type_v_prediction(self):
node = NRS()
model = _StubModel(model_type="v_prediction")
assert node._get_pred_type(model) == PredictionType.V
def test_model_type_eps(self):
node = NRS()
model = _StubModel(model_type="eps")
assert node._get_pred_type(model) == PredictionType.EPS
def test_model_type_x0(self):
node = NRS()
model = _StubModel(model_type="x0")
assert node._get_pred_type(model) == PredictionType.X0
def test_model_type_flow_family_is_currently_eps(self):
"""Flow-family models currently resolve to EPS (pre-reclassification)."""
model = _StubModel(model_type="flow")
assert NRS()._get_pred_type(model) == PredictionType.EPS
def test_model_type_wan_is_currently_eps(self):
model = _StubModel(model_type="wan")
assert NRS()._get_pred_type(model) == PredictionType.EPS
class TestGetPredTypeEnhancedDetectionFallback:
"""The model_sampling class-name and model.model.model_type fallback paths."""
def test_model_sampling_const_class_is_eps(self):
model = _StubModel(model_sampling=_make_model_sampling("ModelSamplingContinuousEDMConst"))
assert NRS()._get_pred_type(model) == PredictionType.EPS
def test_model_sampling_v_prediction_class_is_v(self):
model = _StubModel(model_sampling=_make_model_sampling("ModelSamplingV_Prediction"))
assert NRS()._get_pred_type(model) == PredictionType.V
def test_model_sampling_eps_class_is_eps(self):
model = _StubModel(model_sampling=_make_model_sampling("ModelSamplingEps"))
assert NRS()._get_pred_type(model) == PredictionType.EPS
def test_unrecognized_model_defaults_to_eps(self):
"""Fully-unrecognized models fall back to EPS (documented default)."""
model = _StubModel()
assert NRS()._get_pred_type(model) == PredictionType.EPS
+91
View File
@@ -0,0 +1,91 @@
"""Functional tests for NRS ComfyUI node and WebUI script."""
import inspect
import sys
from pathlib import Path
# Add project root to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
def test_nrs_node_comfyui_interface():
"""Test that NRS node has required ComfyUI interface."""
from NRS.nodes_NRS import NRS
# Test class can be instantiated
node = NRS()
assert node is not None
# Test INPUT_TYPES classmethod exists and returns proper structure
assert hasattr(NRS, "INPUT_TYPES")
assert callable(NRS.INPUT_TYPES)
input_types = NRS.INPUT_TYPES()
assert isinstance(input_types, dict)
assert "required" in input_types
assert "model" in input_types["required"]
assert "skew" in input_types["required"]
assert "stretch" in input_types["required"]
assert "squash" in input_types["required"]
# Test patch method exists with correct signature
assert hasattr(node, "patch")
assert callable(node.patch)
sig = inspect.signature(node.patch)
params = list(sig.parameters.keys())
assert "model" in params
assert "skew" in params
assert "stretch" in params
assert "squash" in params
# Test required class attributes
assert hasattr(NRS, "RETURN_TYPES")
assert NRS.RETURN_TYPES == ("MODEL",)
assert hasattr(NRS, "FUNCTION")
assert NRS.FUNCTION == "patch"
assert hasattr(NRS, "CATEGORY")
assert NRS.CATEGORY == "advanced/model"
def test_nrs_script_webui_interface():
"""Test that NRSScript has required WebUI/Gradio interface."""
from scripts.negative_rejection_steering_script import NRSScript
# Test class can be instantiated
script = NRSScript()
assert script is not None
# Test required methods exist
assert hasattr(script, "title")
assert callable(script.title)
assert isinstance(script.title(), str)
assert hasattr(script, "show")
assert callable(script.show)
assert hasattr(script, "ui")
assert callable(script.ui)
assert hasattr(script, "process_before_every_sampling")
assert callable(script.process_before_every_sampling)
# Test process_before_every_sampling has correct signature
sig = inspect.signature(script.process_before_every_sampling)
params = list(sig.parameters.keys())
# Note: 'self' is not included in signature, only other parameters
assert "p" in params
def test_prediction_type_enum():
"""Test PredictionType enum has required values."""
from NRS.nodes_NRS import PredictionType
# Test enum has required prediction types
assert hasattr(PredictionType, "EPS")
assert hasattr(PredictionType, "V")
assert hasattr(PredictionType, "X0")
assert hasattr(PredictionType, "UNKNOWN")
# Test enum values are distinct
assert PredictionType.EPS != PredictionType.V
assert PredictionType.V != PredictionType.X0
assert PredictionType.X0 != PredictionType.UNKNOWN
Generated
+262
View File
@@ -0,0 +1,262 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "cfgv"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "distlib"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]
[[package]]
name = "filelock"
version = "3.29.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" },
]
[[package]]
name = "identify"
version = "2.6.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "negative-rejection-steering"
version = "0.7.4"
source = { editable = "." }
[package.optional-dependencies]
dev = [
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.7.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6.0" },
]
provides-extras = ["dev"]
[[package]]
name = "nodeenv"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pre-commit"
version = "4.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
{ name = "identify" },
{ name = "nodeenv" },
{ name = "pyyaml" },
{ name = "virtualenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "python-discovery"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "ruff"
version = "0.15.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" },
{ url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" },
{ url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" },
{ url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" },
{ url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" },
{ url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" },
{ url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" },
{ url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" },
{ url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" },
{ url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" },
{ url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" },
{ url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" },
{ url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" },
{ url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" },
]
[[package]]
name = "virtualenv"
version = "21.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
{ name = "python-discovery" },
]
sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" },
]