Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1be05ad7d2 | ||
|
|
244f8c656f | ||
|
|
5655dc0d5e | ||
|
|
e0e48d12c8 | ||
|
|
950b55df5f | ||
|
|
352ac56295 | ||
|
|
c526ec9353 | ||
|
|
d4ff852491 | ||
|
|
2ca0df18bc | ||
|
|
ffeb7fe4f3 | ||
|
|
07f5ea0dbe | ||
|
|
ee6dc40393 | ||
|
|
cd1f2e76f4 | ||
|
|
a70d1d09bb | ||
|
|
b034c3f09f | ||
|
|
2583c237f2 | ||
|
|
c83958c457 | ||
|
|
a0b2d99bc7 | ||
|
|
ba145c4722 | ||
|
|
c21dbfe1e5 | ||
|
|
fc38b5c998 | ||
|
|
d26fcf6fc8 | ||
|
|
3d8827f132 | ||
|
|
62bef2e275 | ||
|
|
fecdfe01df | ||
|
|
c5610837e4 | ||
|
|
21ac7cf0cb | ||
|
|
4930d862d5 | ||
|
|
e8b727f914 | ||
|
|
389aedfd17 | ||
|
|
99824b2ee5 | ||
|
|
4bb226aabb | ||
|
|
60b5127cf4 | ||
|
|
e72afd4189 | ||
|
|
98a9b6d656 | ||
|
|
e0988d3b24 | ||
|
|
932c7b2136 | ||
|
|
50ddd2ac47 | ||
|
|
3768f4a768 | ||
|
|
e82622cefe | ||
|
|
793914ced3 | ||
|
|
46540aa7bc | ||
|
|
873034b095 | ||
|
|
7d399643dd | ||
|
|
50033c2622 | ||
|
|
36eb9d592b | ||
|
|
bc50983954 | ||
|
|
0c37c6b124 | ||
|
|
8d7d9281f7 | ||
|
|
e55881afe2 | ||
|
|
0e8e508213 | ||
|
|
ff69ac386d | ||
|
|
5be3c4c4f1 | ||
|
|
81b836d2f1 | ||
|
|
a57c16a624 | ||
|
|
e6cdf03189 | ||
|
|
47e4b5073b | ||
|
|
0778fa4b03 | ||
|
|
4c8a086f6b |
@@ -0,0 +1,160 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: Report unexpected NRS behavior or a crash
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to report a bug. Please fill out as much detail as you can — NRS's guidance behavior is sensitive to model type and node settings, so precise details help a lot.
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: Platform / UI
|
||||||
|
description: Which UI/front-end are you running NRS through?
|
||||||
|
options:
|
||||||
|
- ComfyUI
|
||||||
|
- AUTOMATIC1111
|
||||||
|
- Forge
|
||||||
|
- reForge
|
||||||
|
- Forge Neo
|
||||||
|
- Stability Matrix
|
||||||
|
- Other (specify below)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: platform-other
|
||||||
|
attributes:
|
||||||
|
label: Platform / UI (if "Other")
|
||||||
|
description: If you selected "Other" above, name the platform/UI here.
|
||||||
|
placeholder: e.g. SD.Next, a custom fork, etc.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: platform-version
|
||||||
|
attributes:
|
||||||
|
label: Platform version
|
||||||
|
description: >
|
||||||
|
The version of whichever platform you selected above. Found in that
|
||||||
|
platform's UI (e.g. Help/About, Settings) or its startup console
|
||||||
|
output.
|
||||||
|
placeholder: e.g. 0.3.30 (ComfyUI) or v1.10.1 (AUTOMATIC1111/Forge)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: model-family
|
||||||
|
attributes:
|
||||||
|
label: Model / sampler family
|
||||||
|
description: Which model or sampler family were you using when the issue occurred?
|
||||||
|
options:
|
||||||
|
- MiniMax H3
|
||||||
|
- Flux
|
||||||
|
- Chroma
|
||||||
|
- WAN
|
||||||
|
- SDXL
|
||||||
|
- SD 1.5
|
||||||
|
- Other (specify below)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: model-family-other
|
||||||
|
attributes:
|
||||||
|
label: Model / sampler family (if "Other")
|
||||||
|
description: If you selected "Other" above, name the model/sampler family here.
|
||||||
|
placeholder: e.g. custom checkpoint, HunyuanVideo, etc.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: nrs-version
|
||||||
|
attributes:
|
||||||
|
label: NRS version
|
||||||
|
description: >
|
||||||
|
Look for the NRS log line in your console/terminal:
|
||||||
|
`NRS v<version>: prediction type detected -> <TYPE>` (printed when
|
||||||
|
NRS runs, or check the extension's about/version info, depending on
|
||||||
|
your platform). Copy the version number from that line.
|
||||||
|
placeholder: e.g. 0.7.4
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: pred-type
|
||||||
|
attributes:
|
||||||
|
label: Detected prediction type
|
||||||
|
description: >
|
||||||
|
From the same NRS log line as above
|
||||||
|
(`NRS v<version>: prediction type detected -> <TYPE>`), copy the
|
||||||
|
detected type (e.g. EPS, V, FLOW, UNKNOWN).
|
||||||
|
placeholder: e.g. FLOW
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: skew
|
||||||
|
attributes:
|
||||||
|
label: Skew value
|
||||||
|
placeholder: e.g. 2.00
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: stretch
|
||||||
|
attributes:
|
||||||
|
label: Stretch value
|
||||||
|
placeholder: e.g. 5.00
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: squash
|
||||||
|
attributes:
|
||||||
|
label: Squash value
|
||||||
|
placeholder: e.g. 0.75
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected behavior
|
||||||
|
description: What did you expect to happen?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: Actual behavior
|
||||||
|
description: What actually happened? Include screenshots if relevant.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: repro
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: Minimal steps (or an attached workflow JSON) to reproduce the issue.
|
||||||
|
placeholder: |
|
||||||
|
1. Load workflow...
|
||||||
|
2. Set Skew/Stretch/Squash to...
|
||||||
|
3. Queue prompt...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: console-log
|
||||||
|
attributes:
|
||||||
|
label: Console log output
|
||||||
|
description: >
|
||||||
|
Paste the relevant console output, including the
|
||||||
|
`NRS v<version>: prediction type detected -> <TYPE>` line and any
|
||||||
|
errors/warnings/tracebacks.
|
||||||
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: true
|
||||||
|
contact_links:
|
||||||
|
- name: NRS Discussions
|
||||||
|
url: https://github.com/Reithan/negative_rejection_steering/discussions
|
||||||
|
about: Ask questions or discuss ideas that aren't a bug report.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
name: Publish to Comfy registry
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- "pyproject.toml"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-node:
|
||||||
|
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@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
|
||||||
|
if: steps.version_check.outputs.should_publish == 'true'
|
||||||
|
uses: Comfy-Org/publish-node-action@main
|
||||||
|
with:
|
||||||
|
personal_access_token: ${{ secrets.COMFY_REGISTRY_KEY }}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Test suite with branch coverage gate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
|
- name: Install real CPU torch
|
||||||
|
run: pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||||
|
|
||||||
|
- name: Install test tooling
|
||||||
|
run: pip install pytest pytest-cov diff-cover
|
||||||
|
|
||||||
|
- name: Run tests with branch coverage
|
||||||
|
run: pytest --cov=NRS --cov-branch --cov-report=xml --cov-report=term-missing
|
||||||
|
|
||||||
|
- name: Determine base branch for diff-cover
|
||||||
|
id: base
|
||||||
|
run: |
|
||||||
|
base_ref="origin/main"
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ] && git rev-parse --verify "origin/${{ github.base_ref }}" >/dev/null 2>&1; then
|
||||||
|
base_ref="origin/${{ github.base_ref }}"
|
||||||
|
fi
|
||||||
|
echo "ref=${base_ref}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Enforce 90% branch coverage on changed code
|
||||||
|
run: diff-cover coverage.xml --compare-branch=${{ steps.base.outputs.ref }} --branch-coverage --fail-under=90
|
||||||
@@ -170,5 +170,13 @@ cython_debug/
|
|||||||
# Ruff stuff:
|
# Ruff stuff:
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
|
||||||
|
# pre-commit stuff:
|
||||||
|
.pre-commit-cache/
|
||||||
|
|
||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
|
||||||
|
# AI agents
|
||||||
|
.claude/settings.local.json
|
||||||
|
.claude/docs/**
|
||||||
|
|||||||
@@ -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 with branch-coverage gate
|
||||||
|
entry: bash -c 'if ! command -v uv > /dev/null 2>&1; then echo "WARNING - uv not found, skipping tests and coverage gate"; exit 0; fi; if ! git rev-parse --verify --quiet origin/main > /dev/null 2>&1; then echo "ERROR - origin/main not resolvable locally; fetch origin main and retry"; exit 1; fi; uvx --with torch --with pytest-cov --with diff-cover pytest --cov=NRS --cov-branch --cov-report=xml --cov-report=term-missing tests/; status=$?; if [ $status -ne 0 ]; then rm -f coverage.xml .coverage; exit $status; fi; uvx --with diff-cover diff-cover coverage.xml --compare-branch=origin/main --branch-coverage --fail-under=90; cov_status=$?; rm -f coverage.xml .coverage; exit $cov_status'
|
||||||
|
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
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## [1.1.0] - Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **X0 (sample) prediction support** (#44): x0-prediction models are now handled through the shared v-prediction-space path, alongside EPS and v-pred.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Correct v-space conversion for all variance-preserving parameterizations** (#44). The sampler hook delivers `cond`/`uncond` as `x - x0` for EPS, v-pred, and x0 alike, so NRS now recovers the true velocity `v = (cond - A)/factor` and runs its geometry in v-prediction space, then inverts exactly on return. This replaces the prior EPS-only affine, which operated on an incorrect input-space assumption. Flow-matching (FLOW/CONST) models remain operated natively — their prediction is already a pure scalar multiple of the velocity, so no conversion is applied.
|
||||||
|
|
||||||
|
### Changed / Upgrade notes
|
||||||
|
|
||||||
|
- **Default parameters changed** 2/5/0.75 → **2/4/0.5** (Skew/Stretch/Squash) in both the ComfyUI node and the A1111-family (Forge/reForge/Forge Neo) script.
|
||||||
|
- **v-prediction models now run the v-space conversion** instead of operating on the raw guidance. For typical config ranges the output change is expected to be minimal (verified on EPS; v-pred/x0 are math-validated but **not yet image-validated** — spot-check and retune if needed).
|
||||||
|
- **Reproducibility note:** the same seed + config may produce a slightly different image than 1.0.0 because of the corrected v-space handling and the new defaults.
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-08-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Flow-family (flow-matching) models now use a dedicated FLOW prediction/operation space (#37) so NRS applies the correct guidance geometry to them. Previously these models were misclassified, causing NRS to operate on an incorrect prediction-type assumption and underperform. This is the headline fix in 1.0.0.
|
||||||
|
- Pack-aware per-stream NRS routing with a degeneracy tripwire (#36): NRS now unpacks multi-stream packed latents (e.g. MiniMax H3 audio+video) and applies the geometry per stream on the real channel axis, instead of collapsing to a silent no-op on the flat packed latent.
|
||||||
|
- Prediction-type detection for WAN / RES4LYF samplers (#30).
|
||||||
|
- Removed a mangled guard and dead operation-space code paths; added prediction-type detection tests (#34).
|
||||||
|
- Resolved Node.js 20 deprecation warnings in GitHub Actions.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `__version__` string plus a patch-time log line announcing the version and detected prediction type; platform-agnostic GitHub issue template (#39).
|
||||||
|
- CI: full test suite with a >90% branch-coverage gate on diffs (#38); version-increment check in the publish workflow (#33); git hooks and development infrastructure (#32).
|
||||||
|
- Declared `requires-python` (>=3.10) so dependency locking is deterministic across environments.
|
||||||
|
|
||||||
|
### Changed / Upgrade notes
|
||||||
|
|
||||||
|
- Because flow-family models now use the correct FLOW space, NRS output for these models changes (for the better). Existing users of flow-matching models should retune Skew/Stretch/Squash. The `pre-flow` git tag preserves the prior behavior if a rollback is needed.
|
||||||
+266
@@ -0,0 +1,266 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
The pre-push hook runs the full test suite with branch coverage and blocks
|
||||||
|
the push if changed code drops below 90% branch coverage (via pytest-cov +
|
||||||
|
diff-cover, mirroring CI). It needs `uv` installed — if `uv` isn't found,
|
||||||
|
the check is skipped with a warning — and `origin/main` fetched locally so
|
||||||
|
there's something to diff against.
|
||||||
|
|
||||||
|
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!
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.8 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.7 MiB |
@@ -0,0 +1,10 @@
|
|||||||
|
"""NRS package init.
|
||||||
|
|
||||||
|
Re-exports `__version__` from nodes_NRS.py so `NRS.__version__` is
|
||||||
|
importable. This value must match the `version` field in pyproject.toml —
|
||||||
|
bump both together at release time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .nodes_NRS import __version__
|
||||||
|
|
||||||
|
__all__ = ["__version__"]
|
||||||
+322
-156
@@ -1,183 +1,349 @@
|
|||||||
import ldm_patched.modules.model_base
|
# ruff: noqa: N999 -- filename predates NRS/__init__.py; mixed-case "nodes_NRS"
|
||||||
|
# only became checkable once NRS became a regular (non-namespace) package here.
|
||||||
|
# Renaming it is out of scope (would break existing imports); pyproject.toml's
|
||||||
|
# per-file-ignores already carve out N802/N804 for this same file.
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
|
from enum import Enum, auto
|
||||||
|
|
||||||
import torch
|
import torch
|
||||||
|
|
||||||
|
try:
|
||||||
|
import comfy.utils as _comfy_utils
|
||||||
|
except Exception:
|
||||||
|
_comfy_utils = None
|
||||||
|
|
||||||
|
# Must be bumped together with the `version` field in pyproject.toml at release time.
|
||||||
|
__version__ = "1.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _unpack_latents(combined, latent_shapes):
|
||||||
|
"""Split a flat packed latent [B, 1, N] back into its per-stream tensors.
|
||||||
|
|
||||||
|
Mirrors comfy.utils.unpack_latents: for each shape in latent_shapes, take
|
||||||
|
math.prod(shape[1:]) elements off the last dim and reshape that [B, 1, n]
|
||||||
|
slice back to `shape`.
|
||||||
|
"""
|
||||||
|
streams = []
|
||||||
|
offset = 0
|
||||||
|
for shape in latent_shapes:
|
||||||
|
n = math.prod(shape[1:])
|
||||||
|
chunk = combined[:, :, offset : offset + n]
|
||||||
|
streams.append(chunk.reshape(shape))
|
||||||
|
offset += n
|
||||||
|
return streams
|
||||||
|
|
||||||
|
|
||||||
|
def _pack_latents(streams):
|
||||||
|
"""Pack a list of per-stream tensors [B, C, ...] into a flat [B, 1, N] tensor.
|
||||||
|
|
||||||
|
Mirrors comfy.utils.pack_latents: each stream is reshaped to (B, 1, -1)
|
||||||
|
and concatenated on the last dim.
|
||||||
|
"""
|
||||||
|
flat = [s.reshape(s.shape[0], 1, -1) for s in streams]
|
||||||
|
return torch.cat(flat, dim=-1)
|
||||||
|
|
||||||
|
|
||||||
|
# fmt: off
|
||||||
|
class PredictionType(Enum):
|
||||||
|
EPS = auto() # ε-prediction
|
||||||
|
V = auto() # v-prediction
|
||||||
|
X0 = auto() # x₀-prediction
|
||||||
|
FLOW = auto() # flow-matching / velocity — operated natively, no VP conversion
|
||||||
|
UNKNOWN = auto() # couldn’t detect / new scheduler
|
||||||
|
|
||||||
|
|
||||||
|
_RAW_TO_ENUM = {
|
||||||
|
"eps": PredictionType.EPS,
|
||||||
|
"epsilon": PredictionType.EPS,
|
||||||
|
"flux": PredictionType.FLOW,
|
||||||
|
"chroma": PredictionType.FLOW,
|
||||||
|
"flow": PredictionType.FLOW, # FLOW models (WAN, etc.) operated natively
|
||||||
|
"wan": PredictionType.FLOW, # WAN21 is FLOW-based
|
||||||
|
"const": PredictionType.FLOW, # CONST prediction class used in FLOW models
|
||||||
|
"v": PredictionType.V,
|
||||||
|
"v_prediction": PredictionType.V,
|
||||||
|
"x0": PredictionType.X0,
|
||||||
|
"sample": PredictionType.X0,
|
||||||
|
}
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
|
|
||||||
class NRS:
|
class NRS:
|
||||||
@classmethod
|
@classmethod
|
||||||
def INPUT_TYPES(s):
|
def INPUT_TYPES(s):
|
||||||
return {"required": { "model": ("MODEL",),
|
return {
|
||||||
"squash": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
|
"required": {
|
||||||
"stretch": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 30.0, "step": 0.01}),
|
"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": 4.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.50,
|
||||||
|
"min": 0.0,
|
||||||
|
"max": 1.0,
|
||||||
|
"step": 0.01,
|
||||||
|
"tooltip": "Softens Skew/Stretch effects, adding micro-detailing. Keep low initially.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
RETURN_TYPES = ("MODEL",)
|
RETURN_TYPES = ("MODEL",)
|
||||||
FUNCTION = "patch"
|
FUNCTION = "patch"
|
||||||
|
|
||||||
CATEGORY = "advanced/model"
|
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
|
||||||
|
and various loaders.
|
||||||
|
Walk common wrappers until we find something that looks like a
|
||||||
|
prediction-type flag, then map it to the enum.
|
||||||
|
Defaults to EPS if all else fails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _canon(p):
|
||||||
|
if p is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(p, bytes):
|
||||||
|
p = p.decode(errors="ignore")
|
||||||
|
if isinstance(p, Enum):
|
||||||
|
p = p.name
|
||||||
|
return str(p).strip().lower()
|
||||||
|
|
||||||
|
# Breadth-first search through a few well-known wrappers.
|
||||||
|
queue, seen = [model], set()
|
||||||
|
|
||||||
|
while queue:
|
||||||
|
obj = queue.pop(0)
|
||||||
|
|
||||||
|
# 1) direct hit on this object ---------------------------------
|
||||||
|
for attr in ("model_type", "prediction_type", "parameterization"):
|
||||||
|
p = _canon(getattr(obj, attr, None))
|
||||||
|
if p:
|
||||||
|
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"):
|
||||||
|
child = getattr(obj, attr, None)
|
||||||
|
if child is not None and id(child) not in seen:
|
||||||
|
seen.add(id(child))
|
||||||
|
queue.append(child)
|
||||||
|
|
||||||
|
# 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}")
|
||||||
|
|
||||||
|
# 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 -> FLOW")
|
||||||
|
return PredictionType.FLOW
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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 -> FLOW")
|
||||||
|
return PredictionType.FLOW
|
||||||
|
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 _is_vp(self, pred_type):
|
||||||
|
"""VP (variance-preserving) parameterizations converted to v-space: EPS, V, X0.
|
||||||
|
|
||||||
|
UNKNOWN (and any unhandled type) falls back to VP/v-space. FLOW/CONST is the only
|
||||||
|
parameterization operated natively (see _convert_to_v_space).
|
||||||
|
"""
|
||||||
|
if pred_type in (PredictionType.EPS, PredictionType.V, PredictionType.X0):
|
||||||
|
return True
|
||||||
|
if pred_type == PredictionType.FLOW:
|
||||||
|
return False
|
||||||
|
logging.warning(f"NRS: unknown prediction type {pred_type}, treating as VP (v-space)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _convert_to_v_space(self, x_orig, sig_root, sigma, cond, uncond, pred_type):
|
||||||
|
"""Convert the (x - x0) guidance vectors into v-prediction space before the NRS geometry.
|
||||||
|
|
||||||
|
The sampler hook delivers cond/uncond as `x - x0` for every parameterization (the
|
||||||
|
model's raw output is converted to a denoised x0 before NRS sees it), so the true
|
||||||
|
velocity is recovered the same way regardless of EPS/V/X0:
|
||||||
|
v = (cond - A)/factor = (x/(sigma^2+1) - x0) * sig_root/sigma
|
||||||
|
with A = x*sigma^2/(sigma^2+1), factor = sigma/sqrt(sigma^2+1).
|
||||||
|
|
||||||
|
FLOW/CONST is operated natively: `x - x0 = sigma*out` is a pure scalar multiple of
|
||||||
|
the model's velocity (no additive offset), and the NRS geometry is scale-invariant,
|
||||||
|
so identity already runs on the native prediction. There is no VP v-space for
|
||||||
|
flow-matching (its sigma is a [0,1] flow time, not a VP karras sigma).
|
||||||
|
"""
|
||||||
|
if not self._is_vp(pred_type):
|
||||||
|
logging.debug("NRS._convert_to_v_space: flow/const operated natively (identity)")
|
||||||
|
return cond, uncond
|
||||||
|
|
||||||
|
logging.debug("NRS._convert_to_v_space: converting VP prediction to v-space")
|
||||||
|
factor = sigma / sig_root
|
||||||
|
a_off = x_orig - x_orig / (sigma**2 + 1) # A = x*sigma^2/(sigma^2+1)
|
||||||
|
return (cond - a_off) / factor, (uncond - a_off) / factor
|
||||||
|
|
||||||
|
def _finalize_from_v_space(self, x_orig, x_final, sig_root, sigma, pred_type):
|
||||||
|
"""Invert _convert_to_v_space so the hook returns `x - x0_final`. Round-trips exactly."""
|
||||||
|
if not self._is_vp(pred_type):
|
||||||
|
logging.debug("NRS._finalize_from_v_space: flow/const operated natively (identity)")
|
||||||
|
return x_final
|
||||||
|
|
||||||
|
factor = sigma / sig_root
|
||||||
|
a_off = x_orig - x_orig / (sigma**2 + 1)
|
||||||
|
return a_off + x_final * factor
|
||||||
|
|
||||||
|
def _apply_guidance(self, x_orig, cond, uncond, sigma, skew, stretch, squash, pred_type):
|
||||||
|
"""Run the NRS geometry pipeline on a single (already-unpacked, channels-first) stream."""
|
||||||
|
sigma = sigma.view(sigma.shape[:1] + (1,) * (cond.ndim - 1))
|
||||||
|
sig_root = (sigma**2 + 1).sqrt()
|
||||||
|
|
||||||
|
# Convert (x - x0) guidance into v-space for all VP parameterizations (EPS/V/X0);
|
||||||
|
# FLOW/CONST runs natively.
|
||||||
|
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]
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
# Amplify Cond based on length compared to projection of uncond
|
||||||
|
proj_diff = nrs_cond - u_on_c
|
||||||
|
stretched = nrs_cond + (stretch * proj_diff)
|
||||||
|
|
||||||
|
# Skew/Steer Conf based on rejection of uncond on cond
|
||||||
|
u_rej_c = nrs_uncond - u_on_c
|
||||||
|
skewed = stretched - (skew * u_rej_c)
|
||||||
|
|
||||||
|
# Squash final length back down to original length of cond
|
||||||
|
cond_len = nrs_cond.norm(dim=1, keepdim=True)
|
||||||
|
nrs_len = skewed.norm(dim=1, keepdim=True) + eps
|
||||||
|
|
||||||
|
squash_scale = (1 - squash) + (squash * (cond_len / nrs_len))
|
||||||
|
x_final = skewed * squash_scale
|
||||||
|
|
||||||
|
return self._finalize_from_v_space(x_orig, x_final, sig_root, sigma, pred_type)
|
||||||
|
|
||||||
def patch(self, model, skew, stretch, squash):
|
def patch(self, model, skew, stretch, squash):
|
||||||
|
pred_type = self._get_pred_type(model)
|
||||||
|
logging.info(f"NRS v{__version__}: prediction type detected -> {pred_type.name}")
|
||||||
|
warned = {"done": False}
|
||||||
|
|
||||||
def nrs(args):
|
def nrs(args):
|
||||||
|
logging.debug(f"NRS.nrs: Skew: {skew}, Stretch: {stretch}, Squash: {squash}")
|
||||||
cond = args["cond"]
|
cond = args["cond"]
|
||||||
uncond = args["uncond"]
|
uncond = args["uncond"]
|
||||||
sigma = args["sigma"]
|
|
||||||
sigma = sigma.view(sigma.shape[:1] + (1,) * (cond.ndim - 1))
|
|
||||||
x_orig = args["input"]
|
x_orig = args["input"]
|
||||||
|
sigma = args["sigma"]
|
||||||
|
|
||||||
logging.debug(f"NRS.nrs: Skew: {skew}, Stretch: {stretch}, Squash: {squash}")
|
shapes = getattr(args["model"], "latent_shapes", None)
|
||||||
|
if shapes and len(shapes) > 1:
|
||||||
|
if _comfy_utils is not None and hasattr(_comfy_utils, "unpack_latents"):
|
||||||
|
cond_streams = _comfy_utils.unpack_latents(cond, shapes)
|
||||||
|
uncond_streams = _comfy_utils.unpack_latents(uncond, shapes)
|
||||||
|
x_streams = _comfy_utils.unpack_latents(x_orig, shapes)
|
||||||
|
else:
|
||||||
|
cond_streams = _unpack_latents(cond, shapes)
|
||||||
|
uncond_streams = _unpack_latents(uncond, shapes)
|
||||||
|
x_streams = _unpack_latents(x_orig, shapes)
|
||||||
|
else:
|
||||||
|
cond_streams, uncond_streams, x_streams = [cond], [uncond], [x_orig]
|
||||||
|
|
||||||
#rescale cfg has to be done on v-pred model output
|
if not warned["done"]:
|
||||||
x = x_orig / (sigma * sigma + 1.0)
|
for stream in x_streams:
|
||||||
cond = ((x - (x_orig - cond)) * (sigma ** 2 + 1.0) ** 0.5) / (sigma)
|
if stream.shape[1] == 1:
|
||||||
uncond = ((x - (x_orig - uncond)) * (sigma ** 2 + 1.0) ** 0.5) / (sigma)
|
logging.warning(
|
||||||
logging.debug(f"NRS.nrs: generated cond and uncond")
|
f"NRS.nrs: routed stream has a singleton reduction axis {tuple(stream.shape)}; "
|
||||||
|
"NRS geometry (dot/proj/skew) will degenerate to a no-op on this stream."
|
||||||
|
)
|
||||||
|
warned["done"] = True
|
||||||
|
break
|
||||||
|
|
||||||
x_final = None
|
results = [
|
||||||
match "v0.4.5":
|
self._apply_guidance(
|
||||||
case "v1":
|
x_streams[i],
|
||||||
# displace cond by rejection of uncond on cond
|
cond_streams[i],
|
||||||
u_dot_c = torch.sum(uncond * cond, dim=-1, keepdim=True)
|
uncond_streams[i],
|
||||||
c_dot_c = torch.sum(cond * cond, dim=-1, keepdim=True)
|
sigma,
|
||||||
u_on_c = (u_dot_c / c_dot_c) * cond
|
skew,
|
||||||
u_rej_c = uncond - u_on_c
|
stretch,
|
||||||
displaced = (cond - skew * u_rej_c)
|
squash,
|
||||||
logging.debug(f"NRS.nrs: displaced")
|
pred_type,
|
||||||
|
)
|
||||||
|
for i in range(len(cond_streams))
|
||||||
|
]
|
||||||
|
|
||||||
# squash displaced vector towards len(cond) based on squash scale
|
if len(results) == 1:
|
||||||
d_len_sq = torch.sum(displaced * displaced, dim=-1, keepdim=True)
|
return results[0]
|
||||||
squash_scale = (1 - squash) + squash * ((c_dot_c/d_len_sq) ** 0.5)
|
|
||||||
squashed = displaced * squash_scale
|
|
||||||
logging.debug(f"NRS.nrs: squashed")
|
|
||||||
|
|
||||||
# stretch turned vector towards cond based on stretch scale
|
if _comfy_utils is not None and hasattr(_comfy_utils, "pack_latents"):
|
||||||
sq_dot_c = torch.sum(squashed * cond, dim=-1, keepdim=True)
|
return _comfy_utils.pack_latents(results)[0]
|
||||||
sq_on_c = (sq_dot_c / c_dot_c) * cond
|
return _pack_latents(results)
|
||||||
x_final = squashed + sq_on_c * stretch
|
|
||||||
logging.debug(f"NRS.nrs: final")
|
|
||||||
case "v2":
|
|
||||||
# displace cond by rejection of uncond on cond
|
|
||||||
u_dot_c = torch.sum(uncond * cond, dim=-1, keepdim=True)
|
|
||||||
c_dot_c = torch.sum(cond * cond, dim=-1, keepdim=True)
|
|
||||||
u_on_c_mag = (u_dot_c / c_dot_c)
|
|
||||||
u_on_c = u_on_c_mag * cond
|
|
||||||
u_rej_c = uncond - u_on_c
|
|
||||||
displaced = cond + stretch * (cond - torch.clamp(u_dot_c / c_dot_c, min=0, max=1) * cond) - skew * u_rej_c
|
|
||||||
logging.debug(f"NRS.nrs: displaced & stretched")
|
|
||||||
|
|
||||||
# squash displaced vector towards len(cond) based on squash scale
|
|
||||||
d_len_sq = torch.sum(displaced * displaced, dim=-1, keepdim=True)
|
|
||||||
squash_scale = (1 - squash) + squash * ((c_dot_c/d_len_sq) ** 0.5)
|
|
||||||
x_final = displaced * squash_scale
|
|
||||||
logging.debug(f"NRS.nrs: final")
|
|
||||||
case "v3":
|
|
||||||
# displace cond by rejection of uncond on cond
|
|
||||||
u_dot_c = torch.sum(uncond * cond, dim=-1, keepdim=True)
|
|
||||||
c_dot_c = torch.sum(cond * cond, dim=-1, keepdim=True)
|
|
||||||
u_on_c_mag = (u_dot_c / c_dot_c)
|
|
||||||
u_on_c = u_on_c_mag * cond
|
|
||||||
u_rej_c = uncond - u_on_c
|
|
||||||
displaced = (cond - skew * u_rej_c)
|
|
||||||
logging.debug(f"NRS.nrs: displaced")
|
|
||||||
|
|
||||||
# squash displaced vector towards len(cond) based on squash scale
|
|
||||||
d_len_sq = torch.sum(displaced * displaced, dim=-1, keepdim=True)
|
|
||||||
squash_scale = (1 - squash) + squash * ((c_dot_c/d_len_sq) ** 0.5)
|
|
||||||
|
|
||||||
# stretch vector towards 2*len(cond) - len(u_on_c)
|
|
||||||
c_len = c_dot_c ** 0.5
|
|
||||||
stretch_scale = (1 - stretch) + stretch * (2 * c_len - u_on_c_mag)/c_len
|
|
||||||
|
|
||||||
x_final = displaced * squash_scale * stretch_scale
|
|
||||||
logging.debug(f"NRS.nrs: final")
|
|
||||||
case "v4":
|
|
||||||
u_dot_c = torch.sum(uncond * cond, dim=-1, keepdim=True)
|
|
||||||
c_dot_c = torch.sum(cond * cond, dim=-1, keepdim=True)
|
|
||||||
u_on_c_mag = (u_dot_c / c_dot_c)
|
|
||||||
u_on_c = u_on_c_mag * cond
|
|
||||||
u_rej_c = uncond - u_on_c
|
|
||||||
rej_dor_rej = torch.sum(u_rej_c * u_rej_c, dim=-1, keepdim=True)
|
|
||||||
x_final = (cond - squash * u_rej_c + stretch * cond * ((rej_dor_rej/c_dot_c) ** 0.5))
|
|
||||||
logging.debug(f"NRS.nrs: displaced")
|
|
||||||
case "v0.4.1":
|
|
||||||
u_dot_c = torch.sum(uncond * cond, dim=-1, keepdim=True)
|
|
||||||
c_dot_c = torch.sum(cond * cond, dim=-1, keepdim=True)
|
|
||||||
u_on_c_mag = (u_dot_c / c_dot_c)
|
|
||||||
u_on_c = u_on_c_mag * cond
|
|
||||||
u_rej_c = uncond - u_on_c
|
|
||||||
rej_dor_rej = torch.sum(u_rej_c * u_rej_c, dim=-1, keepdim=True)
|
|
||||||
stretched = cond + stretch * cond * ((rej_dor_rej/c_dot_c) ** 0.5)
|
|
||||||
skewed = stretched - skew * u_rej_c
|
|
||||||
sk_dot_sk = torch.sum(skewed * skewed, dim=-1, keepdim=True)
|
|
||||||
squash_scale = (1 - squash) + squash * ((c_dot_c/sk_dot_sk) ** 0.5)
|
|
||||||
x_final = skewed * squash_scale
|
|
||||||
logging.debug(f"NRS.nrs: displaced")
|
|
||||||
case "v0.4.2":
|
|
||||||
u_dot_c = torch.sum(uncond * cond, dim=-1, keepdim=True)
|
|
||||||
c_dot_c = torch.sum(cond * cond, dim=-1, keepdim=True)
|
|
||||||
u_on_c_mag = (u_dot_c / c_dot_c)
|
|
||||||
u_on_c = u_on_c_mag * cond
|
|
||||||
u_rej_c = uncond - u_on_c
|
|
||||||
proj_len = torch.sum(u_on_c * u_on_c, dim=-1, keepdim=True) ** 0.5
|
|
||||||
cond_len = c_dot_c ** 0.5
|
|
||||||
stretched = cond * (1 + stretch * torch.abs(cond_len - proj_len) / cond_len)
|
|
||||||
skewed = stretched - skew * u_rej_c
|
|
||||||
sk_dot_sk = torch.sum(skewed * skewed, dim=-1, keepdim=True)
|
|
||||||
squash_scale = (1 - squash) + squash * cond_len / (sk_dot_sk ** 0.5)
|
|
||||||
x_final = skewed * squash_scale
|
|
||||||
logging.debug(f"NRS.nrs: displaced")
|
|
||||||
case "v0.4.3":
|
|
||||||
u_dot_c = torch.sum(uncond * cond, dim=-1, keepdim=True)
|
|
||||||
c_dot_c = torch.sum(cond * cond, dim=-1, keepdim=True)
|
|
||||||
u_on_c_mag = (u_dot_c / c_dot_c)
|
|
||||||
u_on_c = u_on_c_mag * cond
|
|
||||||
u_rej_c = uncond - u_on_c
|
|
||||||
proj_len = torch.sum(u_on_c * u_on_c, dim=-1, keepdim=True) ** 0.5
|
|
||||||
cond_len = c_dot_c ** 0.5
|
|
||||||
stretched = cond * (1 + stretch * (cond_len - proj_len) / cond_len)
|
|
||||||
skewed = stretched - skew * u_rej_c
|
|
||||||
sk_dot_sk = torch.sum(skewed * skewed, dim=-1, keepdim=True)
|
|
||||||
squash_scale = (1 - squash) + squash * cond_len / (sk_dot_sk ** 0.5)
|
|
||||||
x_final = skewed * squash_scale
|
|
||||||
logging.debug(f"NRS.nrs: displaced")
|
|
||||||
case "v0.4.4":
|
|
||||||
u_dot_c = torch.sum(uncond * cond, dim=-1, keepdim=True)
|
|
||||||
c_dot_c = torch.sum(cond * cond, dim=-1, keepdim=True)
|
|
||||||
u_on_c_mag = (u_dot_c / c_dot_c)
|
|
||||||
u_on_c = u_on_c_mag * cond
|
|
||||||
u_rej_c = uncond - u_on_c
|
|
||||||
cond_len = c_dot_c ** 0.5
|
|
||||||
proj_diff = cond - u_on_c
|
|
||||||
proj_diff_len = torch.sum(proj_diff * proj_diff, dim=-1, keepdim=True) ** 0.5
|
|
||||||
stretched = cond * (1 + stretch * proj_diff_len / cond_len)
|
|
||||||
skewed = stretched - skew * u_rej_c
|
|
||||||
sk_dot_sk = torch.sum(skewed * skewed, dim=-1, keepdim=True)
|
|
||||||
squash_scale = (1 - squash) + squash * cond_len / (sk_dot_sk ** 0.5)
|
|
||||||
x_final = skewed * squash_scale
|
|
||||||
logging.debug(f"NRS.nrs: displaced")
|
|
||||||
case "v0.4.5":
|
|
||||||
u_dot_c = torch.sum(uncond * cond, dim=-1, keepdim=True)
|
|
||||||
c_dot_c = torch.sum(cond * cond, dim=-1, keepdim=True)
|
|
||||||
u_on_c_mag = (u_dot_c / c_dot_c)
|
|
||||||
u_on_c = u_on_c_mag * cond
|
|
||||||
u_rej_c = uncond - u_on_c
|
|
||||||
cond_len = c_dot_c ** 0.5
|
|
||||||
proj_diff = cond - u_on_c
|
|
||||||
|
|
||||||
# Amplify Cond based on length compared to projection of uncond
|
|
||||||
stretched = cond + (stretch * proj_diff)
|
|
||||||
|
|
||||||
# Skew/Steer Conf based on rejection of uncond on cond
|
|
||||||
skewed = stretched - skew * u_rej_c
|
|
||||||
|
|
||||||
# Squash final length back down to original length of cond
|
|
||||||
sk_dot_sk = torch.sum(skewed * skewed, dim=-1, keepdim=True)
|
|
||||||
squash_scale = (1 - squash) + squash * cond_len / (sk_dot_sk ** 0.5)
|
|
||||||
x_final = skewed * squash_scale
|
|
||||||
|
|
||||||
return x_orig - (x - x_final * sigma / (sigma * sigma + 1.0) ** 0.5)
|
|
||||||
|
|
||||||
m = model.clone()
|
m = model.clone()
|
||||||
m.set_model_sampler_cfg_function(nrs, True)
|
m.set_model_sampler_cfg_function(nrs, True)
|
||||||
return (m, )
|
return (m,)
|
||||||
|
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
"NRS": NRS,
|
"NRS": NRS,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,134 @@
|
|||||||
|
[](https://github.com/Reithan/negative_rejection_steering/actions/workflows/github-code-scanning/codeql)
|
||||||
|
[](https://registry.comfy.org/nodes/negative_rejection_steering)
|
||||||
|
|
||||||
# Negative Rejection Steering
|
# Negative Rejection Steering
|
||||||
NRS seeks to replace the 'naive' linear interpolation of Classifier Free Guidance with a more nuanced steering of the generation process.
|
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.
|
||||||
|
|
||||||
This is accomplised in 3 steps:
|
#### _**TL;DR**_:
|
||||||
1. **Displacement**: The conditioned output tensor is displaced in the direction of the rejection of the unconditioned tensor on the conditioned tensor. This lengthens the tensor in a direction perpendicular to it's direction without affecting the positive guidance. The tensor is displaced by the rejection x the Displacement parameter.
|
1. CFG is a bad 'knob'
|
||||||
2. **Squashing**: The displaced tensor is rescaled towards the original length of the conditioned tensor. This means for high displacement scaling values the tensor 'turns' away from the unconditioned direction, which for very negative displacements, it turns towards the unconditioned tensor. 0 displacement outputs the original conditioned tensor.
|
2. NRS replaces CFG with 3 new knobs.
|
||||||
3. **Stretching**: The post-squash 'steered' tensor is stretched towards the direction of the original conditioned tensor. The more sharp the steering the less pronounced the stretch is, with fully aligned tensors being stretched the full stretch scale parameter. 1x stretch adds 100% length to the tensor.
|
3. NRS lets you to create cooler outputs than CFG.
|
||||||
|
|
||||||
# Alpha Release
|
**Contributing**: See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
|
||||||
Implements NRS with Skew, Stretch, and Squash parameters.
|
|
||||||
|
|
||||||
## Parameters
|
> [!TIP]
|
||||||
Skew and Stretch are roughly similar to CFG, but decomposed, with `Stretch + Skew = CFG`, roughly.
|
> Skip to the [Beginner How-To](#beginner-how-to) if you want to just get started.
|
||||||
|
|
||||||
**Skew** changes the 'direction' of generation, which should result in changes to the content and composition of the image.
|
### Math Demonstration
|
||||||
**Stretch** changes to 'amplification' of generation, which should result in stronger prompt representation.
|
<details>
|
||||||
**Squash** 'normalizes' the resulting guidance back towards the original amplitude with 1.0 being the same amplitude, while 0.0 is the unmodified amplitude resulting from the Squash and Stretch functions.
|
<summary>Expand for explanation of algorithm</summary>
|
||||||
|
<img align="right" src="Examples/NRS_graph.png" alt="Graph of NRS vs CFG" style="width: 40%; float: right;">
|
||||||
|
|
||||||
|
### NRS is Applied in Three Steps:
|
||||||
|
0. ***V-Space**: pre-NRS step* The sampler hands NRS its `cond`/`uncond` as `x - x0` for every variance-preserving parameterization (EPS, v-prediction, and x0-prediction alike), so NRS recovers the true velocity `v` from them and runs its geometry in v-prediction space, then inverts the transform before returning. This one v-space path handles EPS, v-pred, and x0 models identically. Flow-matching models (flux, chroma, wan, and other flow/CONST families) are operated natively — their prediction is already a pure scalar multiple of the velocity, so no v-space conversion is applied.
|
||||||
|
1. **Skewing**: The conditioned output tensor is skewed away from the direction of the rejection of the unconditioned tensor on the conditioned tensor. This lengthens the tensor in a direction perpendicular to its direction without affecting the positive guidance. The tensor is displaced by the rejection multiplied by the Skew parameter.[^1]
|
||||||
|
2. **Stretching**: The skewed tensor is stretched towards the direction of the original conditioned tensor based on its difference from the projection of uncond on cond. The stretch is multiplied by the Stretch parameter.[^1]
|
||||||
|
3. **Squashing**: The skewed and stretched tensor is rescaled towards the original length of the conditioned tensor. 100% squashing outputs the original length of the conditioned tensor simply 'steered' towards the skewed & squashed version's direction.[^1]
|
||||||
|
[^1]: All operations are done per feature across the step's batch, width, and height.
|
||||||
|
|
||||||
|
[Interactive Graph on Math3D.org](https://www.math3d.org/aTJW4UZtCh)
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Examples of NRS Effects
|
||||||
|
**Skew**
|
||||||
|

|
||||||
|
**Stretch**
|
||||||
|

|
||||||
|
**Squash**
|
||||||
|

|
||||||
|
<details>
|
||||||
|
<summary><small>Generation details for reproduction</small></summary>
|
||||||
|
|
||||||
|
| Prompt | |
|
||||||
|
| ---------- | --- |
|
||||||
|
| Tool | [Stable Diffusion WebUI reForge](https://github.com/Panchovix/stable-diffusion-webui-reForge) |
|
||||||
|
| Sampler | DPM++ 2M |
|
||||||
|
| Scheduler | Align Your Steps |
|
||||||
|
| Steps | 25 |
|
||||||
|
| Dimensions | 912 x 624 |
|
||||||
|
| Seed | `1334103348` |
|
||||||
|
| Model | [Lobotomized Mix v1.5](https://civitai.com/models/1144932) |
|
||||||
|
| Embeddings | [Lazy Embeddings for ALL illustrious NoobAI...](https://civitai.com/models/1302719), [Smooth Embeddings](https://civitai.com/models/1065154) |
|
||||||
|
| Positive | lazypos, [Smooth_Quality\|SmoothNoob_Quality], BREAK<br>very awa, masterpiece, best quality, year 2024, newest, highres, absurdres,<br>1girl, samurai archer, cyberpunk cityscape, rain-soaked rooftop, neon reflection puddles, volumetric mist,<br>photorealistic, digital art,<br>dramatic rim lighting, shallow depth of field, low angle viewpoint |
|
||||||
|
| Negative | lazyloli, lazynsfw, BREAK<br>lazyhand, SmoothNegative_Hands-neg, BREAK<br>[Smooth_Negative-neg\|SmoothNoob_Negative-neg], BREAK<br>lowres, worst quality, worst aesthetic, bad quality, jpeg artifacts, scan artifacts,<br>blurry, deformed anatomy, bad hands, extra fingers, missing fingers, mutated hands,<br>watermark, logo, text, nsfw |
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Explanation of Effects
|
||||||
|
#### Skew
|
||||||
|
**Skew** changes the 'direction' of your generation, altering the image generation to 'steer' away from negative prompt elements as they conflict with your positive prompt. Increasing Skew will change scene composition, geometry, and scene elements to ensure that the final image aligns with the intention of your prompt pair.
|
||||||
|
#### Stretch
|
||||||
|
**Stretch** changes the intensity of generated elements that align more with your positive prompt than the negative. This 'hits the gas' on any elements that are more strongly aligned with your positive prompt than your negative, and 'hit the brakes' on the opposite.
|
||||||
|
#### Squash
|
||||||
|
**Squash** is the speed limit. At 0.0 Squash, each diffusion step receives the full intensity you set from Skew and Stretch, while 1.0 Squash ensures each step has only the original step size output by the model. This setting will only remove intensity unless you have a non-zero Skew value. Squash will 'soften' the effects of Skew and Stretch as it's raised, but the 'removed' Skew and Stretch intensity is replaced by enhanced micro-detailing and 'burn'. Squash should generally be left low and used as a 'finishing' step after dialing in a decent Skew and Stretch value.
|
||||||
|
|
||||||
## Beginner How-To
|
## Beginner How-To
|
||||||
1. Set Squash to 0.0
|
1. Set Skew to 1/2 of your normal CFG Scale setting and Stretch to your full normal CFG Scale. Set Squash to 0.0.<br>
|
||||||
2. Set Skew & Stretch each to 1/2 your normal CFG Scale setting
|
*Alternatively, try starting with the default of 2/4/0.5, or at 1/1/1 to get a baseline.*
|
||||||
3. Test some generation. Results should be 'similar' in quality to CFG
|
2. Test some outputs. Results should be similar in quality to CFG.
|
||||||
4. Adjust Skew up/down to change content and composition
|
3. Adjust Skew to change the intensity of your outputs adherence to your positive and negative prompts. This primarily effects composition of the output.
|
||||||
5. Adjust Stretch up/down to change strength of image aspects and colors
|
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.
|
||||||
6. Adjust Squash up to remove artifacts and color burn (these will tend to be replaced by additional or extraneous details and elements)
|
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**: You can experiment with negative values for Skew and Stretch as well to see what the model 'believes' your negative prompt 'means'.
|
> [!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]
|
||||||
|
> 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.
|
||||||
|
|
||||||
|

|
||||||
|
</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.
|
||||||
|
|
||||||
|
### NRS for Video
|
||||||
|
When using NRS with **video** models (e.g. MiniMax H3), two things need to be turned off or output quality suffers:
|
||||||
|
- **Caching accelerators** (EasyCache, TeaCache, etc.) — their change-thresholded caching skips model evaluations that NRS relies on. With NRS active, this causes motion stutter and audio artifacts.
|
||||||
|
- **Multistep samplers** (`res_multistep`, `dpmpp_2m`, `dpmpp_3m_sde`, and other history/"m" samplers) — they extrapolate NRS's guidance across steps, compounding instability over the clip. Use a memoryless sampler instead; **`euler_ancestral` is recommended** (`euler` and `heun` also work well).
|
||||||
|
|
||||||
|
NRS adds a second inference pass per step, like CFG, so video generation time increases accordingly. Consider reserving NRS for final generations or prompts that need extra adherence.
|
||||||
|
|
||||||
|
These caveats are video-specific — 2D image generation is unaffected.
|
||||||
|
|
||||||
|
## Submitted User Examples
|
||||||
|
| User | CFG | NRS |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Mohnjiles from StabilityMatrix |  |  |
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from .NRS.nodes_NRS import *
|
||||||
|
|
||||||
|
NODE_CLASS_MAPPINGS = {"NRS": NRS}
|
||||||
|
NODE_DISPLAY_NAME_MAPPINGS = {"NRS": "Negative Rejection Steering"}
|
||||||
|
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# The 'purpose' or 'intention' behind our 3 knobs
|
||||||
|
|
||||||
|
## SKEW
|
||||||
|
This is the primary 'steering' knob. This will 'turn' the 'direction' the current denoising step is traveling in the latent space. If we define the 'default' (cond) direction as 'prior step -> cond' then we're just applying a 'lateral' skew to that direction to 'turn' it 'away' from the 'unintended' direction (uncond)
|
||||||
|
|
||||||
|
## STRETCH
|
||||||
|
This is the sister knob to Skew. This is the accelerator. We want to go 'faster' into the intended direction (cond) the less aligned it is with the unintended direction (uncond). Think of this like a combination of brakes + gas. If we're headed directly for a brick wall (uncond is in the same direction as cond), we want to apply no acceleration, or negative acceleration. If we're traveling directly away from danger (uncond is in the opposite direction of cond) then we want to stomp the gas and get as far away as we can. There's only 1 problem with this BASIC-level description: as we get further into generation, regardless of pos/neg promp, cond & uncon will naturally align to be the same vector[^1]. In the last stop of inference, cond and uncond will be basically identical if nothing has fucked up. So whatever math we apply here needs to take the progressive alignment of cond & uncond into account. That's why were/are scaling only on the projection difference right now, rather than the full projection.
|
||||||
|
[^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.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
[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 = "1.1.0"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
license = {file = "LICENSE"}
|
||||||
|
readme = "README.md"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
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"]
|
||||||
@@ -1,46 +1,63 @@
|
|||||||
import gradio as gr
|
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
import traceback
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from modules import scripts, script_callbacks
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import gradio as gr
|
||||||
|
from modules import script_callbacks, scripts
|
||||||
|
|
||||||
from NRS.nodes_NRS import NRS
|
from NRS.nodes_NRS import NRS
|
||||||
|
|
||||||
|
|
||||||
class NRSScript(scripts.Script):
|
class NRSScript(scripts.Script):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.enabled = False
|
self.enabled = False
|
||||||
self.skew = 2.0
|
self.skew = 2.00
|
||||||
self.stretch = 2.0
|
self.stretch = 4.00
|
||||||
self.squash = 1.0
|
self.squash = 0.50
|
||||||
|
|
||||||
sorting_priority = 5
|
sorting_priority = 5
|
||||||
|
|
||||||
def title(self):
|
def title(self):
|
||||||
return "Negative Rejection Steering for reForge"
|
return "Negative Rejection Steering"
|
||||||
|
|
||||||
def show(self, is_img2img):
|
def show(self, is_img2img):
|
||||||
return scripts.AlwaysVisible
|
return scripts.AlwaysVisible
|
||||||
|
|
||||||
def ui(self, *args, **kwargs):
|
def ui(self, *args, **kwargs):
|
||||||
with gr.Accordion(open=False, label=self.title()):
|
with gr.Accordion(open=False, label=self.title()):
|
||||||
gr.HTML("<p><i>Adjust the settings for Negative Rejection Steering.</i></p>")
|
|
||||||
enabled = gr.Checkbox(label="Enable NRS", value=self.enabled)
|
enabled = gr.Checkbox(label="Enable NRS", value=self.enabled)
|
||||||
gr.HTML("<p><i>Adjust the amount guidance is steered.</i></p>")
|
gr.HTML("<p><i>Adjust the settings for Negative Rejection Steering.</i></p>")
|
||||||
skew = gr.Slider(label="NRS Skew Scale", minimum=-30.0, maximum=30.0, step=0.01, value=self.skew)
|
skew = gr.Slider(
|
||||||
gr.HTML("<p><i>Adjust the amount guidance is amplified.</i></p>")
|
label="NRS Skew Scale",
|
||||||
stretch = gr.Slider(label="NRS Stretch Scale", minimum=-30.0, maximum=30.0, step=0.01, value=self.stretch)
|
info="Adjusts the amount guidance is steered.",
|
||||||
gr.HTML("<p><i>Adjust the amount final guidance is normalized.</i></p>")
|
minimum=-30.0,
|
||||||
squash = gr.Slider(label="NRS Squash Multiplier", minimum=0.0, maximum=1.0, step=0.01, value=self.squash)
|
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(
|
enabled.change(lambda x: self.update_enabled(x), inputs=[enabled])
|
||||||
lambda x: self.update_enabled(x),
|
|
||||||
inputs=[enabled]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (enabled, skew, stretch, squash)
|
return (enabled, skew, stretch, squash)
|
||||||
|
|
||||||
|
|
||||||
def update_enabled(self, value):
|
def update_enabled(self, value):
|
||||||
self.enabled = value
|
self.enabled = value
|
||||||
@@ -73,22 +90,28 @@ class NRSScript(scripts.Script):
|
|||||||
unet = NRS().patch(unet, self.skew, self.stretch, self.squash)[0]
|
unet = NRS().patch(unet, self.skew, self.stretch, self.squash)[0]
|
||||||
|
|
||||||
p.sd_model.forge_objects.unet = unet
|
p.sd_model.forge_objects.unet = unet
|
||||||
p.extra_generation_params.update({
|
p.extra_generation_params.update(
|
||||||
"NRS_enabled": True,
|
{
|
||||||
"NRS_skew": self.skew,
|
"NRS_enabled": True,
|
||||||
"NRS_stretch": self.stretch,
|
"NRS_skew": self.skew,
|
||||||
"NRS_squash": self.squash,
|
"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}, Skew: {self.skew}, Stretch: {self.stretch}, Squash: {self.squash}"
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
def set_value(p, x: Any, xs: Any, *, field: str):
|
def set_value(p, x: Any, xs: Any, *, field: str):
|
||||||
if not hasattr(p, "_nrs_xyz"):
|
if not hasattr(p, "_nrs_xyz"):
|
||||||
p._nrs_xyz = {}
|
p._nrs_xyz = {}
|
||||||
p._nrs_xyz[field] = x
|
p._nrs_xyz[field] = x
|
||||||
|
|
||||||
|
|
||||||
def make_axis_on_xyz_grid():
|
def make_axis_on_xyz_grid():
|
||||||
xyz_grid = None
|
xyz_grid = None
|
||||||
for script in scripts.scripts_data:
|
for script in scripts.scripts_data:
|
||||||
@@ -101,10 +124,7 @@ def make_axis_on_xyz_grid():
|
|||||||
|
|
||||||
axis = [
|
axis = [
|
||||||
xyz_grid.AxisOption(
|
xyz_grid.AxisOption(
|
||||||
"(NRS) Enabled",
|
"(NRS) Enabled", str, partial(set_value, field="enabled"), choices=lambda: ["True", "False"]
|
||||||
str,
|
|
||||||
partial(set_value, field="enabled"),
|
|
||||||
choices=lambda: ["True", "False"]
|
|
||||||
),
|
),
|
||||||
xyz_grid.AxisOption(
|
xyz_grid.AxisOption(
|
||||||
"(NRS) Skew",
|
"(NRS) Skew",
|
||||||
@@ -126,6 +146,7 @@ def make_axis_on_xyz_grid():
|
|||||||
if not any(x.label.startswith("(NRS)") for x in xyz_grid.axis_options):
|
if not any(x.label.startswith("(NRS)") for x in xyz_grid.axis_options):
|
||||||
xyz_grid.axis_options.extend(axis)
|
xyz_grid.axis_options.extend(axis)
|
||||||
|
|
||||||
|
|
||||||
def on_before_ui():
|
def on_before_ui():
|
||||||
try:
|
try:
|
||||||
make_axis_on_xyz_grid()
|
make_axis_on_xyz_grid()
|
||||||
@@ -136,4 +157,5 @@ def on_before_ui():
|
|||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
script_callbacks.on_before_ui(on_before_ui)
|
script_callbacks.on_before_ui(on_before_ui)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"""Tests for pack-aware per-stream routing in NRS.nodes_NRS.
|
||||||
|
|
||||||
|
These tests need real torch (tensor math), but tests/conftest.py installs a
|
||||||
|
MagicMock in sys.modules["torch"] for the whole session so other test modules
|
||||||
|
can import without the heavy dependency. We swap the real torch module in for
|
||||||
|
the duration of this module only, then restore the mock so the rest of the
|
||||||
|
suite is unaffected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_saved_torch = None
|
||||||
|
_saved_nodes_nrs = None
|
||||||
|
torch = None
|
||||||
|
nrs_module = None
|
||||||
|
|
||||||
|
|
||||||
|
def setup_module(module):
|
||||||
|
# NOTE: we deliberately avoid importlib.reload() here. reload() mutates
|
||||||
|
# the *existing* NRS.nodes_NRS module dict in place, and other test
|
||||||
|
# modules (e.g. test_pred_type.py) import PredictionType/NRS at
|
||||||
|
# collection time and keep those references for the whole session. Their
|
||||||
|
# methods' __globals__ point at that same dict, so an in-place reload
|
||||||
|
# would silently swap PredictionType out from under them (new class
|
||||||
|
# object, same name -> broken identity-based Enum equality). Instead we
|
||||||
|
# unregister the module from sys.modules and import it fresh: this
|
||||||
|
# creates an independent module object, leaving the original (still
|
||||||
|
# cached in other modules' namespaces) untouched. We restore the exact
|
||||||
|
# original module object on teardown.
|
||||||
|
global _saved_torch, _saved_nodes_nrs, torch, nrs_module
|
||||||
|
_saved_torch = sys.modules.get("torch")
|
||||||
|
sys.modules.pop("torch", None)
|
||||||
|
try:
|
||||||
|
import torch as real_torch
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip("real torch unavailable", allow_module_level=True)
|
||||||
|
torch = real_torch
|
||||||
|
|
||||||
|
_saved_nodes_nrs = sys.modules.get("NRS.nodes_NRS")
|
||||||
|
sys.modules.pop("NRS.nodes_NRS", None)
|
||||||
|
|
||||||
|
import NRS.nodes_NRS as m
|
||||||
|
|
||||||
|
nrs_module = m
|
||||||
|
|
||||||
|
|
||||||
|
def teardown_module(module):
|
||||||
|
if _saved_torch is not None:
|
||||||
|
sys.modules["torch"] = _saved_torch
|
||||||
|
else:
|
||||||
|
sys.modules.pop("torch", None)
|
||||||
|
|
||||||
|
if _saved_nodes_nrs is not None:
|
||||||
|
sys.modules["NRS.nodes_NRS"] = _saved_nodes_nrs
|
||||||
|
else:
|
||||||
|
sys.modules.pop("NRS.nodes_NRS", None)
|
||||||
|
|
||||||
|
|
||||||
|
class _StubModelSampling:
|
||||||
|
"""Minimal stand-in that makes _get_pred_type fall back to EPS quickly."""
|
||||||
|
|
||||||
|
|
||||||
|
class _StubInnerModel:
|
||||||
|
def __init__(self, latent_shapes=None):
|
||||||
|
self.model_sampling = _StubModelSampling()
|
||||||
|
if latent_shapes is not None:
|
||||||
|
self.latent_shapes = latent_shapes
|
||||||
|
|
||||||
|
|
||||||
|
class _StubModel:
|
||||||
|
"""Stub for the outer ComfyUI ModelPatcher passed to NRS.patch()."""
|
||||||
|
|
||||||
|
def __init__(self, latent_shapes=None):
|
||||||
|
self.model = _StubInnerModel(latent_shapes)
|
||||||
|
self._captured_fn = None
|
||||||
|
|
||||||
|
def clone(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def set_model_sampler_cfg_function(self, fn, flag):
|
||||||
|
self._captured_fn = fn
|
||||||
|
|
||||||
|
|
||||||
|
def _make_args(model, cond, uncond, x_orig, sigma):
|
||||||
|
return {
|
||||||
|
"model": model.model, # args["model"] is the inner model carrying latent_shapes
|
||||||
|
"cond": cond,
|
||||||
|
"uncond": uncond,
|
||||||
|
"input": x_orig,
|
||||||
|
"sigma": sigma,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 1: round-trip pack/unpack correctness
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_roundtrip_unpack_repack_two_streams():
|
||||||
|
video = torch.randn(1, 4, 3, 2)
|
||||||
|
audio = torch.randn(1, 6, 5)
|
||||||
|
shapes = [video.shape, audio.shape]
|
||||||
|
|
||||||
|
packed = nrs_module._pack_latents([video, audio])
|
||||||
|
assert packed.shape == (1, 1, video.numel() + audio.numel())
|
||||||
|
|
||||||
|
unpacked = nrs_module._unpack_latents(packed, shapes)
|
||||||
|
assert len(unpacked) == 2
|
||||||
|
assert torch.allclose(unpacked[0], video)
|
||||||
|
assert torch.allclose(unpacked[1], audio)
|
||||||
|
|
||||||
|
repacked = nrs_module._pack_latents(unpacked)
|
||||||
|
assert torch.allclose(repacked, packed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_roundtrip_single_stream():
|
||||||
|
x = torch.randn(1, 4, 8, 8)
|
||||||
|
shapes = [x.shape]
|
||||||
|
|
||||||
|
packed = nrs_module._pack_latents([x])
|
||||||
|
unpacked = nrs_module._unpack_latents(packed, shapes)
|
||||||
|
assert len(unpacked) == 1
|
||||||
|
assert torch.allclose(unpacked[0], x)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 2: fallback path (no latent_shapes) is a byte-for-byte regression no-op
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _run_nrs(model, cond, uncond, x_orig, sigma, skew=2.0, stretch=5.0, squash=0.75):
|
||||||
|
node = nrs_module.NRS()
|
||||||
|
(patched_model,) = node.patch(model, skew, stretch, squash)
|
||||||
|
fn = patched_model._captured_fn
|
||||||
|
args = _make_args(model, cond, uncond, x_orig, sigma)
|
||||||
|
return fn(args)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_no_latent_shapes_matches_single_stream_shape():
|
||||||
|
model = _StubModel(latent_shapes=None)
|
||||||
|
cond = torch.randn(2, 4, 8, 8)
|
||||||
|
uncond = torch.randn(2, 4, 8, 8)
|
||||||
|
x_orig = torch.randn(2, 4, 8, 8)
|
||||||
|
sigma = torch.rand(2) + 0.1
|
||||||
|
|
||||||
|
result = _run_nrs(model, cond, uncond, x_orig, sigma)
|
||||||
|
assert result.shape == x_orig.shape
|
||||||
|
|
||||||
|
# Regression check: manually compute the single-stream result the same
|
||||||
|
# way the pre-split code path did, and confirm equality.
|
||||||
|
node = nrs_module.NRS()
|
||||||
|
expected = node._apply_guidance(x_orig, cond, uncond, sigma, 2.0, 5.0, 0.75, nrs_module.PredictionType.EPS)
|
||||||
|
assert torch.allclose(result, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_stream_latent_shapes_also_matches():
|
||||||
|
"""A model.latent_shapes list of length 1 must take the same code path."""
|
||||||
|
cond = torch.randn(1, 4, 5, 5)
|
||||||
|
uncond = torch.randn(1, 4, 5, 5)
|
||||||
|
x_orig = torch.randn(1, 4, 5, 5)
|
||||||
|
sigma = torch.rand(1) + 0.1
|
||||||
|
|
||||||
|
model = _StubModel(latent_shapes=[cond.shape])
|
||||||
|
result = _run_nrs(model, cond, uncond, x_orig, sigma)
|
||||||
|
|
||||||
|
node = nrs_module.NRS()
|
||||||
|
expected = node._apply_guidance(x_orig, cond, uncond, sigma, 2.0, 5.0, 0.75, nrs_module.PredictionType.EPS)
|
||||||
|
assert torch.allclose(result, expected)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 3: degeneracy tripwire
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_tripwire_fires_on_flat_pack_without_latent_shapes(caplog):
|
||||||
|
model = _StubModel(latent_shapes=None)
|
||||||
|
cond = torch.randn(1, 1, 100)
|
||||||
|
uncond = torch.randn(1, 1, 100)
|
||||||
|
x_orig = torch.randn(1, 1, 100)
|
||||||
|
sigma = torch.rand(1) + 0.1
|
||||||
|
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
_run_nrs(model, cond, uncond, x_orig, sigma)
|
||||||
|
|
||||||
|
assert any("singleton reduction axis" in rec.message for rec in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tripwire_does_not_fire_for_normal_single_stream(caplog):
|
||||||
|
model = _StubModel(latent_shapes=None)
|
||||||
|
cond = torch.randn(1, 4, 8, 8)
|
||||||
|
uncond = torch.randn(1, 4, 8, 8)
|
||||||
|
x_orig = torch.randn(1, 4, 8, 8)
|
||||||
|
sigma = torch.rand(1) + 0.1
|
||||||
|
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
_run_nrs(model, cond, uncond, x_orig, sigma)
|
||||||
|
|
||||||
|
assert not any("singleton reduction axis" in rec.message for rec in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 4: per-stream reduced shapes after unpack (H3-like video + audio)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_per_stream_reduced_shapes_after_unpack():
|
||||||
|
video = torch.randn(1, 24, 4, 3, 2)
|
||||||
|
audio = torch.randn(1, 32, 2, 5)
|
||||||
|
shapes = [video.shape, audio.shape]
|
||||||
|
|
||||||
|
packed = nrs_module._pack_latents([video, audio])
|
||||||
|
unpacked = nrs_module._unpack_latents(packed, shapes)
|
||||||
|
|
||||||
|
video_u, audio_u = unpacked
|
||||||
|
assert video_u.shape == video.shape
|
||||||
|
assert audio_u.shape == audio.shape
|
||||||
|
|
||||||
|
# Channels sit at dim 1 for both streams.
|
||||||
|
assert video_u.shape[1] == 24
|
||||||
|
assert audio_u.shape[1] == 32
|
||||||
|
|
||||||
|
video_reduced = video_u.sum(dim=1, keepdim=True)
|
||||||
|
audio_reduced = audio_u.sum(dim=1, keepdim=True)
|
||||||
|
assert video_reduced.shape == (1, 1, 4, 3, 2)
|
||||||
|
assert audio_reduced.shape == (1, 1, 2, 5)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 5: split restores non-degenerate rejection (proves Skew is alive)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_restores_nondegenerate_rejection():
|
||||||
|
"""On a real multi-channel stream, uncond's rejection on cond must not
|
||||||
|
collapse to ~0 -- this is the geometry that was silently dead on the flat
|
||||||
|
[B,1,N] pack before the unpack/repack fix.
|
||||||
|
"""
|
||||||
|
torch.manual_seed(0)
|
||||||
|
cond = torch.randn(1, 8, 4, 4)
|
||||||
|
# Make uncond non-parallel to cond so the rejection component is nonzero.
|
||||||
|
uncond = torch.randn(1, 8, 4, 4)
|
||||||
|
|
||||||
|
def _dot(a, b):
|
||||||
|
return (a * b).sum(dim=1, keepdim=True)
|
||||||
|
|
||||||
|
eps = torch.finfo(cond.dtype).eps
|
||||||
|
c_dot_c = _dot(cond, cond) + eps
|
||||||
|
u_dot_c = _dot(uncond, cond)
|
||||||
|
u_on_c = (u_dot_c / c_dot_c) * cond
|
||||||
|
u_rej_c = uncond - u_on_c
|
||||||
|
|
||||||
|
assert u_rej_c.abs().max().item() > 1e-4
|
||||||
|
|
||||||
|
|
||||||
|
def test_flat_pack_rejection_is_degenerate_without_split():
|
||||||
|
"""Sanity check for the bug this PR fixes: reducing over the flat pack's
|
||||||
|
singleton dim=1 axis collapses the rejection to exactly zero (up to
|
||||||
|
floating point noise from the eps regularization term).
|
||||||
|
"""
|
||||||
|
# float64 keeps the residual from the eps regularizer near the true
|
||||||
|
# machine epsilon instead of float32 accumulation noise, so the
|
||||||
|
# collapse-to-zero identity is exact enough to assert tightly.
|
||||||
|
packed_cond = torch.randn(1, 1, 100, dtype=torch.float64)
|
||||||
|
packed_uncond = torch.randn(1, 1, 100, dtype=torch.float64)
|
||||||
|
|
||||||
|
def _dot(a, b):
|
||||||
|
return (a * b).sum(dim=1, keepdim=True)
|
||||||
|
|
||||||
|
eps = torch.finfo(packed_cond.dtype).eps
|
||||||
|
c_dot_c = _dot(packed_cond, packed_cond) + eps
|
||||||
|
u_dot_c = _dot(packed_uncond, packed_cond)
|
||||||
|
u_on_c = (u_dot_c / c_dot_c) * packed_cond
|
||||||
|
u_rej_c = packed_uncond - u_on_c
|
||||||
|
|
||||||
|
assert u_rej_c.abs().max().item() < 1e-8
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""Regression tests for NRS._get_pred_type, _RAW_TO_ENUM mappings, and the
|
||||||
|
V/FLOW/EPS operation-space conversion helpers.
|
||||||
|
|
||||||
|
PR-3 reclassified the flow-matching family (flux, chroma, flow, wan, const)
|
||||||
|
from PredictionType.EPS onto a new PredictionType.FLOW, which is operated
|
||||||
|
natively (identity conversion, no VP ε<->v algebra). These tests pin that
|
||||||
|
post-reclassification behavior at both detection sites (the _RAW_TO_ENUM
|
||||||
|
dict and the enhanced-detection fallback in _get_pred_type).
|
||||||
|
|
||||||
|
FLOW is the sole native path; every VP parameterization (EPS, V, X0, and the
|
||||||
|
UNKNOWN fallback) shares one ε/v/x0 -> v-space conversion through
|
||||||
|
_convert_to_v_space / _finalize_from_v_space. These tests cover the FLOW
|
||||||
|
identity round-trip and confirm the VP branches actually transform their inputs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
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 _ModelType(enum.Enum):
|
||||||
|
"""Mirrors ComfyUI's real model_type.ModelType Enum, as exposed by MiniMax H3's
|
||||||
|
BaseModel.model_type -- a genuine Enum member, not a raw string.
|
||||||
|
"""
|
||||||
|
|
||||||
|
FLOW = enum.auto()
|
||||||
|
|
||||||
|
|
||||||
|
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.FLOW),
|
||||||
|
("chroma", PredictionType.FLOW),
|
||||||
|
("flow", PredictionType.FLOW),
|
||||||
|
("wan", PredictionType.FLOW),
|
||||||
|
("const", PredictionType.FLOW),
|
||||||
|
("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_is_flow(self):
|
||||||
|
"""Flow-family models resolve to FLOW (native operation, no VP conversion)."""
|
||||||
|
model = _StubModel(model_type="flow")
|
||||||
|
assert NRS()._get_pred_type(model) == PredictionType.FLOW
|
||||||
|
|
||||||
|
def test_model_type_wan_is_flow(self):
|
||||||
|
model = _StubModel(model_type="wan")
|
||||||
|
assert NRS()._get_pred_type(model) == PredictionType.FLOW
|
||||||
|
|
||||||
|
def test_h3_enum_model_type_resolves_to_flow(self):
|
||||||
|
"""MiniMax H3 exposes model.model.model_type as a real Enum member
|
||||||
|
(ModelType.FLOW), not a raw string. _canon's `isinstance(p, Enum)`
|
||||||
|
branch reduces it to `p.name` ("FLOW" -> "flow") before the
|
||||||
|
_RAW_TO_ENUM dict lookup, so this pins that Enum path -- as taken by
|
||||||
|
H3's real model_type attribute -- resolves at the direct-hit site.
|
||||||
|
"""
|
||||||
|
model = _StubModel(inner_model_type=_ModelType.FLOW)
|
||||||
|
assert NRS()._get_pred_type(model) == PredictionType.FLOW
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetPredTypeEnhancedDetectionFallback:
|
||||||
|
"""The model_sampling class-name and model.model.model_type fallback paths.
|
||||||
|
|
||||||
|
Each stub below is deliberately built so the only detectable signal lives
|
||||||
|
in the fallback (section 3) logic -- not an exact _RAW_TO_ENUM key hit
|
||||||
|
during the BFS walk -- so these tests genuinely exercise the fallback
|
||||||
|
branches rather than just re-testing the dict.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_model_sampling_const_class_is_flow(self):
|
||||||
|
"""A CONST-like model_sampling class name is the only flow signal here."""
|
||||||
|
model = _StubModel(model_sampling=_make_model_sampling("ModelSamplingContinuousEDMConst"))
|
||||||
|
assert NRS()._get_pred_type(model) == PredictionType.FLOW
|
||||||
|
|
||||||
|
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_inner_model_type_flow_string_is_flow(self):
|
||||||
|
"""A model.model.model_type whose str() merely *contains* 'flow' (e.g. an
|
||||||
|
Enum repr like 'ModelType.FLOW') isn't an exact _RAW_TO_ENUM key, so the
|
||||||
|
BFS direct-hit path can't resolve it -- only the model.model.model_type
|
||||||
|
substring fallback can.
|
||||||
|
"""
|
||||||
|
model = _StubModel(inner_model_type="ModelType.FLOW")
|
||||||
|
assert NRS()._get_pred_type(model) == PredictionType.FLOW
|
||||||
|
|
||||||
|
def test_inner_model_type_flux_string_is_flow(self):
|
||||||
|
model = _StubModel(inner_model_type="ModelType.FLUX")
|
||||||
|
assert NRS()._get_pred_type(model) == PredictionType.FLOW
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class TestConvertToVSpaceBranches:
|
||||||
|
"""FLOW is the only native (identity) parameterization; every VP type
|
||||||
|
(EPS, V, X0, and the UNKNOWN fallback) now runs the shared ε/v/x0 -> v-space
|
||||||
|
algebra. FLOW identity needs no tensor math, so sentinel objects prove it;
|
||||||
|
the VP branches use MagicMock to confirm the algebra actually transforms.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_flow_convert_is_identity(self):
|
||||||
|
node = NRS()
|
||||||
|
cond, uncond = object(), object()
|
||||||
|
v_cond, v_uncond = node._convert_to_v_space(object(), object(), object(), cond, uncond, PredictionType.FLOW)
|
||||||
|
assert v_cond is cond
|
||||||
|
assert v_uncond is uncond
|
||||||
|
|
||||||
|
def test_flow_finalize_is_identity(self):
|
||||||
|
node = NRS()
|
||||||
|
x_final = object()
|
||||||
|
result = node._finalize_from_v_space(object(), x_final, object(), object(), PredictionType.FLOW)
|
||||||
|
assert result is x_final
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("pred_type", [PredictionType.EPS, PredictionType.V, PredictionType.X0])
|
||||||
|
def test_vp_convert_performs_algebra(self, pred_type):
|
||||||
|
"""EPS/V/X0 all run the ε->v conversion (cond/uncond are transformed,
|
||||||
|
not passed through)."""
|
||||||
|
node = NRS()
|
||||||
|
x_orig, sig_root, sigma = MagicMock(), MagicMock(), MagicMock()
|
||||||
|
cond, uncond = MagicMock(), MagicMock()
|
||||||
|
v_cond, v_uncond = node._convert_to_v_space(x_orig, sig_root, sigma, cond, uncond, pred_type)
|
||||||
|
assert v_cond is not cond
|
||||||
|
assert v_uncond is not uncond
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("pred_type", [PredictionType.EPS, PredictionType.V, PredictionType.X0])
|
||||||
|
def test_vp_finalize_performs_algebra(self, pred_type):
|
||||||
|
node = NRS()
|
||||||
|
x_orig, x_final, sig_root, sigma = MagicMock(), MagicMock(), MagicMock(), MagicMock()
|
||||||
|
result = node._finalize_from_v_space(x_orig, x_final, sig_root, sigma, pred_type)
|
||||||
|
assert result is not x_final
|
||||||
|
|
||||||
|
def test_unknown_convert_falls_back_to_vp(self):
|
||||||
|
"""UNKNOWN (and any unhandled type) is treated as VP -> runs the algebra."""
|
||||||
|
node = NRS()
|
||||||
|
x_orig, sig_root, sigma = MagicMock(), MagicMock(), MagicMock()
|
||||||
|
cond, uncond = MagicMock(), MagicMock()
|
||||||
|
v_cond, v_uncond = node._convert_to_v_space(x_orig, sig_root, sigma, cond, uncond, PredictionType.UNKNOWN)
|
||||||
|
assert v_cond is not cond
|
||||||
|
assert v_uncond is not uncond
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Tests for NRS package version metadata and the patch()-time version/pred-type log line."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add project root to path for imports
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
import NRS # noqa: E402
|
||||||
|
import NRS.nodes_NRS as nodes_NRS # noqa: E402, N812
|
||||||
|
|
||||||
|
_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_is_nonempty_semver_string():
|
||||||
|
"""NRS.__version__ must be importable and look like a X.Y.Z version."""
|
||||||
|
assert isinstance(NRS.__version__, str)
|
||||||
|
assert NRS.__version__
|
||||||
|
assert _SEMVER_RE.match(NRS.__version__), f"__version__ {NRS.__version__!r} is not X.Y.Z"
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_matches_pyproject():
|
||||||
|
"""__version__ must be kept in lock-step with pyproject.toml's version field."""
|
||||||
|
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
|
||||||
|
text = pyproject_path.read_text()
|
||||||
|
match = re.search(r'(?m)^version\s*=\s*"([^"]+)"', text)
|
||||||
|
assert match, "Could not find version in pyproject.toml"
|
||||||
|
assert NRS.__version__ == match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
class _StubModel:
|
||||||
|
"""Minimal stand-in that resolves to PredictionType.EPS via the direct-hit path."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.model_type = "eps"
|
||||||
|
|
||||||
|
def clone(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def set_model_sampler_cfg_function(self, fn, flag):
|
||||||
|
self._captured_fn = fn
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_logs_version_and_pred_type(caplog):
|
||||||
|
"""patch() must announce the NRS version and detected prediction type."""
|
||||||
|
node = nodes_NRS.NRS()
|
||||||
|
model = _StubModel()
|
||||||
|
|
||||||
|
with caplog.at_level("INFO"):
|
||||||
|
node.patch(model, skew=2.0, stretch=5.0, squash=0.75)
|
||||||
|
|
||||||
|
expected = f"NRS v{NRS.__version__}: prediction type detected -> {nodes_NRS.PredictionType.EPS.name}"
|
||||||
|
assert any(expected in rec.message for rec in caplog.records)
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
|
||||||
|
[[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 = "exceptiongroup"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "1.0.0"
|
||||||
|
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 = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||||
|
]
|
||||||
|
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/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
|
||||||
|
{ 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 = "tomli"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "virtualenv"
|
||||||
|
version = "21.3.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "distlib" },
|
||||||
|
{ name = "filelock" },
|
||||||
|
{ name = "platformdirs" },
|
||||||
|
{ name = "python-discovery" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||||
|
]
|
||||||
|
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" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user