first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,254 @@
# n8n Workflow Comparison
Graph-based workflow similarity comparison using NetworkX and graph edit distance.
## Features
- **Graph Edit Distance**: Uses NetworkX's graph edit distance algorithm for accurate structural comparison
- **Configurable Cost Functions**: Customize costs for different types of edits (node/edge insertion, deletion, substitution)
- **Special Case Handling**: Higher penalties for trigger mismatches, similar node types grouped together
- **Parameter Comparison**: Deep comparison of node parameters with configurable ignore rules
- **External Configuration**: YAML/JSON config files for easy customization without code changes ([see CONFIGURATION.md](CONFIGURATION.md))
- **Built-in Presets**: Strict, standard, and lenient comparison modes
- **Detailed Output**: Returns similarity score and top edit operations needed
## Installation
This module uses `uv` for dependency management. No installation is needed - dependencies are automatically managed by `uvx`.
### Prerequisites
Install `uv`:
```bash
# macOS/Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# Windows
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
```
Install [just](https://github.com/casey/just)
```bash
# on macOS via homebrew
brew install just
# or gloabl install via NPM
npm install -g rust-just
# or cross platform via curl to DEST
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to DEST
```
## Usage
### CLI Usage
```bash
# Using default (standard) configuration
uvx --from . python -m src.compare_workflows generated.json ground_truth.json
# Using a preset
uvx --from . python -m src.compare_workflows generated.json ground_truth.json --preset strict
# Using custom configuration
uvx --from . python -m src.compare_workflows generated.json ground_truth.json --config my-config.yaml
# Output as human-readable summary
uvx --from . python -m src.compare_workflows generated.json ground_truth.json --output-format summary
```
### Python API Usage
```python
from config_loader import load_config
from graph_builder import build_workflow_graph
from similarity import calculate_graph_edit_distance
import json
# Load workflows
with open('generated.json') as f:
generated = json.load(f)
with open('ground_truth.json') as f:
ground_truth = json.load(f)
# Load configuration
config = load_config('preset:standard')
# Build graphs
g1 = build_workflow_graph(generated, config)
g2 = build_workflow_graph(ground_truth, config)
# Calculate similarity
result = calculate_graph_edit_distance(g1, g2, config)
print(f"Similarity: {result['similarity_score']:.2%}")
print(f"Edit cost: {result['edit_cost']:.1f}")
print(f"Top edits: {len(result['top_edits'])}")
```
## Configuration
> **📖 For detailed configuration documentation, see [CONFIGURATION.md](CONFIGURATION.md)**
### Built-in Presets
- **strict**: High penalties, exact matching required
- **standard**: Balanced comparison (default)
- **lenient**: Low penalties, focus on structure over details
### Quick Start
Create a YAML or JSON file with your custom rules:
```yaml
version: "1.0"
name: "my-custom-config"
description: "Custom configuration for my use case"
costs:
nodes:
insertion: 10.0
deletion: 10.0
substitution:
same_type: 1.0
similar_type: 5.0
different_type: 15.0
trigger_mismatch: 50.0
edges:
insertion: 5.0
deletion: 5.0
substitution: 3.0
similarity_groups:
triggers:
- "n8n-nodes-base.webhook"
- "n8n-nodes-base.manualTrigger"
ignore:
node_types:
- "n8n-nodes-base.stickyNote"
global_parameters:
- "position"
- "id"
parameter_comparison:
numeric_tolerance:
- parameter: "options.temperature"
tolerance: 0.1
cost_if_exceeded: 2.0
```
**For comprehensive documentation including:**
- Complete field reference
- Cost configuration strategies
- Advanced ignore rules and wildcards
- Parameter comparison rules
- Exemptions and conditional logic
- Real-world examples
See **[CONFIGURATION.md](CONFIGURATION.md)**
## Output Format
### JSON Output
```json
{
"similarity_score": 0.78,
"similarity_percentage": "78.0%",
"edit_cost": 45.0,
"max_possible_cost": 205.0,
"top_edits": [
{
"type": "node_substitute",
"description": "Replace 'Manual Trigger' with 'Webhook Trigger'",
"cost": 25.0,
"priority": "critical"
}
],
"metadata": {
"generated_nodes": 5,
"ground_truth_nodes": 6
}
}
```
### Summary Output
```
============================================================
WORKFLOW COMPARISON SUMMARY
============================================================
Overall Similarity: 78.0%
Edit Cost: 45.0 / 205.0
Configuration: standard
Standard balanced comparison configuration
Top 3 Required Edits:
------------------------------------------------------------
1. 🔴 [CRITICAL] Cost: 25.0
Replace 'Manual Trigger' with 'Webhook Trigger'
2. 🟠 [MAJOR] Cost: 10.0
Add missing 'HTTP Request' tool node
3. 🟡 [MINOR] Cost: 5.0
Remove connection from 'Agent' to 'Extra Node'
============================================================
✅ PASS - Workflows are sufficiently similar
============================================================
```
## Testing
Run the test suite:
```bash
# Install dev dependencies
uv sync --dev
# Run tests
uv run pytest
# Run with coverage
uv run pytest --cov
```
## Algorithm Details
### Graph Representation
- Each workflow node becomes a graph node with attributes (type, parameters, etc.)
- Node and edge get a generated ID based on their position in the workflow
- Each workflow connection becomes a directed edge with connection type
- Nodes and edges are filtered based on configuration rules
### Graph Edit Distance
Uses NetworkX's `optimize_graph_edit_distance` with custom cost functions:
- Node operations: insertion, deletion, substitution
- Edge operations: insertion, deletion, substitution
- Cost functions consider node types, parameters, and configuration rules
### Similarity Score
```
similarity = 1 - (edit_cost / max_possible_cost)
```
Where `max_possible_cost` is the cost of deleting all nodes/edges from g1 and inserting all from g2.
## Troubleshooting
### Timeout errors
For very large or complex workflows, the comparison may timeout. Consider:
- Using a lenient preset to reduce computation
- Simplifying the workflow structure
- Increasing the timeout in the TypeScript wrapper
### Configuration errors
- Ensure YAML/JSON syntax is valid
- Check that node types and parameter paths are correct
- Use `--verbose` flag to see detailed configuration info
@@ -0,0 +1,38 @@
{
"name": "Simple Test Workflow",
"nodes": [
{
"id": "1",
"name": "Webhook Trigger",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [250, 300],
"parameters": {
"path": "/test"
}
},
{
"id": "2",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [450, 300],
"parameters": {
"jsCode": "return items;"
}
}
],
"connections": {
"Webhook Trigger": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
}
}
}
@@ -0,0 +1,38 @@
{
"name": "Simple Test Workflow",
"nodes": [
{
"id": "1",
"name": "Webhook Trigger 2",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [250, 300],
"parameters": {
"path": "/test2"
}
},
{
"id": "2",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [450, 300],
"parameters": {
"jsCode": "return items.map(item => item.name);"
}
}
],
"connections": {
"Webhook Trigger 2": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
}
}
}
@@ -0,0 +1,85 @@
{
"name": "Multi-Trigger Workflow",
"nodes": [
{
"id": "1",
"name": "Webhook Trigger",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [250, 300],
"parameters": {
"path": "/webhook1"
}
},
{
"id": "2",
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1,
"position": [250, 450],
"parameters": {
"rule": {
"interval": [
{
"field": "hours",
"hoursInterval": 1
}
]
}
}
},
{
"id": "3",
"name": "Merge",
"type": "n8n-nodes-base.merge",
"typeVersion": 1,
"position": [450, 375],
"parameters": {}
},
{
"id": "4",
"name": "Process Data",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [650, 375],
"parameters": {
"jsCode": "return items;"
}
}
],
"connections": {
"Webhook Trigger": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 0
}
]
]
},
"Schedule Trigger": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 1
}
]
]
},
"Merge": {
"main": [
[
{
"node": "Process Data",
"type": "main",
"index": 0
}
]
]
}
}
}
@@ -0,0 +1,85 @@
{
"name": "Multi-Trigger Workflow Different Order",
"nodes": [
{
"id": "4",
"name": "Process Data Node",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [650, 375],
"parameters": {
"jsCode": "return items;"
}
},
{
"id": "2",
"name": "Schedule Trigger Different",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1,
"position": [250, 450],
"parameters": {
"rule": {
"interval": [
{
"field": "hours",
"hoursInterval": 1
}
]
}
}
},
{
"id": "3",
"name": "Merge Node",
"type": "n8n-nodes-base.merge",
"typeVersion": 1,
"position": [450, 375],
"parameters": {}
},
{
"id": "1",
"name": "Webhook Trigger Different",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [250, 300],
"parameters": {
"path": "/webhook1"
}
}
],
"connections": {
"Webhook Trigger Different": {
"main": [
[
{
"node": "Merge Node",
"type": "main",
"index": 0
}
]
]
},
"Schedule Trigger Different": {
"main": [
[
{
"node": "Merge Node",
"type": "main",
"index": 1
}
]
]
},
"Merge Node": {
"main": [
[
{
"node": "Process Data Node",
"type": "main",
"index": 0
}
]
]
}
}
}
@@ -0,0 +1,55 @@
{
"name": "Multi-Trigger Workflow Missing Merge",
"nodes": [
{
"id": "1",
"name": "HTTP Trigger",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [250, 300],
"parameters": {
"path": "/webhook1"
}
},
{
"id": "2",
"name": "Timer Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1,
"position": [250, 450],
"parameters": {
"rule": {
"interval": [
{
"field": "hours",
"hoursInterval": 2
}
]
}
}
},
{
"id": "4",
"name": "Process Data",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [650, 375],
"parameters": {
"jsCode": "return items;"
}
}
],
"connections": {
"HTTP Trigger": {
"main": [
[
{
"node": "Process Data",
"type": "main",
"index": 0
}
]
]
}
}
}
@@ -0,0 +1,38 @@
{
"name": "Simple Test Workflow",
"nodes": [
{
"id": "1",
"name": "Webhook Trigger",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [250, 300],
"parameters": {
"path": "/test"
}
},
{
"id": "2",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [450, 300],
"parameters": {
"jsCode": "return items;"
}
}
],
"connections": {
"Webhook Trigger": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
}
}
}
@@ -0,0 +1,37 @@
check:
just lint
just format-check
just typecheck
run:
uv run python -m src.main
sync:
uv sync --group dev
sync-all:
uv sync --all-extras --group dev
lint:
uv run ruff check
lintfix:
uv run ruff check --fix
format:
uv run ruff format
format-check:
uv run ruff format --check
test:
uv run pytest
test-cov:
uv run pytest --cov=src --cov-report=term-missing
test-v:
uv run pytest -vv
typecheck:
uv run ty check src/
@@ -0,0 +1,36 @@
[project]
name = "n8n-workflow-comparison"
version = "0.1.0"
description = "Graph-based workflow similarity comparison for n8n"
requires-python = ">=3.11"
dependencies = [
"networkx>=3.2",
"numpy>=2.3.4",
"pyyaml>=6.0",
"scipy>=1.16.3",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["."]
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
addopts = "-v"
[tool.coverage.run]
source = ["."]
omit = ["tests/*", "**/__pycache__/*"]
[dependency-groups]
dev = [
"pytest>=9.0.1",
"pytest-cov>=7.0.0",
"ruff>=0.14.5",
"ty>=0.0.1a26",
]
@@ -0,0 +1,21 @@
"""
n8n Workflow Comparison Module
Graph-based workflow similarity comparison using NetworkX.
"""
from __future__ import annotations
from src.config_loader import WorkflowComparisonConfig, load_config
from src.graph_builder import build_workflow_graph, graph_stats
from src.similarity import calculate_graph_edit_distance
__version__ = "0.1.0"
__all__ = [
"WorkflowComparisonConfig",
"load_config",
"build_workflow_graph",
"graph_stats",
"calculate_graph_edit_distance",
]
@@ -0,0 +1,8 @@
"""
Allow running as a module: python -m compare_workflows
"""
from compare_workflows import main
if __name__ == "__main__":
main()
@@ -0,0 +1,334 @@
#!/usr/bin/env python3
"""
Workflow comparison using graph edit distance.
Options:
--config PATH Path to custom config file (.yaml or .json)
--preset NAME Use built-in preset (strict|standard|lenient)
--output-format FORMAT Output format (json|summary) [default: json]
--verbose Show detailed comparison info
--help Show this help message
"""
import argparse
import json
import sys
from typing import Dict, Any
from src.graph_builder import build_workflow_graph, graph_stats
from src.similarity import calculate_graph_edit_distance
from src.config_loader import load_config
def parse_args():
"""Parse command line arguments"""
parser = argparse.ArgumentParser(
description="Compare n8n workflows using graph edit distance",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Use default configuration
python compare_workflows.py generated.json ground_truth.json
# Use a preset
python compare_workflows.py generated.json ground_truth.json --preset strict
# Use custom configuration
python compare_workflows.py generated.json ground_truth.json --config my-config.yaml
# Output summary instead of JSON
python compare_workflows.py generated.json ground_truth.json --output-format summary
""",
)
parser.add_argument("generated", help="Path to generated workflow JSON file")
parser.add_argument("ground_truth", help="Path to ground truth workflow JSON file")
parser.add_argument(
"--config", help="Path to custom configuration file (.yaml or .json)"
)
parser.add_argument(
"--preset",
choices=["strict", "standard", "lenient"],
help="Use built-in configuration preset",
)
parser.add_argument(
"--output-format",
choices=["json", "summary"],
default="json",
help="Output format (default: json)",
)
parser.add_argument(
"--verbose", action="store_true", help="Show detailed comparison information"
)
return parser.parse_args()
def load_workflow(path: str) -> Dict[str, Any]:
"""
Load workflow JSON from file.
Args:
path: Path to workflow JSON file
Returns:
Workflow dictionary
Raises:
SystemExit: If file cannot be loaded
"""
try:
with open(path) as f:
return json.load(f)
except FileNotFoundError:
print(f"Error: Workflow file not found: {path}", file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in {path}: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error loading {path}: {e}", file=sys.stderr)
sys.exit(1)
def format_output_json(
result: Dict[str, Any], metadata: Dict[str, Any], verbose: bool = False
) -> str:
"""Format result as JSON"""
output: Dict[str, Any] = {
"similarity_score": result["similarity_score"],
"similarity_percentage": f"{result['similarity_score'] * 100:.1f}%",
"edit_cost": result["edit_cost"],
"max_possible_cost": result["max_possible_cost"],
"top_edits": result["top_edits"],
"metadata": metadata,
}
if verbose:
metadata_dict = output["metadata"]
assert isinstance(metadata_dict, dict)
metadata_dict["verbose"] = True
else:
for edit in output["top_edits"]:
if "parameter_diff" in edit:
del edit["parameter_diff"]
return json.dumps(output, indent=2)
def _format_parameter_diff(
diff: Dict[str, Any],
indent: str = "",
max_value_length: int = 50,
) -> list:
"""
Format parameter diff for human-readable display.
Args:
diff: Parameter diff dictionary with 'added', 'removed', 'changed' keys
indent: Indentation prefix for each line
max_value_length: Maximum length for displayed values before truncation
Returns:
List of formatted lines
"""
lines = []
def truncate_value(value: Any) -> str:
"""Truncate long values for display"""
value_str = str(value)
if len(value_str) > max_value_length:
return value_str[:max_value_length] + "..."
return value_str
# Added parameters
if "added" in diff and diff["added"]:
lines.append(f"{indent}Added:")
for key, value in diff["added"].items():
lines.append(f"{indent} + {key}: {truncate_value(value)}")
# Removed parameters
if "removed" in diff and diff["removed"]:
lines.append(f"{indent}Removed:")
for key, value in diff["removed"].items():
lines.append(f"{indent} - {key}: {truncate_value(value)}")
# Changed parameters
if "changed" in diff and diff["changed"]:
lines.append(f"{indent}Changed:")
for key, value in diff["changed"].items():
if isinstance(value, dict) and "from" in value and "to" in value:
# Simple value change
lines.append(f"{indent} ~ {key}:")
lines.append(f"{indent} from: {truncate_value(value['from'])}")
lines.append(f"{indent} to: {truncate_value(value['to'])}")
elif isinstance(value, dict):
# Nested diff
lines.append(f"{indent} ~ {key}:")
nested_lines = _format_parameter_diff(
value, indent + " ", max_value_length
)
lines.extend(nested_lines)
return lines
def format_output_summary(
result: Dict[str, Any], metadata: Dict[str, Any], verbose: bool = False
) -> str:
"""Format result as human-readable summary"""
lines = []
# Header
lines.append("=" * 60)
lines.append("WORKFLOW COMPARISON SUMMARY")
lines.append("=" * 60)
lines.append("")
# Similarity score
similarity_pct = result["similarity_score"] * 100
lines.append(f"Overall Similarity: {similarity_pct:.1f}%")
lines.append(
f"Edit Cost: {result['edit_cost']:.1f} / {result['max_possible_cost']:.1f}"
)
lines.append("")
# Configuration info
lines.append(f"Configuration: {metadata['config_name']}")
if metadata.get("config_description"):
lines.append(f" {metadata['config_description']}")
lines.append("")
# Graph statistics
lines.append("Graph Statistics:")
lines.append(
f" Generated workflow: {metadata['generated_nodes']} nodes "
f"({metadata.get('generated_nodes_after_filter', metadata['generated_nodes'])} after filtering)"
)
lines.append(
f" Ground truth workflow: {metadata['ground_truth_nodes']} nodes "
f"({metadata.get('ground_truth_nodes_after_filter', metadata['ground_truth_nodes'])} after filtering)"
)
lines.append("")
# Top edits
if result["top_edits"]:
lines.append(f"Top {len(result['top_edits'])} Required Edits:")
lines.append("-" * 60)
for i, edit in enumerate(result["top_edits"], 1):
priority = edit["priority"].upper()
cost = edit["cost"]
desc = edit["description"]
# Priority indicator
if priority == "CRITICAL":
indicator = "🔴"
elif priority == "MAJOR":
indicator = "🟠"
else:
indicator = "🟡"
lines.append(f"{i}. {indicator} [{priority}] Cost: {cost:.1f}")
lines.append(f" {desc}")
# Add parameter diff if present
if verbose and "parameter_diff" in edit:
lines.append("")
lines.extend(
_format_parameter_diff(edit["parameter_diff"], indent=" ")
)
lines.append("")
else:
lines.append("No edits required - workflows are identical!")
lines.append("")
# Pass/Fail indicator
lines.append("=" * 60)
if similarity_pct >= 70:
lines.append("✅ PASS - Workflows are sufficiently similar")
else:
lines.append("❌ FAIL - Workflows differ significantly")
lines.append("=" * 60)
return "\n".join(lines)
def main():
"""Main entry point"""
args = parse_args()
# Load workflows
generated = load_workflow(args.generated)
ground_truth = load_workflow(args.ground_truth)
# Load configuration
try:
if args.config:
config = load_config(args.config)
elif args.preset:
config = load_config(f"preset:{args.preset}")
else:
config = load_config() # Default (standard)
except Exception as e:
print(f"Error loading configuration: {e}", file=sys.stderr)
sys.exit(1)
# Build graphs with config filtering
try:
g1 = build_workflow_graph(generated, config)
g2 = build_workflow_graph(ground_truth, config)
except Exception as e:
print(f"Error building workflow graphs: {e}", file=sys.stderr)
sys.exit(1)
# Get graph statistics
stats1 = graph_stats(g1)
stats2 = graph_stats(g2)
# Calculate similarity
try:
result = calculate_graph_edit_distance(g1, g2, config)
except Exception as e:
print(f"Error calculating similarity: {e}", file=sys.stderr)
sys.exit(1)
# Prepare metadata
metadata = {
"generated_nodes": len(generated.get("nodes", [])),
"ground_truth_nodes": len(ground_truth.get("nodes", [])),
"generated_nodes_after_filter": stats1["node_count"],
"ground_truth_nodes_after_filter": stats2["node_count"],
"config_name": config.name,
"config_description": config.description,
}
if args.verbose:
metadata["verbose_info"] = {
"generated_stats": stats1,
"ground_truth_stats": stats2,
"config_details": config.to_dict(),
}
# Format and output result
if args.output_format == "json":
output = format_output_json(result, metadata, args.verbose)
print(output)
elif args.output_format == "summary":
output = format_output_summary(result, metadata, args.verbose)
print(output)
# Always exit with 0 - let the caller interpret the similarity score
sys.exit(0)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\nInterrupted by user", file=sys.stderr)
sys.exit(130)
except Exception as e:
print(f"Unexpected error: {e}", file=sys.stderr)
sys.exit(1)
@@ -0,0 +1,389 @@
"""
Configuration loader for workflow comparison.
Supports loading from YAML, JSON, and built-in presets.
"""
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any, Set
from pathlib import Path
import yaml
import json
import re
def _get_param_path_matching_pattern(pattern: str) -> str:
"""
Convert glob-like pattern to regex pattern.
Supports wildcards: ** (matches any chars including dots), * (matches any chars except dots)
"""
# Use placeholders to preserve wildcards during escaping
regex_pattern = pattern.replace("**", "\x00DOUBLE_STAR\x00").replace(
"*", "\x00STAR\x00"
)
regex_pattern = regex_pattern.replace(".", r"\.")
regex_pattern = regex_pattern.replace("\x00DOUBLE_STAR\x00", ".*").replace(
"\x00STAR\x00", "[^.]*"
)
return regex_pattern
@dataclass
class NodeIgnoreRule:
"""Rule for ignoring nodes during comparison"""
pattern: Optional[str] = None
name: Optional[str] = None
node_type: Optional[str] = None
reason: str = ""
def matches(self, node: Dict) -> bool:
"""Check if this rule matches a node"""
if self.name and node.get("name") == self.name:
return True
if self.pattern and re.match(self.pattern, node.get("name", "")):
return True
if self.node_type and node.get("type") == self.node_type:
return True
return False
@dataclass
class ParameterComparisonRule:
"""Rule for comparing specific parameters"""
parameter: str
type: str # 'semantic', 'normalized', 'exact', 'numeric'
threshold: Optional[float] = None
tolerance: Optional[float] = None
cost_if_below: float = 0.0
cost_if_exceeded: float = 0.0
options: Dict[str, Any] = field(default_factory=dict)
def matches_parameter(self, param_path: str) -> bool:
"""Check if this rule applies to a parameter path"""
regex_pattern = _get_param_path_matching_pattern(self.parameter)
return bool(re.match(f"^{regex_pattern}$", param_path))
@dataclass
class ExemptionRule:
"""Rule for exempting certain nodes from full cost"""
name_pattern: Optional[str] = None
node_type: Optional[str] = None
penalty: float = 0.0
reason: str = ""
when: Optional[Dict[str, Any]] = None
def matches(self, node: Dict) -> bool:
"""Check if node matches exemption"""
match = False
if self.name_pattern and re.match(self.name_pattern, node.get("name", "")):
match = True
if self.node_type and node.get("type") == self.node_type:
match = True
# Check additional conditions
if match and self.when:
for key, value in self.when.items():
if node.get(key) != value:
return False
return match
@dataclass
class WorkflowComparisonConfig:
"""Complete configuration for workflow comparison"""
version: str = "1.0"
name: str = "default"
description: str = ""
# Cost weights
node_insertion_cost: float = 10.0
node_deletion_cost: float = 10.0
node_substitution_same_type: float = 1.0
node_substitution_similar_type: float = 5.0
node_substitution_different_type: float = 15.0
node_substitution_trigger: float = 50.0
edge_insertion_cost: float = 5.0
edge_deletion_cost: float = 5.0
edge_substitution_cost: float = 3.0
parameter_mismatch_weight: float = 0.5
parameter_nested_weight: float = 0.3
# Similarity groups
similarity_groups: Dict[str, List[str]] = field(default_factory=dict)
# Ignore rules
ignored_node_rules: List[NodeIgnoreRule] = field(default_factory=list)
ignored_node_types: Set[str] = field(default_factory=set)
ignored_global_parameters: Set[str] = field(default_factory=set)
ignored_node_type_parameters: Dict[str, Set[str]] = field(default_factory=dict)
ignored_parameter_paths: List[str] = field(default_factory=list)
# Parameter comparison rules
parameter_rules: List[ParameterComparisonRule] = field(default_factory=list)
# Exemptions
optional_in_generated: List[ExemptionRule] = field(default_factory=list)
optional_in_ground_truth: List[ExemptionRule] = field(default_factory=list)
# Connection rules
ignored_connection_types: Set[str] = field(default_factory=set)
equivalent_connection_types: List[List[str]] = field(default_factory=list)
# Output config
max_edits: int = 15
group_by: str = "priority"
include_explanations: bool = True
include_suggestions: bool = True
def should_ignore_node(self, node: Dict) -> bool:
"""Check if node should be ignored"""
# Check node type
if node.get("type") in self.ignored_node_types:
return True
# Check node rules
for rule in self.ignored_node_rules:
if rule.matches(node):
return True
return False
def should_ignore_parameter(self, node_type: str, param_path: str) -> bool:
"""Check if parameter should be ignored"""
# Global parameters
param_name = param_path.split(".")[-1]
if param_name in self.ignored_global_parameters:
return True
# Node type specific
if node_type in self.ignored_node_type_parameters:
if param_path in self.ignored_node_type_parameters[node_type]:
return True
# Check wildcards
for ignored_path in self.ignored_node_type_parameters[node_type]:
if self._matches_path_pattern(param_path, ignored_path):
return True
# Parameter path patterns
for pattern in self.ignored_parameter_paths:
if self._matches_path_pattern(param_path, pattern):
return True
return False
def get_parameter_rule(self, param_path: str) -> Optional[ParameterComparisonRule]:
"""Get comparison rule for parameter"""
for rule in self.parameter_rules:
if rule.matches_parameter(param_path):
return rule
return None
def get_exemption_penalty(
self,
node: Dict,
context: str, # 'generated' or 'ground_truth'
) -> Optional[float]:
"""Get exemption penalty for a node, if applicable"""
exemptions = (
self.optional_in_generated
if context == "generated"
else self.optional_in_ground_truth
)
for exemption in exemptions:
if exemption.matches(node):
return exemption.penalty
return None
@staticmethod
def _matches_path_pattern(path: str, pattern: str) -> bool:
"""Check if path matches pattern (supports ** and *)"""
regex_pattern = _get_param_path_matching_pattern(pattern)
return bool(re.match(f"^{regex_pattern}$", path))
def are_node_types_similar(self, type1: str, type2: str) -> bool:
"""Check if two node types are in the same similarity group"""
for group_name, types in self.similarity_groups.items():
if type1 in types and type2 in types:
return True
return False
def to_dict(self) -> Dict:
"""Convert config to dictionary for serialization"""
return {
"version": self.version,
"name": self.name,
"description": self.description,
"costs": {
"nodes": {
"insertion": self.node_insertion_cost,
"deletion": self.node_deletion_cost,
"substitution": {
"same_type": self.node_substitution_same_type,
"similar_type": self.node_substitution_similar_type,
"different_type": self.node_substitution_different_type,
"trigger_mismatch": self.node_substitution_trigger,
},
},
"edges": {
"insertion": self.edge_insertion_cost,
"deletion": self.edge_deletion_cost,
"substitution": self.edge_substitution_cost,
},
"parameters": {
"mismatch_weight": self.parameter_mismatch_weight,
"nested_weight": self.parameter_nested_weight,
},
},
"similarity_groups": self.similarity_groups,
"max_edits": self.max_edits,
}
@classmethod
def from_yaml(cls, path: Path) -> "WorkflowComparisonConfig":
"""Load configuration from YAML file"""
with open(path) as f:
data = yaml.safe_load(f)
return cls._from_dict(data)
@classmethod
def from_json(cls, path: Path) -> "WorkflowComparisonConfig":
"""Load configuration from JSON file"""
with open(path) as f:
data = json.load(f)
return cls._from_dict(data)
@classmethod
def _from_dict(cls, data: Dict) -> "WorkflowComparisonConfig":
"""Parse configuration dictionary"""
config = cls()
# Basic info
config.version = data.get("version", "1.0")
config.name = data.get("name", "default")
config.description = data.get("description", "")
# Costs
costs = data.get("costs", {})
nodes = costs.get("nodes", {})
config.node_insertion_cost = nodes.get("insertion", 10.0)
config.node_deletion_cost = nodes.get("deletion", 10.0)
subst = nodes.get("substitution", {})
config.node_substitution_same_type = subst.get("same_type", 1.0)
config.node_substitution_similar_type = subst.get("similar_type", 5.0)
config.node_substitution_different_type = subst.get("different_type", 15.0)
config.node_substitution_trigger = subst.get("trigger_mismatch", 50.0)
edges = costs.get("edges", {})
config.edge_insertion_cost = edges.get("insertion", 5.0)
config.edge_deletion_cost = edges.get("deletion", 5.0)
config.edge_substitution_cost = edges.get("substitution", 3.0)
params = costs.get("parameters", {})
config.parameter_mismatch_weight = params.get("mismatch_weight", 0.5)
config.parameter_nested_weight = params.get("nested_weight", 0.3)
# Similarity groups
config.similarity_groups = data.get("similarity_groups", {})
# Ignore rules
ignore = data.get("ignore", {})
# Node ignore rules
for node_rule in ignore.get("nodes", []):
config.ignored_node_rules.append(NodeIgnoreRule(**node_rule))
config.ignored_node_types = set(ignore.get("node_types", []))
config.ignored_global_parameters = set(ignore.get("global_parameters", []))
# Node type parameters
for node_type, params in ignore.get("node_type_parameters", {}).items():
config.ignored_node_type_parameters[node_type] = set(params)
config.ignored_parameter_paths = ignore.get("parameter_paths", [])
# Parameter comparison rules
param_comp = data.get("parameter_comparison", {})
for rule_data in param_comp.get("fuzzy_match", []):
config.parameter_rules.append(ParameterComparisonRule(**rule_data))
for rule_data in param_comp.get("numeric_tolerance", []):
rule_data["type"] = "numeric"
config.parameter_rules.append(ParameterComparisonRule(**rule_data))
# Exemptions
exemptions = data.get("exemptions", {})
for exemption_data in exemptions.get("optional_in_generated", []):
config.optional_in_generated.append(ExemptionRule(**exemption_data))
for exemption_data in exemptions.get("optional_in_ground_truth", []):
config.optional_in_ground_truth.append(ExemptionRule(**exemption_data))
# Connection rules
connections = data.get("connections", {})
config.ignored_connection_types = set(
connections.get("ignore_connection_types", [])
)
config.equivalent_connection_types = connections.get("equivalent_types", [])
# Output config
output = data.get("output", {})
config.max_edits = output.get("max_edits", 15)
config.group_by = output.get("group_by", "priority")
config.include_explanations = output.get("include_explanations", True)
config.include_suggestions = output.get("include_suggestions", True)
return config
@classmethod
def load_preset(cls, preset_name: str) -> "WorkflowComparisonConfig":
"""Load a built-in preset configuration"""
presets_dir = Path(__file__).parent / "configs" / "presets"
preset_path = presets_dir / f"{preset_name}.yaml"
if not preset_path.exists():
raise ValueError(f"Preset '{preset_name}' not found at {preset_path}")
return cls.from_yaml(preset_path)
def load_config(config_source: Optional[str] = None) -> WorkflowComparisonConfig:
"""
Load configuration from various sources.
Args:
config_source: Can be:
- None: use default config
- "preset:name": load built-in preset (e.g., "preset:strict")
- "/path/to/config.yaml": load custom YAML file
- "/path/to/config.json": load custom JSON file
Returns:
WorkflowComparisonConfig instance
"""
if not config_source:
return WorkflowComparisonConfig()
if config_source.startswith("preset:"):
preset_name = config_source.split(":", 1)[1]
return WorkflowComparisonConfig.load_preset(preset_name)
path = Path(config_source)
if not path.exists():
raise ValueError(f"Config file not found: {config_source}")
if path.suffix in [".yaml", ".yml"]:
return WorkflowComparisonConfig.from_yaml(path)
elif path.suffix == ".json":
return WorkflowComparisonConfig.from_json(path)
else:
raise ValueError(f"Unsupported config format: {path.suffix}")
@@ -0,0 +1,83 @@
version: "1.0"
name: "lenient"
description: "Lenient comparison - focus on structure over details"
# Cost weights - lower penalties
costs:
nodes:
insertion: 7.0
deletion: 7.0
substitution:
same_type: 0.1 # Very low cost for parameter differences
similar_type: 2.0
different_type: 8.0
trigger_mismatch: 50.0
edges:
insertion: 3.0
deletion: 3.0
substitution: 2.0
parameters:
mismatch_weight: 0.3
nested_weight: 0.1
# Node type similarity groups (same as standard)
similarity_groups:
ai_llms:
- "@n8n/n8n-nodes-langchain.lmChatOpenAi"
- "@n8n/n8n-nodes-langchain.lmChatAnthropic"
- "@n8n/n8n-nodes-langchain.lmChatOllama"
- "@n8n/n8n-nodes-langchain.lmChatMistralCloud"
- "@n8n/n8n-nodes-langchain.lmChatAws"
- "@n8n/n8n-nodes-langchain.lmChatGroq"
ignore:
node_types:
- "n8n-nodes-base.stickyNote"
- "n8n-nodes-base.noOp"
global_parameters:
- "position"
- "name"
- "id"
- "notes"
- "notesInFlow"
- "color"
- "alwaysOutputData"
- "executeOnce"
# Ignore implementation details for specific node types
node_type_parameters:
"@n8n/n8n-nodes-langchain.agent":
- "options.*" # Ignore all options
"@n8n/n8n-nodes-langchain.lmChatOpenAi":
- "model" # Model choice is flexible
"@n8n/n8n-nodes-langchain.lmChatAnthropic":
- "model"
# Ignore credential references (might differ between environments)
parameter_paths:
- "**.credentials"
# Parameter comparison - very tolerant
parameter_comparison:
# High tolerance for numeric values
numeric_tolerance:
- parameter: "options.temperature"
tolerance: 0.3
cost_if_exceeded: 0.5
- parameter: "options.maxTokens"
tolerance: 500
cost_if_exceeded: 0.5
# Output configuration
output:
max_edits: 20
group_by: "priority"
include_explanations: true
include_suggestions: true
@@ -0,0 +1,60 @@
version: "1.0"
name: "standard"
description: "Standard balanced comparison configuration"
# Cost weights - balanced approach
costs:
nodes:
insertion: 10.0
deletion: 10.0
substitution:
same_type: 1.0
similar_type: 5.0
different_type: 15.0
trigger_mismatch: 50.0
edges:
insertion: 2.0
deletion: 2.0
substitution: 1.0
parameters:
mismatch_weight: 0.5
nested_weight: 0.3
# Ignore rules
ignore:
# Ignore UI-only nodes
node_types:
- "n8n-nodes-base.stickyNote"
# Ignore cosmetic parameters
global_parameters:
- "position"
- "id"
- "notes"
- "notesInFlow"
- "color"
# Ignore credential references (might differ between environments)
parameter_paths:
- "**.credentials"
# Parameter comparison rules
parameter_comparison:
# Numeric tolerance for temperature and similar params
numeric_tolerance:
- parameter: "options.temperature"
tolerance: 0.1
cost_if_exceeded: 2.0
- parameter: "options.maxTokens"
tolerance: 100
cost_if_exceeded: 1.0
# Output configuration
output:
max_edits: 29
group_by: "priority"
include_explanations: true
@@ -0,0 +1,44 @@
version: "1.0"
name: "strict"
description: "Strict comparison - exact match required for most aspects"
# Cost weights - higher penalties
costs:
nodes:
insertion: 15.0
deletion: 15.0
substitution:
same_type: 0.5 # Very sensitive to parameter changes
similar_type: 10.0
different_type: 25.0
trigger_mismatch: 100.0 # Trigger mismatch is critical
edges:
insertion: 8.0
deletion: 8.0
substitution: 5.0
parameters:
mismatch_weight: 1.0 # High weight for parameter differences
nested_weight: 0.8
# Minimal ignores - most things should match
ignore:
# Only ignore pure UI elements
node_types:
- "n8n-nodes-base.stickyNote"
# Only ignore metadata that doesn't affect execution
global_parameters:
- "position"
- "id"
# No parameter tolerance - exact match required
parameter_comparison: {}
# Output configuration
output:
max_edits: 20 # Show more edits for strict mode
group_by: "priority"
include_explanations: true
include_suggestions: true
@@ -0,0 +1,497 @@
"""
Cost functions for graph edit distance operations.
Configuration-aware costs for node and edge operations.
"""
import re
from typing import Dict, Any
from src.config_loader import WorkflowComparisonConfig, ParameterComparisonRule
def normalize_expression(value: Any) -> Any:
"""
Normalize expression strings for comparison.
This function:
1. Removes all /* */ style comments
2. Normalizes whitespace
3. Normalizes $fromAI function calls with optional parameters
Args:
value: The value to normalize (typically a string expression)
Returns:
Normalized value for comparison
"""
if not isinstance(value, str):
return value
# Remove all /* */ style comments
cleaned = re.sub(r"/\*.*?\*/", "", value)
# Normalize whitespace - collapse multiple spaces/tabs to single space
cleaned = re.sub(r"\s+", " ", cleaned)
# Normalize $fromAI calls with optional parameters
def normalize_fromAI(match):
# Extract the first parameter and clean it up
first_param = match.group(1).strip()
# If there's a comma in first_param, split and take first part
if "," in first_param:
first_param = first_param.split(",")[0].strip()
return f"$fromAI({first_param})"
cleaned = re.sub(
r"\$fromAI\(([^)]*?)\)",
normalize_fromAI,
cleaned,
)
# Strip any remaining extra whitespace
cleaned = cleaned.strip()
return cleaned
def node_substitution_cost(
node1_data: Dict[str, Any],
node2_data: Dict[str, Any],
config: WorkflowComparisonConfig,
) -> float:
"""
Calculate cost of substituting node1 with node2.
Args:
node1_data: Attributes of first node
node2_data: Attributes of second node
config: Configuration with cost weights
Returns:
Cost value (0 = identical, higher = more different)
"""
# Check for exemptions
original_data = node1_data.get("parameters", "{}")
exemption_penalty = config.get_exemption_penalty(original_data, "generated")
if exemption_penalty is not None:
return exemption_penalty
# Get node types first for trigger detection
type1 = node1_data.get("type", "")
type2 = node2_data.get("type", "")
# Critical: Trigger nodes
is_trigger1 = node1_data.get("is_trigger", False)
is_trigger2 = node2_data.get("is_trigger", False)
# If both are triggers but different types, high cost
if is_trigger1 and is_trigger2 and type1 != type2:
return config.node_substitution_trigger
# If one is trigger and other isn't, high cost
if is_trigger1 != is_trigger2:
return config.node_substitution_trigger
# Same type: compare parameters
if type1 == type2:
# Get parameters directly from node data
params1 = node1_data.get("parameters", {})
params2 = node2_data.get("parameters", {})
param_diff = compare_parameters(params1, params2, type1, config)
# Check if names match using the hash
# This prevents GED from swapping nodes with same type but different names
name_hash1 = node1_data.get("_name_hash", 0)
name_hash2 = node2_data.get("_name_hash", 0)
# If hashes are present and different, add penalty
# This prevents swapping "Slack Assistant" with "Master Orchestrator Agent"
# Names are normalized (smart quotes -> regular quotes) before hashing
name_mismatch_penalty = 0.0
if name_hash1 != 0 and name_hash2 != 0 and name_hash1 != name_hash2:
name_mismatch_penalty = config.node_substitution_different_type * 0.5
# If parameters are identical and names match, no cost
if param_diff == 0 and name_mismatch_penalty == 0:
return 0.0
# Otherwise, base cost plus parameter difference plus name penalty
return (
config.node_substitution_same_type
+ (param_diff * config.parameter_mismatch_weight)
+ name_mismatch_penalty
)
# Similar types (from config)
if config.are_node_types_similar(type1, type2):
return config.node_substitution_similar_type
# Completely different
return config.node_substitution_different_type
def node_deletion_cost(
node_data: Dict[str, Any], config: WorkflowComparisonConfig
) -> float:
"""
Calculate cost of deleting a node.
Args:
node_data: Attributes of the node to delete
config: Configuration with cost weights
Returns:
Cost value
"""
# Check for exemptions
original_data = node_data.get("parameters", "{}")
exemption_penalty = config.get_exemption_penalty(original_data, "generated")
if exemption_penalty is not None:
return exemption_penalty
is_trigger = node_data.get("is_trigger", False)
if is_trigger:
return config.node_substitution_trigger / 2
return config.node_deletion_cost
def node_insertion_cost(
node_data: Dict[str, Any], config: WorkflowComparisonConfig
) -> float:
"""
Calculate cost of inserting a node.
Args:
node_data: Attributes of the node to insert
config: Configuration with cost weights
Returns:
Cost value
"""
# Check for exemptions
original_data = node_data.get("parameters", "{}")
exemption_penalty = config.get_exemption_penalty(original_data, "ground_truth")
if exemption_penalty is not None:
return exemption_penalty
is_trigger = node_data.get("is_trigger", False)
if is_trigger:
return config.node_substitution_trigger / 2
return config.node_insertion_cost
def edge_substitution_cost(
edge1_data: Dict[str, Any],
edge2_data: Dict[str, Any],
config: WorkflowComparisonConfig,
) -> float:
"""
Calculate cost of changing edge connection type.
Args:
edge1_data: Attributes of first edge
edge2_data: Attributes of second edge
config: Configuration with cost weights
Returns:
Cost value
"""
# Check if source and target node types match
# If they do, this edge is likely following a node match
source_type1 = edge1_data.get("source_node_type", "")
source_type2 = edge2_data.get("source_node_type", "")
target_type1 = edge1_data.get("target_node_type", "")
target_type2 = edge2_data.get("target_node_type", "")
# If both source and target node types match, check connection compatibility
if source_type1 == source_type2 and target_type1 == target_type2:
conn_type1 = edge1_data.get("connection_type", "main")
conn_type2 = edge2_data.get("connection_type", "main")
# No cost if connection types are identical
if conn_type1 == conn_type2:
return 0.0
# Check for equivalent types in config
for equiv_group in config.equivalent_connection_types:
if conn_type1 in equiv_group and conn_type2 in equiv_group:
return 0.0 # No cost for equivalent types
# If node types match but connection types differ significantly,
# this is still an edge substitution with cost
return config.edge_substitution_cost
# Node types don't match - this is a structural change
# Higher cost because the edge endpoints are different
return config.edge_substitution_cost
def edge_deletion_cost(
edge_data: Dict[str, Any], config: WorkflowComparisonConfig
) -> float:
"""Calculate cost of deleting an edge"""
return config.edge_deletion_cost
def edge_insertion_cost(
edge_data: Dict[str, Any], config: WorkflowComparisonConfig
) -> float:
"""Calculate cost of inserting an edge"""
return config.edge_insertion_cost
def compare_parameters(
params1: Dict[str, Any],
params2: Dict[str, Any],
node_type: str,
config: WorkflowComparisonConfig,
path_prefix: str = "",
) -> float:
"""
Deep comparison of parameters with config-aware filtering.
Args:
params1: First parameter dictionary
params2: Second parameter dictionary
node_type: Type of the node (for node-specific rules)
config: Configuration with comparison rules
path_prefix: Current parameter path (for nested params)
Returns:
Score representing parameter difference (higher = more different)
"""
all_keys = set(params1.keys()) | set(params2.keys())
diff_score = 0.0
for key in all_keys:
param_path = f"{path_prefix}.{key}" if path_prefix else key
# Check if this parameter should be ignored (should be filtered already, but double-check)
if config.should_ignore_parameter(node_type, param_path):
continue
# Get comparison rule for this parameter
rule = config.get_parameter_rule(param_path)
# Handle missing keys
if key not in params1 or key not in params2:
diff_score += 1.0
continue
val1, val2 = params1[key], params2[key]
# Normalize expressions for comparison
val1_cleaned = normalize_expression(val1)
val2_cleaned = normalize_expression(val2)
# Apply comparison rule if exists
if rule:
cost = apply_comparison_rule(val1_cleaned, val2_cleaned, rule)
diff_score += cost
continue
# Default comparison logic
if isinstance(val1_cleaned, dict) and isinstance(val2_cleaned, dict):
# Recursive for nested objects
nested_diff = compare_parameters(
val1_cleaned, val2_cleaned, node_type, config, param_path
)
diff_score += nested_diff * config.parameter_nested_weight
elif isinstance(val1_cleaned, list) and isinstance(val2_cleaned, list):
# Compare lists (order matters)
diff_score += compare_lists(val1_cleaned, val2_cleaned, config)
elif val1_cleaned != val2_cleaned:
diff_score += 1.0
return diff_score
def compare_lists(list1: list, list2: list, config: WorkflowComparisonConfig) -> float:
"""
Compare two lists with tolerance for order and content.
Args:
list1: First list
list2: Second list
config: Configuration
Returns:
Difference score
"""
# Simple approach: penalize length difference and content difference
if len(list1) != len(list2):
return abs(len(list1) - len(list2)) * 0.5
# Compare elements pairwise
diff = 0.0
for v1, v2 in zip(list1, list2):
if isinstance(v1, dict) and isinstance(v2, dict):
# Nested dict comparison (simplified)
if v1 != v2:
diff += 0.5
elif v1 != v2:
diff += 1.0
return diff
def apply_comparison_rule(val1: Any, val2: Any, rule: ParameterComparisonRule) -> float:
"""
Apply custom comparison rule to parameter values.
Args:
val1: First value
val2: Second value
rule: Comparison rule to apply
Returns:
Cost based on rule
"""
if rule.type == "semantic":
# Semantic similarity (simplified version)
similarity = calculate_semantic_similarity(str(val1), str(val2))
if similarity >= (rule.threshold or 0.8):
return 0.0
return rule.cost_if_below
elif rule.type == "numeric":
# Numeric tolerance
try:
num1, num2 = float(val1), float(val2)
if abs(num1 - num2) <= (rule.tolerance or 0):
return 0.0
return rule.cost_if_exceeded
except (ValueError, TypeError):
# Not numeric, count as different
return rule.cost_if_exceeded
elif rule.type == "normalized":
# Normalized comparison (e.g., URLs)
norm1 = normalize_value(val1, rule.options)
norm2 = normalize_value(val2, rule.options)
if norm1 == norm2:
return 0.0
return 1.0
# Default: exact match
return 0.0 if val1 == val2 else 1.0
def calculate_semantic_similarity(text1: str, text2: str) -> float:
"""
Calculate semantic similarity between two text strings.
This is a placeholder using simple word overlap (Jaccard similarity).
In a production system, this could use sentence-transformers or similar.
Args:
text1: First text
text2: Second text
Returns:
Similarity score 0-1 (1 = identical)
"""
# Convert to lowercase and split into words
words1 = set(text1.lower().split())
words2 = set(text2.lower().split())
if not words1 and not words2:
return 1.0
if not words1 or not words2:
return 0.0
# Jaccard similarity: intersection / union
intersection = words1 & words2
union = words1 | words2
return len(intersection) / len(union)
def normalize_value(value: Any, options: Dict[str, Any]) -> Any:
"""
Normalize value based on options.
Args:
value: Value to normalize
options: Normalization options [ignore_trailing_slash, case_insensitive]
Returns:
Normalized value
"""
if isinstance(value, str):
# URL normalization
if value.startswith("http"):
normalized = value
if options.get("ignore_trailing_slash", False):
normalized = normalized.rstrip("/")
# Could add more URL normalization here
return normalized
# String normalization
if options.get("case_insensitive", False):
return value.lower()
return value
def get_parameter_diff(
params1: Dict[str, Any],
params2: Dict[str, Any],
node_type: str,
config: WorkflowComparisonConfig,
path_prefix: str = "",
) -> Dict[str, Any]:
"""
Get detailed diff of parameters for display purposes.
Args:
params1: First parameter dictionary
params2: Second parameter dictionary
node_type: Type of the node (for node-specific rules)
config: Configuration with comparison rules
path_prefix: Current parameter path (for nested params)
Returns:
Dictionary with added, removed, and changed parameters
"""
diff = {"added": {}, "removed": {}, "changed": {}}
all_keys = set(params1.keys()) | set(params2.keys())
for key in all_keys:
param_path = f"{path_prefix}.{key}" if path_prefix else key
# Skip ignored parameters
if config.should_ignore_parameter(node_type, param_path):
continue
if key not in params1:
# Parameter added in params2
diff["added"][key] = params2[key]
elif key not in params2:
# Parameter removed in params1
diff["removed"][key] = params1[key]
else:
val1, val2 = params1[key], params2[key]
# Normalize expressions before comparing
val1_cleaned = normalize_expression(val1)
val2_cleaned = normalize_expression(val2)
# Handle nested dicts
if isinstance(val1_cleaned, dict) and isinstance(val2_cleaned, dict):
nested_diff = get_parameter_diff(
val1_cleaned, val2_cleaned, node_type, config, param_path
)
if any(nested_diff.values()):
diff["changed"][key] = nested_diff
elif val1_cleaned != val2_cleaned:
# Store cleaned values for display
diff["changed"][key] = {"from": val1_cleaned, "to": val2_cleaned}
# Clean up empty sections
diff = {k: v for k, v in diff.items() if v}
return diff
@@ -0,0 +1,222 @@
"""
Build NetworkX graphs from n8n workflow JSON structures.
"""
import networkx as nx
from typing import Dict, Any, Optional
from src.config_loader import WorkflowComparisonConfig
def build_workflow_graph(
workflow: Dict[str, Any], config: Optional[WorkflowComparisonConfig] = None
) -> nx.DiGraph:
"""
Convert n8n workflow to NetworkX directed graph.
Args:
workflow: n8n workflow JSON (with 'nodes' and 'connections')
config: Optional configuration for filtering
Returns:
NetworkX DiGraph with nodes and edges
"""
G = nx.DiGraph()
if config is None:
config = WorkflowComparisonConfig()
# Add nodes with attributes
for node in workflow.get("nodes", []):
# Check if node should be ignored
if config.should_ignore_node(node):
continue
node_name = node.get("name", node.get("id", "unknown"))
# Filter parameters based on config
filtered_params = _filter_parameters(
node.get("parameters", {}), node.get("type", ""), config
)
# Add node with filtered parameters
G.add_node(
node_name,
type=node.get("type", ""),
type_version=node.get("typeVersion", 1),
parameters=filtered_params,
is_trigger=_is_trigger_node(node),
)
# Add edges from connections
connections = workflow.get("connections", {})
for source_name, source_conns in connections.items():
# Skip if source node was filtered out
if source_name not in G:
continue
for conn_type, conn_arrays in source_conns.items():
# Check if this connection type should be ignored
if conn_type in config.ignored_connection_types:
continue
# conn_arrays is typically: [[connections], [connections], ...]
# where first index is output index
for output_index, conn_array in enumerate(conn_arrays):
if conn_array is None:
continue
for conn in conn_array:
target_name = conn.get("node")
# Skip if target node was filtered out
if target_name not in G:
continue
# Add edge with connection metadata
# Include source and target node info for better matching
source_node_data = G.nodes[source_name]
target_node_data = G.nodes[target_name]
G.add_edge(
source_name,
target_name,
connection_type=conn.get("type", "main"),
source_index=output_index,
target_index=conn.get("index", 0),
source_node_type=source_node_data.get("type", ""),
target_node_type=target_node_data.get("type", ""),
)
return G
def _filter_parameters(
params: Dict[str, Any],
node_type: str,
config: WorkflowComparisonConfig,
path_prefix: str = "",
) -> Dict[str, Any]:
"""
Filter parameters based on configuration ignore rules.
Args:
params: Parameter dictionary to filter
node_type: Type of the node (for node-specific filtering)
config: Configuration with ignore rules
path_prefix: Current parameter path (for nested params)
Returns:
Filtered parameter dictionary
"""
filtered = {}
for key, value in params.items():
param_path = f"{path_prefix}.{key}" if path_prefix else key
# Check if this parameter should be ignored
if config.should_ignore_parameter(node_type, param_path):
continue
# Recursively filter nested dictionaries
if isinstance(value, dict):
nested_filtered = _filter_parameters(value, node_type, config, param_path)
if nested_filtered: # Only include if not empty
filtered[key] = nested_filtered
else:
filtered[key] = value
return filtered
def _is_trigger_node(node: Dict[str, Any]) -> bool:
"""
Detect if a node is a trigger node.
Args:
node: Node dictionary
Returns:
True if node is a trigger
"""
node_type = node.get("type", "").lower()
node_name = node.get("name", "").lower()
# Check if 'trigger' is in type or name
if "trigger" in node_type or "trigger" in node_name:
return True
# Check for known trigger node types that don't have 'trigger' in the name
trigger_types = [
"webhook", # n8n-nodes-base.webhook
"webhooktrigger",
"manualtrigger",
"scheduletrigger",
"chattrigger",
"cron", # n8n-nodes-base.cron
]
for trigger_type in trigger_types:
if trigger_type in node_type:
return True
return False
def get_node_data(graph: nx.DiGraph, node_name: str) -> Dict[str, Any]:
"""
Get all data for a node from the graph.
Args:
graph: NetworkX graph
node_name: Name of the node
Returns:
Dictionary with node attributes
"""
if node_name not in graph:
return {}
return dict(graph.nodes[node_name])
def get_edge_data(graph: nx.DiGraph, source: str, target: str) -> Dict[str, Any]:
"""
Get all data for an edge from the graph.
Args:
graph: NetworkX graph
source: Source node name
target: Target node name
Returns:
Dictionary with edge attributes
"""
if not graph.has_edge(source, target):
return {}
return dict(graph.edges[source, target])
def graph_stats(graph: nx.DiGraph) -> Dict[str, Any]:
"""
Get statistics about a workflow graph.
Args:
graph: NetworkX graph
Returns:
Dictionary with graph statistics
"""
return {
"node_count": graph.number_of_nodes(),
"edge_count": graph.number_of_edges(),
"trigger_count": sum(
1 for _, data in graph.nodes(data=True) if data.get("is_trigger", False)
),
"node_types": list(
set(data.get("type", "unknown") for _, data in graph.nodes(data=True))
),
"is_connected": nx.is_weakly_connected(graph)
if graph.number_of_nodes() > 0
else False,
}
@@ -0,0 +1,501 @@
"""
Calculate workflow similarity using graph edit distance.
"""
import networkx as nx
from typing import Dict, List, Any, Optional
from src.config_loader import WorkflowComparisonConfig
from src.cost_functions import (
node_substitution_cost,
node_deletion_cost,
node_insertion_cost,
edge_substitution_cost,
edge_deletion_cost,
edge_insertion_cost,
get_parameter_diff,
)
def calculate_graph_edit_distance(
g1: nx.DiGraph, g2: nx.DiGraph, config: WorkflowComparisonConfig
) -> Dict[str, Any]:
"""
Calculate graph edit distance with custom cost functions.
Args:
g1: First workflow graph (generated)
g2: Second workflow graph (ground truth)
config: Configuration with cost weights
Returns:
Dictionary with:
- similarity_score: 0-1 (1 = identical)
- edit_cost: Total cost of edits
- max_possible_cost: Theoretical maximum cost
- top_edits: List of most important edit operations
"""
# Handle empty graphs
if g1.number_of_nodes() == 0 and g2.number_of_nodes() == 0:
return {
"similarity_score": 1.0,
"edit_cost": 0.0,
"max_possible_cost": 0.0,
"top_edits": [],
}
# Relabel graphs to use structural IDs instead of node names
# This ensures nodes are matched by type/position, not by name
g1_relabeled, g1_mapping = _relabel_graph_by_structure(g1)
g2_relabeled, g2_mapping = _relabel_graph_by_structure(g2)
# Create cost function closures with config
# NetworkX passes node ATTRIBUTE DICTS, not node names
def node_subst_cost(n1_attrs, n2_attrs):
return node_substitution_cost(n1_attrs, n2_attrs, config)
def node_del_cost(n_attrs):
return node_deletion_cost(n_attrs, config)
def node_ins_cost(n_attrs):
return node_insertion_cost(n_attrs, config)
# Edge match function - returns True if edges are equivalent
# This is better than cost functions for preventing false positives
def edge_match(e1_attrs, e2_attrs):
"""Check if two edges match (same connection type or equivalent)"""
conn_type1 = e1_attrs.get("connection_type", "main")
conn_type2 = e2_attrs.get("connection_type", "main")
# Exact match
if conn_type1 == conn_type2:
return True
# Check for equivalent types in config
for equiv_group in config.equivalent_connection_types:
if conn_type1 in equiv_group and conn_type2 in equiv_group:
return True
return False
# Calculate GED using NetworkX
# Note: This can be slow for large graphs, but workflow graphs are typically small
try:
# Use optimize_edit_paths with edge_match instead of edge cost functions
# This prevents false positive edge insertions/deletions
edit_path_generator = nx.optimize_edit_paths(
g1_relabeled,
g2_relabeled,
node_subst_cost=node_subst_cost,
node_del_cost=node_del_cost,
node_ins_cost=node_ins_cost,
edge_match=edge_match,
upper_bound=None, # Calculate exact
)
# Get the best (first) edit path
best_edit_path = None
for node_edit_path, edge_edit_path, cost in edit_path_generator:
best_edit_path = (node_edit_path, edge_edit_path, cost)
break # Take the first (best) path
if not best_edit_path:
# Fallback to basic calculation
edit_cost = _calculate_basic_edit_cost(g1, g2, config)
node_edit_path = []
edge_edit_path = []
else:
node_edit_path, edge_edit_path, edit_cost = best_edit_path
# Extract and rank edit operations
# Use the actual edit path from NetworkX if available
if best_edit_path:
edit_ops = _extract_operations_from_path(
node_edit_path,
edge_edit_path,
g1_relabeled,
g2_relabeled,
config,
g1_mapping,
g2_mapping,
)
else:
edit_ops = []
except Exception as e:
# Fallback if NetworkX GED fails
print(f"Warning: GED calculation failed, using fallback: {e}")
edit_cost = _calculate_basic_edit_cost(g1, g2, config)
edit_ops = []
# Calculate theoretical maximum cost
max_cost = _calculate_max_cost(g1, g2, config)
# Avoid division by zero
if max_cost == 0:
similarity_score = 1.0 if edit_cost == 0 else 0.0
else:
# Similarity score: 1 - (cost / max_cost)
similarity_score = max(0.0, min(1.0, 1.0 - (edit_cost / max_cost)))
return {
"similarity_score": similarity_score,
"edit_cost": edit_cost,
"max_possible_cost": max_cost,
"top_edits": sorted(edit_ops, key=lambda x: x["cost"], reverse=True),
}
def _calculate_basic_edit_cost(
g1: nx.DiGraph, g2: nx.DiGraph, config: WorkflowComparisonConfig
) -> float:
"""
Calculate a basic edit cost when full GED fails.
Uses simple node and edge count differences.
Args:
g1: First graph
g2: Second graph
config: Configuration
Returns:
Estimated edit cost
"""
cost = 0.0
# Node differences
nodes1 = set(g1.nodes())
nodes2 = set(g2.nodes())
# Nodes only in g1 (need to be deleted)
deleted_nodes = nodes1 - nodes2
cost += len(deleted_nodes) * config.node_deletion_cost
# Nodes only in g2 (need to be inserted)
inserted_nodes = nodes2 - nodes1
cost += len(inserted_nodes) * config.node_insertion_cost
# Nodes in both (might need substitution)
common_nodes = nodes1 & nodes2
for node in common_nodes:
subst_cost = node_substitution_cost(g1.nodes[node], g2.nodes[node], config)
cost += subst_cost
# Edge differences
edges1 = set(g1.edges())
edges2 = set(g2.edges())
deleted_edges = edges1 - edges2
cost += len(deleted_edges) * config.edge_deletion_cost
inserted_edges = edges2 - edges1
cost += len(inserted_edges) * config.edge_insertion_cost
return cost
def _calculate_max_cost(
g1: nx.DiGraph, g2: nx.DiGraph, config: WorkflowComparisonConfig
) -> float:
"""
Calculate theoretical maximum edit cost.
This represents the cost of completely transforming g1 to g2.
Args:
g1: First graph
g2: Second graph
config: Configuration
Returns:
Maximum possible cost
"""
# Worst case: delete all of g1, insert all of g2
delete_cost = (
len(g1.nodes()) * config.node_deletion_cost
+ len(g1.edges()) * config.edge_deletion_cost
)
insert_cost = (
len(g2.nodes()) * config.node_insertion_cost
+ len(g2.edges()) * config.edge_insertion_cost
)
return delete_cost + insert_cost
def _extract_operations_from_path(
node_edit_path: List[tuple],
edge_edit_path: List[tuple],
g1: nx.DiGraph,
g2: nx.DiGraph,
config: WorkflowComparisonConfig,
g1_name_mapping: Dict[str, str],
g2_name_mapping: Dict[str, str],
) -> List[Dict[str, Any]]:
"""
Extract edit operations from NetworkX's edit path.
Args:
node_edit_path: List of node edit tuples (u, v) where:
- (u, v): nodes u in g1 and v in g2 are matched/substituted
- (u, None): node u in g1 is deleted
- (None, v): node v in g2 is inserted
edge_edit_path: List of edge edit tuples ((u1, v1), (u2, v2))
g1, g2: Relabeled graphs
config: Configuration
g1_name_mapping, g2_name_mapping: Mappings to original names
Returns:
List of edit operations with descriptions and costs
"""
operations = []
# Helper to get display name
def get_display_name(
node_id: str, mapping: Dict[str, str], graph: nx.DiGraph
) -> str:
if mapping and node_id in mapping:
return mapping[node_id]
return graph.nodes[node_id].get("_original_name", node_id)
# Process node edits
for u, v in node_edit_path:
if u is None:
# Node insertion (v in g2 is inserted)
node_data = g2.nodes[v]
display_name = get_display_name(v, g2_name_mapping, g2)
cost = node_insertion_cost(node_data, config)
if cost > 0:
operations.append(
{
"type": "node_insert",
"description": f"Add missing node '{display_name}' (type: {node_data.get('type', 'unknown')})",
"cost": cost,
"priority": _determine_priority(
cost, config, node_data, "node_insert"
),
"node_name": display_name,
}
)
elif v is None:
# Node deletion (u in g1 is deleted)
node_data = g1.nodes[u]
display_name = get_display_name(u, g1_name_mapping, g1)
cost = node_deletion_cost(node_data, config)
if cost > 0:
operations.append(
{
"type": "node_delete",
"description": f"Remove node '{display_name}' (type: {node_data.get('type', 'unknown')})",
"cost": cost,
"priority": _determine_priority(
cost, config, node_data, "node_delete"
),
"node_name": display_name,
}
)
else:
# Node substitution (u in g1 matched to v in g2)
node1_data = g1.nodes[u]
node2_data = g2.nodes[v]
display_name = get_display_name(u, g1_name_mapping, g1)
cost = node_substitution_cost(node1_data, node2_data, config)
if cost > 0:
type1 = node1_data.get("type", "unknown")
type2 = node2_data.get("type", "unknown")
operation_data = {
"type": "node_substitute",
"cost": cost,
"priority": _determine_priority(
cost, config, node1_data, "node_substitute"
),
"node_name": display_name,
}
if type1 != type2:
operation_data["description"] = (
f"Change node '{display_name}' from type '{type1}' to '{type2}'"
)
else:
operation_data["description"] = (
f"Update parameters of node '{display_name}' (type: {type1})"
)
# Extract parameter diff for same-type substitutions
params1 = node1_data.get("parameters", {})
params2 = node2_data.get("parameters", {})
if params1 or params2:
param_diff = get_parameter_diff(params1, params2, type1, config)
if param_diff:
operation_data["parameter_diff"] = param_diff
operations.append(operation_data)
# Process edge edits
# Note: With edge_match function, the GED algorithm should only report
# edges that truly differ, so we don't need complex filtering here
for e1, e2 in edge_edit_path:
if e1 is None:
# Edge insertion
u2, v2 = e2
edge_data = g2.edges[e2]
source_display = get_display_name(u2, g2_name_mapping, g2)
target_display = get_display_name(v2, g2_name_mapping, g2)
cost = edge_insertion_cost(edge_data, config)
if cost > 0:
operations.append(
{
"type": "edge_insert",
"description": f"Add missing connection from '{source_display}' to '{target_display}'",
"cost": cost,
"priority": _determine_priority(cost, config),
}
)
elif e2 is None:
# Edge deletion
u1, v1 = e1
edge_data = g1.edges[e1]
source_display = get_display_name(u1, g1_name_mapping, g1)
target_display = get_display_name(v1, g1_name_mapping, g1)
cost = edge_deletion_cost(edge_data, config)
if cost > 0:
operations.append(
{
"type": "edge_delete",
"description": f"Remove connection from '{source_display}' to '{target_display}'",
"cost": cost,
"priority": _determine_priority(cost, config),
}
)
else:
# Edge substitution
u1, v1 = e1
u2, v2 = e2
edge1_data = g1.edges[e1]
edge2_data = g2.edges[e2]
cost = edge_substitution_cost(edge1_data, edge2_data, config)
if cost > 0:
source_display = get_display_name(u1, g1_name_mapping, g1)
target_display = get_display_name(v1, g1_name_mapping, g1)
operations.append(
{
"type": "edge_substitute",
"description": f"Update connection from '{source_display}' to '{target_display}'",
"cost": cost,
"priority": _determine_priority(cost, config),
}
)
return operations
def _determine_priority(
cost: float,
config: WorkflowComparisonConfig,
node_data: Optional[Dict[str, Any]] = None,
operation_type: Optional[str] = None,
) -> str:
"""
Determine priority level based on cost, node type, and operation.
Args:
cost: Edit operation cost
config: Configuration
node_data: Optional node data to check if it's a trigger
operation_type: Type of operation (node_insert, node_delete, node_substitute, etc.)
Returns:
Priority level: 'critical', 'major', or 'minor'
"""
# Critical: trigger deletions/insertions (but not minor parameter updates)
if node_data and node_data.get("is_trigger", False):
if operation_type in ("node_insert", "node_delete"):
return "critical"
# Critical: trigger mismatches and high-cost operations
if cost >= config.node_substitution_trigger * 0.8:
return "critical"
elif cost >= config.node_substitution_different_type * 0.8:
return "major"
else:
return "minor"
def _relabel_graph_by_structure(graph: nx.DiGraph) -> tuple[nx.DiGraph, Dict[str, str]]:
"""
Relabel graph nodes using structural IDs instead of names.
This ensures nodes are matched by their type and position in the workflow,
not by their display names. The original name is preserved as a node attribute.
Args:
graph: Original graph with name-based node IDs
Returns:
Tuple of (relabeled_graph, mapping_dict) where:
- relabeled_graph: Graph with structural IDs
- mapping_dict: Maps new IDs back to original names
"""
# Sort nodes by structural properties for consistent matching
nodes_with_data = list(graph.nodes(data=True))
# Define sorting key based on structural properties
def node_sort_key(node_tuple):
name, data = node_tuple
return (
data.get("type", ""), # Sort by type for consistency
-graph.out_degree(name), # Then by out-degree (descending)
-graph.in_degree(name), # Then by in-degree (descending)
name, # Finally by name for deterministic ordering
)
# Separate and sort triggers and non-triggers
triggers = sorted(
[
(name, data)
for name, data in nodes_with_data
if data.get("is_trigger", False)
],
key=node_sort_key,
)
non_triggers = sorted(
[
(name, data)
for name, data in nodes_with_data
if not data.get("is_trigger", False)
],
key=node_sort_key,
)
# Create new labels: trigger_0, trigger_1, node_0, node_1, etc.
mapping = {}
reverse_mapping = {}
for i, (original_name, _) in enumerate(triggers):
new_label = f"trigger_{i}"
mapping[original_name] = new_label
reverse_mapping[new_label] = original_name
for i, (original_name, _) in enumerate(non_triggers):
new_label = f"node_{i}"
mapping[original_name] = new_label
reverse_mapping[new_label] = original_name
# Create relabeled graph - this preserves all node attributes
relabeled = nx.relabel_nodes(graph, mapping, copy=True)
# Store original names in node attributes for matching
# This helps the GED algorithm match nodes correctly
for new_label, original_name in reverse_mapping.items():
if new_label in relabeled.nodes:
relabeled.nodes[new_label]["_original_name"] = original_name
# Add a normalized name hash to help with matching nodes of the same type
# Normalize by replacing smart quotes with regular quotes for comparison
# U+2018 (') -> U+0027 ('), U+2019 (') -> U+0027 (')
# U+201C (") -> U+0022 ("), U+201D (") -> U+0022 (")
normalized_name = original_name.replace("\u2018", "'").replace(
"\u2019", "'"
)
normalized_name = normalized_name.replace("\u201c", '"').replace(
"\u201d", '"'
)
relabeled.nodes[new_label]["_name_hash"] = hash(normalized_name)
return relabeled, reverse_mapping
@@ -0,0 +1 @@
"""Tests for n8n workflow comparison"""
@@ -0,0 +1,161 @@
"""
Tests for graph_builder module.
"""
from src.graph_builder import build_workflow_graph, graph_stats, _is_trigger_node
from src.config_loader import WorkflowComparisonConfig
def test_build_simple_workflow_graph():
"""Test building graph from simple workflow"""
workflow = {
"name": "Test Workflow",
"nodes": [
{
"id": "1",
"name": "Trigger",
"type": "n8n-nodes-base.webhook",
"parameters": {"path": "/test"},
},
{
"id": "2",
"name": "Process",
"type": "n8n-nodes-base.code",
"parameters": {},
},
],
"connections": {
"Trigger": {"main": [[{"node": "Process", "type": "main", "index": 0}]]}
},
}
graph = build_workflow_graph(workflow)
assert graph.number_of_nodes() == 2
assert graph.number_of_edges() == 1
assert "Trigger" in graph.nodes
assert "Process" in graph.nodes
assert graph.has_edge("Trigger", "Process")
def test_build_graph_with_filtering():
"""Test that ignored nodes are filtered out"""
workflow = {
"name": "Test Workflow",
"nodes": [
{
"id": "1",
"name": "Note",
"type": "n8n-nodes-base.stickyNote",
"parameters": {"content": "This is a note"},
},
{
"id": "2",
"name": "Trigger",
"type": "n8n-nodes-base.webhook",
"parameters": {},
},
],
"connections": {},
}
config = WorkflowComparisonConfig()
config.ignored_node_types.add("n8n-nodes-base.stickyNote")
graph = build_workflow_graph(workflow, config)
# Sticky note should be filtered out
assert graph.number_of_nodes() == 1
assert "Trigger" in graph.nodes
assert "Note" not in graph.nodes
def test_parameter_filtering():
"""Test that ignored parameters are filtered"""
workflow = {
"name": "Test",
"nodes": [
{
"id": "1",
"name": "Node1",
"type": "test.node",
"position": [100, 200],
"parameters": {
"important": "value",
"position": [100, 200],
"id": "123",
},
}
],
"connections": {},
}
config = WorkflowComparisonConfig()
config.ignored_global_parameters = {"position", "id"}
graph = build_workflow_graph(workflow, config)
node_data = graph.nodes["Node1"]
params = node_data["parameters"]
assert "important" in params
assert "position" not in params
assert "id" not in params
def test_is_trigger_node():
"""Test trigger node detection"""
trigger_node = {"type": "n8n-nodes-base.webhook", "name": "Webhook Trigger"}
assert _is_trigger_node(trigger_node) is True
regular_node = {"type": "n8n-nodes-base.httpRequest", "name": "HTTP Request"}
assert _is_trigger_node(regular_node) is False
def test_graph_stats():
"""Test graph statistics calculation"""
workflow = {
"name": "Test",
"nodes": [
{
"id": "1",
"name": "Trigger",
"type": "n8n-nodes-base.manualTrigger",
"parameters": {},
},
{
"id": "2",
"name": "Node1",
"type": "n8n-nodes-base.code",
"parameters": {},
},
{
"id": "3",
"name": "Node2",
"type": "n8n-nodes-base.code",
"parameters": {},
},
],
"connections": {
"Trigger": {"main": [[{"node": "Node1", "type": "main", "index": 0}]]},
"Node1": {"main": [[{"node": "Node2", "type": "main", "index": 0}]]},
},
}
graph = build_workflow_graph(workflow)
stats = graph_stats(graph)
assert stats["node_count"] == 3
assert stats["edge_count"] == 2
assert stats["trigger_count"] == 1
assert stats["is_connected"] is True
def test_empty_workflow():
"""Test building graph from empty workflow"""
workflow = {"name": "Empty", "nodes": [], "connections": {}}
graph = build_workflow_graph(workflow)
assert graph.number_of_nodes() == 0
assert graph.number_of_edges() == 0
@@ -0,0 +1,328 @@
"""
Tests for similarity module.
"""
from src.graph_builder import build_workflow_graph
from src.similarity import calculate_graph_edit_distance
from src.config_loader import WorkflowComparisonConfig
def test_identical_workflows():
"""Test that identical workflows have 100% similarity"""
workflow = {
"name": "Test",
"nodes": [
{
"id": "1",
"name": "Trigger",
"type": "n8n-nodes-base.webhook",
"parameters": {"path": "/test"},
}
],
"connections": {},
}
config = WorkflowComparisonConfig()
g1 = build_workflow_graph(workflow, config)
g2 = build_workflow_graph(workflow, config)
result = calculate_graph_edit_distance(g1, g2, config)
assert result["similarity_score"] == 1.0
assert result["edit_cost"] == 0.0
assert len(result["top_edits"]) == 0
def test_empty_workflows():
"""Test that empty workflows are identical"""
workflow = {"name": "Empty", "nodes": [], "connections": {}}
config = WorkflowComparisonConfig()
g1 = build_workflow_graph(workflow, config)
g2 = build_workflow_graph(workflow, config)
result = calculate_graph_edit_distance(g1, g2, config)
assert result["similarity_score"] == 1.0
assert result["edit_cost"] == 0.0
def test_missing_node():
"""Test similarity when one workflow is missing a node"""
workflow1 = {
"name": "Test1",
"nodes": [{"id": "1", "name": "Node1", "type": "test.node", "parameters": {}}],
"connections": {},
}
workflow2 = {
"name": "Test2",
"nodes": [
{"id": "1", "name": "Node1", "type": "test.node", "parameters": {}},
{"id": "2", "name": "Node2", "type": "test.node", "parameters": {}},
],
"connections": {},
}
config = WorkflowComparisonConfig()
g1 = build_workflow_graph(workflow1, config)
g2 = build_workflow_graph(workflow2, config)
result = calculate_graph_edit_distance(g1, g2, config)
# Should have less than 100% similarity
assert result["similarity_score"] < 1.0
# Should have edit operations
assert len(result["top_edits"]) > 0
# Should have a node insertion edit
assert any(edit["type"] == "node_insert" for edit in result["top_edits"])
def test_trigger_mismatch():
"""Test that trigger mismatches have high cost"""
workflow1 = {
"name": "Test1",
"nodes": [
{
"id": "1",
"name": "Trigger",
"type": "n8n-nodes-base.webhook",
"parameters": {},
}
],
"connections": {},
}
workflow2 = {
"name": "Test2",
"nodes": [
{
"id": "1",
"name": "Trigger",
"type": "n8n-nodes-base.manualTrigger",
"parameters": {},
}
],
"connections": {},
}
config = WorkflowComparisonConfig()
g1 = build_workflow_graph(workflow1, config)
g2 = build_workflow_graph(workflow2, config)
result = calculate_graph_edit_distance(g1, g2, config)
# Trigger mismatch should result in low similarity
assert result["similarity_score"] < 0.8
# NetworkX may choose delete+insert (cost 20) over substitution (cost 50)
# but the important thing is that we detect it as a critical issue
assert result["edit_cost"] >= (
config.node_deletion_cost + config.node_insertion_cost
)
# Should have critical priority edit in the top edits
assert len(result["top_edits"]) > 0
assert any(edit["priority"] == "critical" for edit in result["top_edits"])
def test_parameter_differences():
"""Test that parameter differences affect similarity"""
workflow1 = {
"name": "Test1",
"nodes": [
{
"id": "1",
"name": "Node",
"type": "test.node",
"parameters": {"value": "a"},
}
],
"connections": {},
}
workflow2 = {
"name": "Test2",
"nodes": [
{
"id": "1",
"name": "Node",
"type": "test.node",
"parameters": {"value": "b"},
}
],
"connections": {},
}
config = WorkflowComparisonConfig()
g1 = build_workflow_graph(workflow1, config)
g2 = build_workflow_graph(workflow2, config)
result = calculate_graph_edit_distance(g1, g2, config)
# Should not be identical due to parameter difference
assert result["similarity_score"] < 1.0
# But should still be fairly similar (same structure)
assert result["similarity_score"] > 0.8
def test_connection_difference():
"""Test that connection differences are detected"""
workflow1 = {
"name": "Test1",
"nodes": [
{"id": "1", "name": "Node1", "type": "test.node", "parameters": {}},
{"id": "2", "name": "Node2", "type": "test.node", "parameters": {}},
],
"connections": {}, # No connections
}
workflow2 = {
"name": "Test2",
"nodes": [
{"id": "1", "name": "Node1", "type": "test.node", "parameters": {}},
{"id": "2", "name": "Node2", "type": "test.node", "parameters": {}},
],
"connections": {
"Node1": {"main": [[{"node": "Node2", "type": "main", "index": 0}]]}
},
}
config = WorkflowComparisonConfig()
g1 = build_workflow_graph(workflow1, config)
g2 = build_workflow_graph(workflow2, config)
result = calculate_graph_edit_distance(g1, g2, config)
# Should have lower similarity due to missing connection
assert result["similarity_score"] < 1.0
# Should have edge insertion in edits
assert any(edit["type"] == "edge_insert" for edit in result["top_edits"])
def test_trigger_parameter_update_priority():
"""Test that minor trigger parameter updates are not marked as critical."""
from src.config_loader import load_config
workflow1 = {
"name": "Test1",
"nodes": [
{
"id": "1",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"parameters": {"path": "test1"},
}
],
"connections": {},
}
workflow2 = {
"name": "Test2",
"nodes": [
{
"id": "1",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"parameters": {"path": "test2"},
}
],
"connections": {},
}
config = load_config("preset:lenient")
g1 = build_workflow_graph(workflow1, config)
g2 = build_workflow_graph(workflow2, config)
result = calculate_graph_edit_distance(g1, g2, config)
# Should have high similarity (just parameter change)
assert result["similarity_score"] > 0.9
# Should have one edit (parameter update)
assert len(result["top_edits"]) == 1
edit = result["top_edits"][0]
# Should NOT be critical priority (just a minor parameter change)
assert edit["priority"] != "critical"
assert edit["type"] == "node_substitute"
def test_trigger_deletion_is_critical():
"""Test that trigger deletions are marked as critical."""
from src.config_loader import WorkflowComparisonConfig
workflow1 = {
"name": "Test1",
"nodes": [
{
"id": "1",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"parameters": {},
},
{"id": "2", "name": "Node", "type": "test.node", "parameters": {}},
],
"connections": {},
}
workflow2 = {
"name": "Test2",
"nodes": [{"id": "2", "name": "Node", "type": "test.node", "parameters": {}}],
"connections": {},
}
config = WorkflowComparisonConfig()
g1 = build_workflow_graph(workflow1, config)
g2 = build_workflow_graph(workflow2, config)
result = calculate_graph_edit_distance(g1, g2, config)
# Should have lower similarity
assert result["similarity_score"] < 0.8
# Should have a critical priority edit (trigger deletion)
assert any(
edit["priority"] == "critical" and edit["type"] == "node_delete"
for edit in result["top_edits"]
)
def test_trigger_insertion_is_critical():
"""Test that trigger insertions are marked as critical."""
from src.config_loader import WorkflowComparisonConfig
workflow1 = {
"name": "Test1",
"nodes": [{"id": "1", "name": "Node", "type": "test.node", "parameters": {}}],
"connections": {},
}
workflow2 = {
"name": "Test2",
"nodes": [
{
"id": "1",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"parameters": {},
},
{"id": "2", "name": "Node", "type": "test.node", "parameters": {}},
],
"connections": {},
}
config = WorkflowComparisonConfig()
g1 = build_workflow_graph(workflow1, config)
g2 = build_workflow_graph(workflow2, config)
result = calculate_graph_edit_distance(g1, g2, config)
# Should have lower similarity
assert result["similarity_score"] < 0.8
# Should have a critical priority edit (trigger insertion)
assert any(
edit["priority"] == "critical" and edit["type"] == "node_insert"
for edit in result["top_edits"]
)
@@ -0,0 +1,521 @@
version = 1
revision = 2
requires-python = ">=3.11"
[[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 = "coverage"
version = "7.11.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d2/59/9698d57a3b11704c7b89b21d69e9d23ecf80d538cabb536c8b63f4a12322/coverage-7.11.3.tar.gz", hash = "sha256:0f59387f5e6edbbffec2281affb71cdc85e0776c1745150a3ab9b6c1d016106b", size = 815210, upload_time = "2025-11-10T00:13:17.18Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/92/43a961c0f57b666d01c92bcd960c7f93677de5e4ee7ca722564ad6dee0fa/coverage-7.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:200bb89fd2a8a07780eafcdff6463104dec459f3c838d980455cfa84f5e5e6e1", size = 216504, upload_time = "2025-11-10T00:10:49.524Z" },
{ url = "https://files.pythonhosted.org/packages/5d/5c/dbfc73329726aef26dbf7fefef81b8a2afd1789343a579ea6d99bf15d26e/coverage-7.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d264402fc179776d43e557e1ca4a7d953020d3ee95f7ec19cc2c9d769277f06", size = 217006, upload_time = "2025-11-10T00:10:51.32Z" },
{ url = "https://files.pythonhosted.org/packages/a5/e0/878c84fb6661964bc435beb1e28c050650aa30e4c1cdc12341e298700bda/coverage-7.11.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:385977d94fc155f8731c895accdfcc3dd0d9dd9ef90d102969df95d3c637ab80", size = 247415, upload_time = "2025-11-10T00:10:52.805Z" },
{ url = "https://files.pythonhosted.org/packages/56/9e/0677e78b1e6a13527f39c4b39c767b351e256b333050539861c63f98bd61/coverage-7.11.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0542ddf6107adbd2592f29da9f59f5d9cff7947b5bb4f734805085c327dcffaa", size = 249332, upload_time = "2025-11-10T00:10:54.35Z" },
{ url = "https://files.pythonhosted.org/packages/54/90/25fc343e4ce35514262451456de0953bcae5b37dda248aed50ee51234cee/coverage-7.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d60bf4d7f886989ddf80e121a7f4d140d9eac91f1d2385ce8eb6bda93d563297", size = 251443, upload_time = "2025-11-10T00:10:55.832Z" },
{ url = "https://files.pythonhosted.org/packages/13/56/bc02bbc890fd8b155a64285c93e2ab38647486701ac9c980d457cdae857a/coverage-7.11.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0a3b6e32457535df0d41d2d895da46434706dd85dbaf53fbc0d3bd7d914b362", size = 247554, upload_time = "2025-11-10T00:10:57.829Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ab/0318888d091d799a82d788c1e8d8bd280f1d5c41662bbb6e11187efe33e8/coverage-7.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:876a3ee7fd2613eb79602e4cdb39deb6b28c186e76124c3f29e580099ec21a87", size = 249139, upload_time = "2025-11-10T00:10:59.465Z" },
{ url = "https://files.pythonhosted.org/packages/79/d8/3ee50929c4cd36fcfcc0f45d753337001001116c8a5b8dd18d27ea645737/coverage-7.11.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a730cd0824e8083989f304e97b3f884189efb48e2151e07f57e9e138ab104200", size = 247209, upload_time = "2025-11-10T00:11:01.432Z" },
{ url = "https://files.pythonhosted.org/packages/94/7c/3cf06e327401c293e60c962b4b8a2ceb7167c1a428a02be3adbd1d7c7e4c/coverage-7.11.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b5cd111d3ab7390be0c07ad839235d5ad54d2ca497b5f5db86896098a77180a4", size = 246936, upload_time = "2025-11-10T00:11:02.964Z" },
{ url = "https://files.pythonhosted.org/packages/99/0b/ffc03dc8f4083817900fd367110015ef4dd227b37284104a5eb5edc9c106/coverage-7.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:074e6a5cd38e06671580b4d872c1a67955d4e69639e4b04e87fc03b494c1f060", size = 247835, upload_time = "2025-11-10T00:11:04.405Z" },
{ url = "https://files.pythonhosted.org/packages/17/4d/dbe54609ee066553d0bcdcdf108b177c78dab836292bee43f96d6a5674d1/coverage-7.11.3-cp311-cp311-win32.whl", hash = "sha256:86d27d2dd7c7c5a44710565933c7dc9cd70e65ef97142e260d16d555667deef7", size = 218994, upload_time = "2025-11-10T00:11:05.966Z" },
{ url = "https://files.pythonhosted.org/packages/94/11/8e7155df53f99553ad8114054806c01a2c0b08f303ea7e38b9831652d83d/coverage-7.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:ca90ef33a152205fb6f2f0c1f3e55c50df4ef049bb0940ebba666edd4cdebc55", size = 219926, upload_time = "2025-11-10T00:11:07.936Z" },
{ url = "https://files.pythonhosted.org/packages/1f/93/bea91b6a9e35d89c89a1cd5824bc72e45151a9c2a9ca0b50d9e9a85e3ae3/coverage-7.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:56f909a40d68947ef726ce6a34eb38f0ed241ffbe55c5007c64e616663bcbafc", size = 218599, upload_time = "2025-11-10T00:11:09.578Z" },
{ url = "https://files.pythonhosted.org/packages/c2/39/af056ec7a27c487e25c7f6b6e51d2ee9821dba1863173ddf4dc2eebef4f7/coverage-7.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b771b59ac0dfb7f139f70c85b42717ef400a6790abb6475ebac1ecee8de782f", size = 216676, upload_time = "2025-11-10T00:11:11.566Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f8/21126d34b174d037b5d01bea39077725cbb9a0da94a95c5f96929c695433/coverage-7.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:603c4414125fc9ae9000f17912dcfd3d3eb677d4e360b85206539240c96ea76e", size = 217034, upload_time = "2025-11-10T00:11:13.12Z" },
{ url = "https://files.pythonhosted.org/packages/d5/3f/0fd35f35658cdd11f7686303214bd5908225838f374db47f9e457c8d6df8/coverage-7.11.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:77ffb3b7704eb7b9b3298a01fe4509cef70117a52d50bcba29cffc5f53dd326a", size = 248531, upload_time = "2025-11-10T00:11:15.023Z" },
{ url = "https://files.pythonhosted.org/packages/8f/59/0bfc5900fc15ce4fd186e092451de776bef244565c840c9c026fd50857e1/coverage-7.11.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4d4ca49f5ba432b0755ebb0fc3a56be944a19a16bb33802264bbc7311622c0d1", size = 251290, upload_time = "2025-11-10T00:11:16.628Z" },
{ url = "https://files.pythonhosted.org/packages/71/88/d5c184001fa2ac82edf1b8f2cd91894d2230d7c309e937c54c796176e35b/coverage-7.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05fd3fb6edff0c98874d752013588836f458261e5eba587afe4c547bba544afd", size = 252375, upload_time = "2025-11-10T00:11:18.249Z" },
{ url = "https://files.pythonhosted.org/packages/5c/29/f60af9f823bf62c7a00ce1ac88441b9a9a467e499493e5cc65028c8b8dd2/coverage-7.11.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e920567f8c3a3ce68ae5a42cf7c2dc4bb6cc389f18bff2235dd8c03fa405de5", size = 248946, upload_time = "2025-11-10T00:11:20.202Z" },
{ url = "https://files.pythonhosted.org/packages/67/16/4662790f3b1e03fce5280cad93fd18711c35980beb3c6f28dca41b5230c6/coverage-7.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bec8c7160688bd5a34e65c82984b25409563134d63285d8943d0599efbc448e", size = 250310, upload_time = "2025-11-10T00:11:21.689Z" },
{ url = "https://files.pythonhosted.org/packages/8f/75/dd6c2e28308a83e5fc1ee602f8204bd3aa5af685c104cb54499230cf56db/coverage-7.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:adb9b7b42c802bd8cb3927de8c1c26368ce50c8fdaa83a9d8551384d77537044", size = 248461, upload_time = "2025-11-10T00:11:23.384Z" },
{ url = "https://files.pythonhosted.org/packages/16/fe/b71af12be9f59dc9eb060688fa19a95bf3223f56c5af1e9861dfa2275d2c/coverage-7.11.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c8f563b245b4ddb591e99f28e3cd140b85f114b38b7f95b2e42542f0603eb7d7", size = 248039, upload_time = "2025-11-10T00:11:25.07Z" },
{ url = "https://files.pythonhosted.org/packages/11/b8/023b2003a2cd96bdf607afe03d9b96c763cab6d76e024abe4473707c4eb8/coverage-7.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e2a96fdc7643c9517a317553aca13b5cae9bad9a5f32f4654ce247ae4d321405", size = 249903, upload_time = "2025-11-10T00:11:26.992Z" },
{ url = "https://files.pythonhosted.org/packages/d6/ee/5f1076311aa67b1fa4687a724cc044346380e90ce7d94fec09fd384aa5fd/coverage-7.11.3-cp312-cp312-win32.whl", hash = "sha256:e8feeb5e8705835f0622af0fe7ff8d5cb388948454647086494d6c41ec142c2e", size = 219201, upload_time = "2025-11-10T00:11:28.619Z" },
{ url = "https://files.pythonhosted.org/packages/4f/24/d21688f48fe9fcc778956680fd5aaf69f4e23b245b7c7a4755cbd421d25b/coverage-7.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:abb903ffe46bd319d99979cdba350ae7016759bb69f47882242f7b93f3356055", size = 220012, upload_time = "2025-11-10T00:11:30.234Z" },
{ url = "https://files.pythonhosted.org/packages/4f/9e/d5eb508065f291456378aa9b16698b8417d87cb084c2b597f3beb00a8084/coverage-7.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:1451464fd855d9bd000c19b71bb7dafea9ab815741fb0bd9e813d9b671462d6f", size = 218652, upload_time = "2025-11-10T00:11:32.165Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f6/d8572c058211c7d976f24dab71999a565501fb5b3cdcb59cf782f19c4acb/coverage-7.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84b892e968164b7a0498ddc5746cdf4e985700b902128421bb5cec1080a6ee36", size = 216694, upload_time = "2025-11-10T00:11:34.296Z" },
{ url = "https://files.pythonhosted.org/packages/4a/f6/b6f9764d90c0ce1bce8d995649fa307fff21f4727b8d950fa2843b7b0de5/coverage-7.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f761dbcf45e9416ec4698e1a7649248005f0064ce3523a47402d1bff4af2779e", size = 217065, upload_time = "2025-11-10T00:11:36.281Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8d/a12cb424063019fd077b5be474258a0ed8369b92b6d0058e673f0a945982/coverage-7.11.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1410bac9e98afd9623f53876fae7d8a5db9f5a0ac1c9e7c5188463cb4b3212e2", size = 248062, upload_time = "2025-11-10T00:11:37.903Z" },
{ url = "https://files.pythonhosted.org/packages/7f/9c/dab1a4e8e75ce053d14259d3d7485d68528a662e286e184685ea49e71156/coverage-7.11.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:004cdcea3457c0ea3233622cd3464c1e32ebba9b41578421097402bee6461b63", size = 250657, upload_time = "2025-11-10T00:11:39.509Z" },
{ url = "https://files.pythonhosted.org/packages/3f/89/a14f256438324f33bae36f9a1a7137729bf26b0a43f5eda60b147ec7c8c7/coverage-7.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f067ada2c333609b52835ca4d4868645d3b63ac04fb2b9a658c55bba7f667d3", size = 251900, upload_time = "2025-11-10T00:11:41.372Z" },
{ url = "https://files.pythonhosted.org/packages/04/07/75b0d476eb349f1296486b1418b44f2d8780cc8db47493de3755e5340076/coverage-7.11.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07bc7745c945a6d95676953e86ba7cebb9f11de7773951c387f4c07dc76d03f5", size = 248254, upload_time = "2025-11-10T00:11:43.27Z" },
{ url = "https://files.pythonhosted.org/packages/5a/4b/0c486581fa72873489ca092c52792d008a17954aa352809a7cbe6cf0bf07/coverage-7.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bba7e4743e37484ae17d5c3b8eb1ce78b564cb91b7ace2e2182b25f0f764cb5", size = 250041, upload_time = "2025-11-10T00:11:45.274Z" },
{ url = "https://files.pythonhosted.org/packages/af/a3/0059dafb240ae3e3291f81b8de00e9c511d3dd41d687a227dd4b529be591/coverage-7.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbffc22d80d86fbe456af9abb17f7a7766e7b2101f7edaacc3535501691563f7", size = 248004, upload_time = "2025-11-10T00:11:46.93Z" },
{ url = "https://files.pythonhosted.org/packages/83/93/967d9662b1eb8c7c46917dcc7e4c1875724ac3e73c3cb78e86d7a0ac719d/coverage-7.11.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0dba4da36730e384669e05b765a2c49f39514dd3012fcc0398dd66fba8d746d5", size = 247828, upload_time = "2025-11-10T00:11:48.563Z" },
{ url = "https://files.pythonhosted.org/packages/4c/1c/5077493c03215701e212767e470b794548d817dfc6247a4718832cc71fac/coverage-7.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae12fe90b00b71a71b69f513773310782ce01d5f58d2ceb2b7c595ab9d222094", size = 249588, upload_time = "2025-11-10T00:11:50.581Z" },
{ url = "https://files.pythonhosted.org/packages/7f/a5/77f64de461016e7da3e05d7d07975c89756fe672753e4cf74417fc9b9052/coverage-7.11.3-cp313-cp313-win32.whl", hash = "sha256:12d821de7408292530b0d241468b698bce18dd12ecaf45316149f53877885f8c", size = 219223, upload_time = "2025-11-10T00:11:52.184Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1c/ec51a3c1a59d225b44bdd3a4d463135b3159a535c2686fac965b698524f4/coverage-7.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:6bb599052a974bb6cedfa114f9778fedfad66854107cf81397ec87cb9b8fbcf2", size = 220033, upload_time = "2025-11-10T00:11:53.871Z" },
{ url = "https://files.pythonhosted.org/packages/01/ec/e0ce39746ed558564c16f2cc25fa95ce6fc9fa8bfb3b9e62855d4386b886/coverage-7.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:bb9d7efdb063903b3fdf77caec7b77c3066885068bdc0d44bc1b0c171033f944", size = 218661, upload_time = "2025-11-10T00:11:55.597Z" },
{ url = "https://files.pythonhosted.org/packages/46/cb/483f130bc56cbbad2638248915d97b185374d58b19e3cc3107359715949f/coverage-7.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fb58da65e3339b3dbe266b607bb936efb983d86b00b03eb04c4ad5b442c58428", size = 217389, upload_time = "2025-11-10T00:11:57.59Z" },
{ url = "https://files.pythonhosted.org/packages/cb/ae/81f89bae3afef75553cf10e62feb57551535d16fd5859b9ee5a2a97ddd27/coverage-7.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d16bbe566e16a71d123cd66382c1315fcd520c7573652a8074a8fe281b38c6a", size = 217742, upload_time = "2025-11-10T00:11:59.519Z" },
{ url = "https://files.pythonhosted.org/packages/db/6e/a0fb897041949888191a49c36afd5c6f5d9f5fd757e0b0cd99ec198a324b/coverage-7.11.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8258f10059b5ac837232c589a350a2df4a96406d6d5f2a09ec587cbdd539655", size = 259049, upload_time = "2025-11-10T00:12:01.592Z" },
{ url = "https://files.pythonhosted.org/packages/d9/b6/d13acc67eb402d91eb94b9bd60593411799aed09ce176ee8d8c0e39c94ca/coverage-7.11.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c5627429f7fbff4f4131cfdd6abd530734ef7761116811a707b88b7e205afd7", size = 261113, upload_time = "2025-11-10T00:12:03.639Z" },
{ url = "https://files.pythonhosted.org/packages/ea/07/a6868893c48191d60406df4356aa7f0f74e6de34ef1f03af0d49183e0fa1/coverage-7.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:465695268414e149bab754c54b0c45c8ceda73dd4a5c3ba255500da13984b16d", size = 263546, upload_time = "2025-11-10T00:12:05.485Z" },
{ url = "https://files.pythonhosted.org/packages/24/e5/28598f70b2c1098332bac47925806353b3313511d984841111e6e760c016/coverage-7.11.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ebcddfcdfb4c614233cff6e9a3967a09484114a8b2e4f2c7a62dc83676ba13f", size = 258260, upload_time = "2025-11-10T00:12:07.137Z" },
{ url = "https://files.pythonhosted.org/packages/0e/58/58e2d9e6455a4ed746a480c4b9cf96dc3cb2a6b8f3efbee5efd33ae24b06/coverage-7.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13b2066303a1c1833c654d2af0455bb009b6e1727b3883c9964bc5c2f643c1d0", size = 261121, upload_time = "2025-11-10T00:12:09.138Z" },
{ url = "https://files.pythonhosted.org/packages/17/57/38803eefb9b0409934cbc5a14e3978f0c85cb251d2b6f6a369067a7105a0/coverage-7.11.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d8750dd20362a1b80e3cf84f58013d4672f89663aee457ea59336df50fab6739", size = 258736, upload_time = "2025-11-10T00:12:11.195Z" },
{ url = "https://files.pythonhosted.org/packages/a8/f3/f94683167156e93677b3442be1d4ca70cb33718df32a2eea44a5898f04f6/coverage-7.11.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ab6212e62ea0e1006531a2234e209607f360d98d18d532c2fa8e403c1afbdd71", size = 257625, upload_time = "2025-11-10T00:12:12.843Z" },
{ url = "https://files.pythonhosted.org/packages/87/ed/42d0bf1bc6bfa7d65f52299a31daaa866b4c11000855d753857fe78260ac/coverage-7.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b17c2b5e0b9bb7702449200f93e2d04cb04b1414c41424c08aa1e5d352da76", size = 259827, upload_time = "2025-11-10T00:12:15.128Z" },
{ url = "https://files.pythonhosted.org/packages/d3/76/5682719f5d5fbedb0c624c9851ef847407cae23362deb941f185f489c54e/coverage-7.11.3-cp313-cp313t-win32.whl", hash = "sha256:426559f105f644b69290ea414e154a0d320c3ad8a2bb75e62884731f69cf8e2c", size = 219897, upload_time = "2025-11-10T00:12:17.274Z" },
{ url = "https://files.pythonhosted.org/packages/10/e0/1da511d0ac3d39e6676fa6cc5ec35320bbf1cebb9b24e9ee7548ee4e931a/coverage-7.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:90a96fcd824564eae6137ec2563bd061d49a32944858d4bdbae5c00fb10e76ac", size = 220959, upload_time = "2025-11-10T00:12:19.292Z" },
{ url = "https://files.pythonhosted.org/packages/e5/9d/e255da6a04e9ec5f7b633c54c0fdfa221a9e03550b67a9c83217de12e96c/coverage-7.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:1e33d0bebf895c7a0905fcfaff2b07ab900885fc78bba2a12291a2cfbab014cc", size = 219234, upload_time = "2025-11-10T00:12:21.251Z" },
{ url = "https://files.pythonhosted.org/packages/84/d6/634ec396e45aded1772dccf6c236e3e7c9604bc47b816e928f32ce7987d1/coverage-7.11.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fdc5255eb4815babcdf236fa1a806ccb546724c8a9b129fd1ea4a5448a0bf07c", size = 216746, upload_time = "2025-11-10T00:12:23.089Z" },
{ url = "https://files.pythonhosted.org/packages/28/76/1079547f9d46f9c7c7d0dad35b6873c98bc5aa721eeabceafabd722cd5e7/coverage-7.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fe3425dc6021f906c6325d3c415e048e7cdb955505a94f1eb774dafc779ba203", size = 217077, upload_time = "2025-11-10T00:12:24.863Z" },
{ url = "https://files.pythonhosted.org/packages/2d/71/6ad80d6ae0d7cb743b9a98df8bb88b1ff3dc54491508a4a97549c2b83400/coverage-7.11.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4ca5f876bf41b24378ee67c41d688155f0e54cdc720de8ef9ad6544005899240", size = 248122, upload_time = "2025-11-10T00:12:26.553Z" },
{ url = "https://files.pythonhosted.org/packages/20/1d/784b87270784b0b88e4beec9d028e8d58f73ae248032579c63ad2ac6f69a/coverage-7.11.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9061a3e3c92b27fd8036dafa26f25d95695b6aa2e4514ab16a254f297e664f83", size = 250638, upload_time = "2025-11-10T00:12:28.555Z" },
{ url = "https://files.pythonhosted.org/packages/f5/26/b6dd31e23e004e9de84d1a8672cd3d73e50f5dae65dbd0f03fa2cdde6100/coverage-7.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abcea3b5f0dc44e1d01c27090bc32ce6ffb7aa665f884f1890710454113ea902", size = 251972, upload_time = "2025-11-10T00:12:30.246Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ef/f9c64d76faac56b82daa036b34d4fe9ab55eb37f22062e68e9470583e688/coverage-7.11.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:68c4eb92997dbaaf839ea13527be463178ac0ddd37a7ac636b8bc11a51af2428", size = 248147, upload_time = "2025-11-10T00:12:32.195Z" },
{ url = "https://files.pythonhosted.org/packages/b6/eb/5b666f90a8f8053bd264a1ce693d2edef2368e518afe70680070fca13ecd/coverage-7.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:149eccc85d48c8f06547534068c41d69a1a35322deaa4d69ba1561e2e9127e75", size = 249995, upload_time = "2025-11-10T00:12:33.969Z" },
{ url = "https://files.pythonhosted.org/packages/eb/7b/871e991ffb5d067f8e67ffb635dabba65b231d6e0eb724a4a558f4a702a5/coverage-7.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08c0bcf932e47795c49f0406054824b9d45671362dfc4269e0bc6e4bff010704", size = 247948, upload_time = "2025-11-10T00:12:36.341Z" },
{ url = "https://files.pythonhosted.org/packages/0a/8b/ce454f0af9609431b06dbe5485fc9d1c35ddc387e32ae8e374f49005748b/coverage-7.11.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:39764c6167c82d68a2d8c97c33dba45ec0ad9172570860e12191416f4f8e6e1b", size = 247770, upload_time = "2025-11-10T00:12:38.167Z" },
{ url = "https://files.pythonhosted.org/packages/61/8f/79002cb58a61dfbd2085de7d0a46311ef2476823e7938db80284cedd2428/coverage-7.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3224c7baf34e923ffc78cb45e793925539d640d42c96646db62dbd61bbcfa131", size = 249431, upload_time = "2025-11-10T00:12:40.354Z" },
{ url = "https://files.pythonhosted.org/packages/58/cc/d06685dae97468ed22999440f2f2f5060940ab0e7952a7295f236d98cce7/coverage-7.11.3-cp314-cp314-win32.whl", hash = "sha256:c713c1c528284d636cd37723b0b4c35c11190da6f932794e145fc40f8210a14a", size = 219508, upload_time = "2025-11-10T00:12:42.231Z" },
{ url = "https://files.pythonhosted.org/packages/5f/ed/770cd07706a3598c545f62d75adf2e5bd3791bffccdcf708ec383ad42559/coverage-7.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:c381a252317f63ca0179d2c7918e83b99a4ff3101e1b24849b999a00f9cd4f86", size = 220325, upload_time = "2025-11-10T00:12:44.065Z" },
{ url = "https://files.pythonhosted.org/packages/ee/ac/6a1c507899b6fb1b9a56069954365f655956bcc648e150ce64c2b0ecbed8/coverage-7.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:3e33a968672be1394eded257ec10d4acbb9af2ae263ba05a99ff901bb863557e", size = 218899, upload_time = "2025-11-10T00:12:46.18Z" },
{ url = "https://files.pythonhosted.org/packages/9a/58/142cd838d960cd740654d094f7b0300d7b81534bb7304437d2439fb685fb/coverage-7.11.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f9c96a29c6d65bd36a91f5634fef800212dff69dacdb44345c4c9783943ab0df", size = 217471, upload_time = "2025-11-10T00:12:48.392Z" },
{ url = "https://files.pythonhosted.org/packages/bc/2c/2f44d39eb33e41ab3aba80571daad32e0f67076afcf27cb443f9e5b5a3ee/coverage-7.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ec27a7a991d229213c8070d31e3ecf44d005d96a9edc30c78eaeafaa421c001", size = 217742, upload_time = "2025-11-10T00:12:50.182Z" },
{ url = "https://files.pythonhosted.org/packages/32/76/8ebc66c3c699f4de3174a43424c34c086323cd93c4930ab0f835731c443a/coverage-7.11.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:72c8b494bd20ae1c58528b97c4a67d5cfeafcb3845c73542875ecd43924296de", size = 259120, upload_time = "2025-11-10T00:12:52.451Z" },
{ url = "https://files.pythonhosted.org/packages/19/89/78a3302b9595f331b86e4f12dfbd9252c8e93d97b8631500888f9a3a2af7/coverage-7.11.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:60ca149a446da255d56c2a7a813b51a80d9497a62250532598d249b3cdb1a926", size = 261229, upload_time = "2025-11-10T00:12:54.667Z" },
{ url = "https://files.pythonhosted.org/packages/07/59/1a9c0844dadef2a6efac07316d9781e6c5a3f3ea7e5e701411e99d619bfd/coverage-7.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb5069074db19a534de3859c43eec78e962d6d119f637c41c8e028c5ab3f59dd", size = 263642, upload_time = "2025-11-10T00:12:56.841Z" },
{ url = "https://files.pythonhosted.org/packages/37/86/66c15d190a8e82eee777793cabde730640f555db3c020a179625a2ad5320/coverage-7.11.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac5d5329c9c942bbe6295f4251b135d860ed9f86acd912d418dce186de7c19ac", size = 258193, upload_time = "2025-11-10T00:12:58.687Z" },
{ url = "https://files.pythonhosted.org/packages/c7/c7/4a4aeb25cb6f83c3ec4763e5f7cc78da1c6d4ef9e22128562204b7f39390/coverage-7.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e22539b676fafba17f0a90ac725f029a309eb6e483f364c86dcadee060429d46", size = 261107, upload_time = "2025-11-10T00:13:00.502Z" },
{ url = "https://files.pythonhosted.org/packages/ed/91/b986b5035f23cf0272446298967ecdd2c3c0105ee31f66f7e6b6948fd7f8/coverage-7.11.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2376e8a9c889016f25472c452389e98bc6e54a19570b107e27cde9d47f387b64", size = 258717, upload_time = "2025-11-10T00:13:02.747Z" },
{ url = "https://files.pythonhosted.org/packages/f0/c7/6c084997f5a04d050c513545d3344bfa17bd3b67f143f388b5757d762b0b/coverage-7.11.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4234914b8c67238a3c4af2bba648dc716aa029ca44d01f3d51536d44ac16854f", size = 257541, upload_time = "2025-11-10T00:13:04.689Z" },
{ url = "https://files.pythonhosted.org/packages/3b/c5/38e642917e406930cb67941210a366ccffa767365c8f8d9ec0f465a8b218/coverage-7.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0b4101e2b3c6c352ff1f70b3a6fcc7c17c1ab1a91ccb7a33013cb0782af9820", size = 259872, upload_time = "2025-11-10T00:13:06.559Z" },
{ url = "https://files.pythonhosted.org/packages/b7/67/5e812979d20c167f81dbf9374048e0193ebe64c59a3d93d7d947b07865fa/coverage-7.11.3-cp314-cp314t-win32.whl", hash = "sha256:305716afb19133762e8cf62745c46c4853ad6f9eeba54a593e373289e24ea237", size = 220289, upload_time = "2025-11-10T00:13:08.635Z" },
{ url = "https://files.pythonhosted.org/packages/24/3a/b72573802672b680703e0df071faadfab7dcd4d659aaaffc4626bc8bbde8/coverage-7.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9245bd392572b9f799261c4c9e7216bafc9405537d0f4ce3ad93afe081a12dc9", size = 221398, upload_time = "2025-11-10T00:13:10.734Z" },
{ url = "https://files.pythonhosted.org/packages/f8/4e/649628f28d38bad81e4e8eb3f78759d20ac173e3c456ac629123815feb40/coverage-7.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:9a1d577c20b4334e5e814c3d5fe07fa4a8c3ae42a601945e8d7940bab811d0bd", size = 219435, upload_time = "2025-11-10T00:13:12.712Z" },
{ url = "https://files.pythonhosted.org/packages/19/8f/92bdd27b067204b99f396a1414d6342122f3e2663459baf787108a6b8b84/coverage-7.11.3-py3-none-any.whl", hash = "sha256:351511ae28e2509c8d8cae5311577ea7dd511ab8e746ffc8814a0896c3d33fbe", size = 208478, upload_time = "2025-11-10T00:13:14.908Z" },
]
[package.optional-dependencies]
toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
[[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 = "n8n-workflow-comparison"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "networkx" },
{ name = "numpy" },
{ name = "pyyaml" },
{ name = "scipy" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
{ name = "ty" },
]
[package.metadata]
requires-dist = [
{ name = "networkx", specifier = ">=3.2" },
{ name = "numpy", specifier = ">=2.3.4" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "scipy", specifier = ">=1.16.3" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=9.0.1" },
{ name = "pytest-cov", specifier = ">=7.0.0" },
{ name = "ruff", specifier = ">=0.14.5" },
{ name = "ty", specifier = ">=0.0.1a26" },
]
[[package]]
name = "networkx"
version = "3.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/4f/ccdb8ad3a38e583f214547fd2f7ff1fc160c43a75af88e6aec213404b96a/networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037", size = 2471065, upload_time = "2025-05-29T11:35:07.804Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406, upload_time = "2025-05-29T11:35:04.961Z" },
]
[[package]]
name = "numpy"
version = "2.3.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/f4/098d2270d52b41f1bd7db9fc288aaa0400cb48c2a3e2af6fa365d9720947/numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a", size = 20582187, upload_time = "2025-10-15T16:18:11.77Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/e7/0e07379944aa8afb49a556a2b54587b828eb41dc9adc56fb7615b678ca53/numpy-2.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e78aecd2800b32e8347ce49316d3eaf04aed849cd5b38e0af39f829a4e59f5eb", size = 21259519, upload_time = "2025-10-15T16:15:19.012Z" },
{ url = "https://files.pythonhosted.org/packages/d0/cb/5a69293561e8819b09e34ed9e873b9a82b5f2ade23dce4c51dc507f6cfe1/numpy-2.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7fd09cc5d65bda1e79432859c40978010622112e9194e581e3415a3eccc7f43f", size = 14452796, upload_time = "2025-10-15T16:15:23.094Z" },
{ url = "https://files.pythonhosted.org/packages/e4/04/ff11611200acd602a1e5129e36cfd25bf01ad8e5cf927baf2e90236eb02e/numpy-2.3.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1b219560ae2c1de48ead517d085bc2d05b9433f8e49d0955c82e8cd37bd7bf36", size = 5381639, upload_time = "2025-10-15T16:15:25.572Z" },
{ url = "https://files.pythonhosted.org/packages/ea/77/e95c757a6fe7a48d28a009267408e8aa382630cc1ad1db7451b3bc21dbb4/numpy-2.3.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:bafa7d87d4c99752d07815ed7a2c0964f8ab311eb8168f41b910bd01d15b6032", size = 6914296, upload_time = "2025-10-15T16:15:27.079Z" },
{ url = "https://files.pythonhosted.org/packages/a3/d2/137c7b6841c942124eae921279e5c41b1c34bab0e6fc60c7348e69afd165/numpy-2.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36dc13af226aeab72b7abad501d370d606326a0029b9f435eacb3b8c94b8a8b7", size = 14591904, upload_time = "2025-10-15T16:15:29.044Z" },
{ url = "https://files.pythonhosted.org/packages/bb/32/67e3b0f07b0aba57a078c4ab777a9e8e6bc62f24fb53a2337f75f9691699/numpy-2.3.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7b2f9a18b5ff9824a6af80de4f37f4ec3c2aab05ef08f51c77a093f5b89adda", size = 16939602, upload_time = "2025-10-15T16:15:31.106Z" },
{ url = "https://files.pythonhosted.org/packages/95/22/9639c30e32c93c4cee3ccdb4b09c2d0fbff4dcd06d36b357da06146530fb/numpy-2.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9984bd645a8db6ca15d850ff996856d8762c51a2239225288f08f9050ca240a0", size = 16372661, upload_time = "2025-10-15T16:15:33.546Z" },
{ url = "https://files.pythonhosted.org/packages/12/e9/a685079529be2b0156ae0c11b13d6be647743095bb51d46589e95be88086/numpy-2.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:64c5825affc76942973a70acf438a8ab618dbd692b84cd5ec40a0a0509edc09a", size = 18884682, upload_time = "2025-10-15T16:15:36.105Z" },
{ url = "https://files.pythonhosted.org/packages/cf/85/f6f00d019b0cc741e64b4e00ce865a57b6bed945d1bbeb1ccadbc647959b/numpy-2.3.4-cp311-cp311-win32.whl", hash = "sha256:ed759bf7a70342f7817d88376eb7142fab9fef8320d6019ef87fae05a99874e1", size = 6570076, upload_time = "2025-10-15T16:15:38.225Z" },
{ url = "https://files.pythonhosted.org/packages/7d/10/f8850982021cb90e2ec31990291f9e830ce7d94eef432b15066e7cbe0bec/numpy-2.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:faba246fb30ea2a526c2e9645f61612341de1a83fb1e0c5edf4ddda5a9c10996", size = 13089358, upload_time = "2025-10-15T16:15:40.404Z" },
{ url = "https://files.pythonhosted.org/packages/d1/ad/afdd8351385edf0b3445f9e24210a9c3971ef4de8fd85155462fc4321d79/numpy-2.3.4-cp311-cp311-win_arm64.whl", hash = "sha256:4c01835e718bcebe80394fd0ac66c07cbb90147ebbdad3dcecd3f25de2ae7e2c", size = 10462292, upload_time = "2025-10-15T16:15:42.896Z" },
{ url = "https://files.pythonhosted.org/packages/96/7a/02420400b736f84317e759291b8edaeee9dc921f72b045475a9cbdb26b17/numpy-2.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef1b5a3e808bc40827b5fa2c8196151a4c5abe110e1726949d7abddfe5c7ae11", size = 20957727, upload_time = "2025-10-15T16:15:44.9Z" },
{ url = "https://files.pythonhosted.org/packages/18/90/a014805d627aa5750f6f0e878172afb6454552da929144b3c07fcae1bb13/numpy-2.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2f91f496a87235c6aaf6d3f3d89b17dba64996abadccb289f48456cff931ca9", size = 14187262, upload_time = "2025-10-15T16:15:47.761Z" },
{ url = "https://files.pythonhosted.org/packages/c7/e4/0a94b09abe89e500dc748e7515f21a13e30c5c3fe3396e6d4ac108c25fca/numpy-2.3.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f77e5b3d3da652b474cc80a14084927a5e86a5eccf54ca8ca5cbd697bf7f2667", size = 5115992, upload_time = "2025-10-15T16:15:50.144Z" },
{ url = "https://files.pythonhosted.org/packages/88/dd/db77c75b055c6157cbd4f9c92c4458daef0dd9cbe6d8d2fe7f803cb64c37/numpy-2.3.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:8ab1c5f5ee40d6e01cbe96de5863e39b215a4d24e7d007cad56c7184fdf4aeef", size = 6648672, upload_time = "2025-10-15T16:15:52.442Z" },
{ url = "https://files.pythonhosted.org/packages/e1/e6/e31b0d713719610e406c0ea3ae0d90760465b086da8783e2fd835ad59027/numpy-2.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77b84453f3adcb994ddbd0d1c5d11db2d6bda1a2b7fd5ac5bd4649d6f5dc682e", size = 14284156, upload_time = "2025-10-15T16:15:54.351Z" },
{ url = "https://files.pythonhosted.org/packages/f9/58/30a85127bfee6f108282107caf8e06a1f0cc997cb6b52cdee699276fcce4/numpy-2.3.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4121c5beb58a7f9e6dfdee612cb24f4df5cd4db6e8261d7f4d7450a997a65d6a", size = 16641271, upload_time = "2025-10-15T16:15:56.67Z" },
{ url = "https://files.pythonhosted.org/packages/06/f2/2e06a0f2adf23e3ae29283ad96959267938d0efd20a2e25353b70065bfec/numpy-2.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:65611ecbb00ac9846efe04db15cbe6186f562f6bb7e5e05f077e53a599225d16", size = 16059531, upload_time = "2025-10-15T16:15:59.412Z" },
{ url = "https://files.pythonhosted.org/packages/b0/e7/b106253c7c0d5dc352b9c8fab91afd76a93950998167fa3e5afe4ef3a18f/numpy-2.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dabc42f9c6577bcc13001b8810d300fe814b4cfbe8a92c873f269484594f9786", size = 18578983, upload_time = "2025-10-15T16:16:01.804Z" },
{ url = "https://files.pythonhosted.org/packages/73/e3/04ecc41e71462276ee867ccbef26a4448638eadecf1bc56772c9ed6d0255/numpy-2.3.4-cp312-cp312-win32.whl", hash = "sha256:a49d797192a8d950ca59ee2d0337a4d804f713bb5c3c50e8db26d49666e351dc", size = 6291380, upload_time = "2025-10-15T16:16:03.938Z" },
{ url = "https://files.pythonhosted.org/packages/3d/a8/566578b10d8d0e9955b1b6cd5db4e9d4592dd0026a941ff7994cedda030a/numpy-2.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:985f1e46358f06c2a09921e8921e2c98168ed4ae12ccd6e5e87a4f1857923f32", size = 12787999, upload_time = "2025-10-15T16:16:05.801Z" },
{ url = "https://files.pythonhosted.org/packages/58/22/9c903a957d0a8071b607f5b1bff0761d6e608b9a965945411f867d515db1/numpy-2.3.4-cp312-cp312-win_arm64.whl", hash = "sha256:4635239814149e06e2cb9db3dd584b2fa64316c96f10656983b8026a82e6e4db", size = 10197412, upload_time = "2025-10-15T16:16:07.854Z" },
{ url = "https://files.pythonhosted.org/packages/57/7e/b72610cc91edf138bc588df5150957a4937221ca6058b825b4725c27be62/numpy-2.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c090d4860032b857d94144d1a9976b8e36709e40386db289aaf6672de2a81966", size = 20950335, upload_time = "2025-10-15T16:16:10.304Z" },
{ url = "https://files.pythonhosted.org/packages/3e/46/bdd3370dcea2f95ef14af79dbf81e6927102ddf1cc54adc0024d61252fd9/numpy-2.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a13fc473b6db0be619e45f11f9e81260f7302f8d180c49a22b6e6120022596b3", size = 14179878, upload_time = "2025-10-15T16:16:12.595Z" },
{ url = "https://files.pythonhosted.org/packages/ac/01/5a67cb785bda60f45415d09c2bc245433f1c68dd82eef9c9002c508b5a65/numpy-2.3.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:3634093d0b428e6c32c3a69b78e554f0cd20ee420dcad5a9f3b2a63762ce4197", size = 5108673, upload_time = "2025-10-15T16:16:14.877Z" },
{ url = "https://files.pythonhosted.org/packages/c2/cd/8428e23a9fcebd33988f4cb61208fda832800ca03781f471f3727a820704/numpy-2.3.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:043885b4f7e6e232d7df4f51ffdef8c36320ee9d5f227b380ea636722c7ed12e", size = 6641438, upload_time = "2025-10-15T16:16:16.805Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d1/913fe563820f3c6b079f992458f7331278dcd7ba8427e8e745af37ddb44f/numpy-2.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ee6a571d1e4f0ea6d5f22d6e5fbd6ed1dc2b18542848e1e7301bd190500c9d7", size = 14281290, upload_time = "2025-10-15T16:16:18.764Z" },
{ url = "https://files.pythonhosted.org/packages/9e/7e/7d306ff7cb143e6d975cfa7eb98a93e73495c4deabb7d1b5ecf09ea0fd69/numpy-2.3.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc8a63918b04b8571789688b2780ab2b4a33ab44bfe8ccea36d3eba51228c953", size = 16636543, upload_time = "2025-10-15T16:16:21.072Z" },
{ url = "https://files.pythonhosted.org/packages/47/6a/8cfc486237e56ccfb0db234945552a557ca266f022d281a2f577b98e955c/numpy-2.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40cc556d5abbc54aabe2b1ae287042d7bdb80c08edede19f0c0afb36ae586f37", size = 16056117, upload_time = "2025-10-15T16:16:23.369Z" },
{ url = "https://files.pythonhosted.org/packages/b1/0e/42cb5e69ea901e06ce24bfcc4b5664a56f950a70efdcf221f30d9615f3f3/numpy-2.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ecb63014bb7f4ce653f8be7f1df8cbc6093a5a2811211770f6606cc92b5a78fd", size = 18577788, upload_time = "2025-10-15T16:16:27.496Z" },
{ url = "https://files.pythonhosted.org/packages/86/92/41c3d5157d3177559ef0a35da50f0cda7fa071f4ba2306dd36818591a5bc/numpy-2.3.4-cp313-cp313-win32.whl", hash = "sha256:e8370eb6925bb8c1c4264fec52b0384b44f675f191df91cbe0140ec9f0955646", size = 6282620, upload_time = "2025-10-15T16:16:29.811Z" },
{ url = "https://files.pythonhosted.org/packages/09/97/fd421e8bc50766665ad35536c2bb4ef916533ba1fdd053a62d96cc7c8b95/numpy-2.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:56209416e81a7893036eea03abcb91c130643eb14233b2515c90dcac963fe99d", size = 12784672, upload_time = "2025-10-15T16:16:31.589Z" },
{ url = "https://files.pythonhosted.org/packages/ad/df/5474fb2f74970ca8eb978093969b125a84cc3d30e47f82191f981f13a8a0/numpy-2.3.4-cp313-cp313-win_arm64.whl", hash = "sha256:a700a4031bc0fd6936e78a752eefb79092cecad2599ea9c8039c548bc097f9bc", size = 10196702, upload_time = "2025-10-15T16:16:33.902Z" },
{ url = "https://files.pythonhosted.org/packages/11/83/66ac031464ec1767ea3ed48ce40f615eb441072945e98693bec0bcd056cc/numpy-2.3.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:86966db35c4040fdca64f0816a1c1dd8dbd027d90fca5a57e00e1ca4cd41b879", size = 21049003, upload_time = "2025-10-15T16:16:36.101Z" },
{ url = "https://files.pythonhosted.org/packages/5f/99/5b14e0e686e61371659a1d5bebd04596b1d72227ce36eed121bb0aeab798/numpy-2.3.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:838f045478638b26c375ee96ea89464d38428c69170360b23a1a50fa4baa3562", size = 14302980, upload_time = "2025-10-15T16:16:39.124Z" },
{ url = "https://files.pythonhosted.org/packages/2c/44/e9486649cd087d9fc6920e3fc3ac2aba10838d10804b1e179fb7cbc4e634/numpy-2.3.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d7315ed1dab0286adca467377c8381cd748f3dc92235f22a7dfc42745644a96a", size = 5231472, upload_time = "2025-10-15T16:16:41.168Z" },
{ url = "https://files.pythonhosted.org/packages/3e/51/902b24fa8887e5fe2063fd61b1895a476d0bbf46811ab0c7fdf4bd127345/numpy-2.3.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:84f01a4d18b2cc4ade1814a08e5f3c907b079c847051d720fad15ce37aa930b6", size = 6739342, upload_time = "2025-10-15T16:16:43.777Z" },
{ url = "https://files.pythonhosted.org/packages/34/f1/4de9586d05b1962acdcdb1dc4af6646361a643f8c864cef7c852bf509740/numpy-2.3.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:817e719a868f0dacde4abdfc5c1910b301877970195db9ab6a5e2c4bd5b121f7", size = 14354338, upload_time = "2025-10-15T16:16:46.081Z" },
{ url = "https://files.pythonhosted.org/packages/1f/06/1c16103b425de7969d5a76bdf5ada0804b476fed05d5f9e17b777f1cbefd/numpy-2.3.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85e071da78d92a214212cacea81c6da557cab307f2c34b5f85b628e94803f9c0", size = 16702392, upload_time = "2025-10-15T16:16:48.455Z" },
{ url = "https://files.pythonhosted.org/packages/34/b2/65f4dc1b89b5322093572b6e55161bb42e3e0487067af73627f795cc9d47/numpy-2.3.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2ec646892819370cf3558f518797f16597b4e4669894a2ba712caccc9da53f1f", size = 16134998, upload_time = "2025-10-15T16:16:51.114Z" },
{ url = "https://files.pythonhosted.org/packages/d4/11/94ec578896cdb973aaf56425d6c7f2aff4186a5c00fac15ff2ec46998b46/numpy-2.3.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:035796aaaddfe2f9664b9a9372f089cfc88bd795a67bd1bfe15e6e770934cf64", size = 18651574, upload_time = "2025-10-15T16:16:53.429Z" },
{ url = "https://files.pythonhosted.org/packages/62/b7/7efa763ab33dbccf56dade36938a77345ce8e8192d6b39e470ca25ff3cd0/numpy-2.3.4-cp313-cp313t-win32.whl", hash = "sha256:fea80f4f4cf83b54c3a051f2f727870ee51e22f0248d3114b8e755d160b38cfb", size = 6413135, upload_time = "2025-10-15T16:16:55.992Z" },
{ url = "https://files.pythonhosted.org/packages/43/70/aba4c38e8400abcc2f345e13d972fb36c26409b3e644366db7649015f291/numpy-2.3.4-cp313-cp313t-win_amd64.whl", hash = "sha256:15eea9f306b98e0be91eb344a94c0e630689ef302e10c2ce5f7e11905c704f9c", size = 12928582, upload_time = "2025-10-15T16:16:57.943Z" },
{ url = "https://files.pythonhosted.org/packages/67/63/871fad5f0073fc00fbbdd7232962ea1ac40eeaae2bba66c76214f7954236/numpy-2.3.4-cp313-cp313t-win_arm64.whl", hash = "sha256:b6c231c9c2fadbae4011ca5e7e83e12dc4a5072f1a1d85a0a7b3ed754d145a40", size = 10266691, upload_time = "2025-10-15T16:17:00.048Z" },
{ url = "https://files.pythonhosted.org/packages/72/71/ae6170143c115732470ae3a2d01512870dd16e0953f8a6dc89525696069b/numpy-2.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81c3e6d8c97295a7360d367f9f8553973651b76907988bb6066376bc2252f24e", size = 20955580, upload_time = "2025-10-15T16:17:02.509Z" },
{ url = "https://files.pythonhosted.org/packages/af/39/4be9222ffd6ca8a30eda033d5f753276a9c3426c397bb137d8e19dedd200/numpy-2.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7c26b0b2bf58009ed1f38a641f3db4be8d960a417ca96d14e5b06df1506d41ff", size = 14188056, upload_time = "2025-10-15T16:17:04.873Z" },
{ url = "https://files.pythonhosted.org/packages/6c/3d/d85f6700d0a4aa4f9491030e1021c2b2b7421b2b38d01acd16734a2bfdc7/numpy-2.3.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:62b2198c438058a20b6704351b35a1d7db881812d8512d67a69c9de1f18ca05f", size = 5116555, upload_time = "2025-10-15T16:17:07.499Z" },
{ url = "https://files.pythonhosted.org/packages/bf/04/82c1467d86f47eee8a19a464c92f90a9bb68ccf14a54c5224d7031241ffb/numpy-2.3.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:9d729d60f8d53a7361707f4b68a9663c968882dd4f09e0d58c044c8bf5faee7b", size = 6643581, upload_time = "2025-10-15T16:17:09.774Z" },
{ url = "https://files.pythonhosted.org/packages/0c/d3/c79841741b837e293f48bd7db89d0ac7a4f2503b382b78a790ef1dc778a5/numpy-2.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd0c630cf256b0a7fd9d0a11c9413b42fef5101219ce6ed5a09624f5a65392c7", size = 14299186, upload_time = "2025-10-15T16:17:11.937Z" },
{ url = "https://files.pythonhosted.org/packages/e8/7e/4a14a769741fbf237eec5a12a2cbc7a4c4e061852b6533bcb9e9a796c908/numpy-2.3.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5e081bc082825f8b139f9e9fe42942cb4054524598aaeb177ff476cc76d09d2", size = 16638601, upload_time = "2025-10-15T16:17:14.391Z" },
{ url = "https://files.pythonhosted.org/packages/93/87/1c1de269f002ff0a41173fe01dcc925f4ecff59264cd8f96cf3b60d12c9b/numpy-2.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15fb27364ed84114438fff8aaf998c9e19adbeba08c0b75409f8c452a8692c52", size = 16074219, upload_time = "2025-10-15T16:17:17.058Z" },
{ url = "https://files.pythonhosted.org/packages/cd/28/18f72ee77408e40a76d691001ae599e712ca2a47ddd2c4f695b16c65f077/numpy-2.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:85d9fb2d8cd998c84d13a79a09cc0c1091648e848e4e6249b0ccd7f6b487fa26", size = 18576702, upload_time = "2025-10-15T16:17:19.379Z" },
{ url = "https://files.pythonhosted.org/packages/c3/76/95650169b465ececa8cf4b2e8f6df255d4bf662775e797ade2025cc51ae6/numpy-2.3.4-cp314-cp314-win32.whl", hash = "sha256:e73d63fd04e3a9d6bc187f5455d81abfad05660b212c8804bf3b407e984cd2bc", size = 6337136, upload_time = "2025-10-15T16:17:22.886Z" },
{ url = "https://files.pythonhosted.org/packages/dc/89/a231a5c43ede5d6f77ba4a91e915a87dea4aeea76560ba4d2bf185c683f0/numpy-2.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:3da3491cee49cf16157e70f607c03a217ea6647b1cea4819c4f48e53d49139b9", size = 12920542, upload_time = "2025-10-15T16:17:24.783Z" },
{ url = "https://files.pythonhosted.org/packages/0d/0c/ae9434a888f717c5ed2ff2393b3f344f0ff6f1c793519fa0c540461dc530/numpy-2.3.4-cp314-cp314-win_arm64.whl", hash = "sha256:6d9cd732068e8288dbe2717177320723ccec4fb064123f0caf9bbd90ab5be868", size = 10480213, upload_time = "2025-10-15T16:17:26.935Z" },
{ url = "https://files.pythonhosted.org/packages/83/4b/c4a5f0841f92536f6b9592694a5b5f68c9ab37b775ff342649eadf9055d3/numpy-2.3.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:22758999b256b595cf0b1d102b133bb61866ba5ceecf15f759623b64c020c9ec", size = 21052280, upload_time = "2025-10-15T16:17:29.638Z" },
{ url = "https://files.pythonhosted.org/packages/3e/80/90308845fc93b984d2cc96d83e2324ce8ad1fd6efea81b324cba4b673854/numpy-2.3.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9cb177bc55b010b19798dc5497d540dea67fd13a8d9e882b2dae71de0cf09eb3", size = 14302930, upload_time = "2025-10-15T16:17:32.384Z" },
{ url = "https://files.pythonhosted.org/packages/3d/4e/07439f22f2a3b247cec4d63a713faae55e1141a36e77fb212881f7cda3fb/numpy-2.3.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0f2bcc76f1e05e5ab58893407c63d90b2029908fa41f9f1cc51eecce936c3365", size = 5231504, upload_time = "2025-10-15T16:17:34.515Z" },
{ url = "https://files.pythonhosted.org/packages/ab/de/1e11f2547e2fe3d00482b19721855348b94ada8359aef5d40dd57bfae9df/numpy-2.3.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8dc20bde86802df2ed8397a08d793da0ad7a5fd4ea3ac85d757bf5dd4ad7c252", size = 6739405, upload_time = "2025-10-15T16:17:36.128Z" },
{ url = "https://files.pythonhosted.org/packages/3b/40/8cd57393a26cebe2e923005db5134a946c62fa56a1087dc7c478f3e30837/numpy-2.3.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e199c087e2aa71c8f9ce1cb7a8e10677dc12457e7cc1be4798632da37c3e86e", size = 14354866, upload_time = "2025-10-15T16:17:38.884Z" },
{ url = "https://files.pythonhosted.org/packages/93/39/5b3510f023f96874ee6fea2e40dfa99313a00bf3ab779f3c92978f34aace/numpy-2.3.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85597b2d25ddf655495e2363fe044b0ae999b75bc4d630dc0d886484b03a5eb0", size = 16703296, upload_time = "2025-10-15T16:17:41.564Z" },
{ url = "https://files.pythonhosted.org/packages/41/0d/19bb163617c8045209c1996c4e427bccbc4bbff1e2c711f39203c8ddbb4a/numpy-2.3.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04a69abe45b49c5955923cf2c407843d1c85013b424ae8a560bba16c92fe44a0", size = 16136046, upload_time = "2025-10-15T16:17:43.901Z" },
{ url = "https://files.pythonhosted.org/packages/e2/c1/6dba12fdf68b02a21ac411c9df19afa66bed2540f467150ca64d246b463d/numpy-2.3.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e1708fac43ef8b419c975926ce1eaf793b0c13b7356cfab6ab0dc34c0a02ac0f", size = 18652691, upload_time = "2025-10-15T16:17:46.247Z" },
{ url = "https://files.pythonhosted.org/packages/f8/73/f85056701dbbbb910c51d846c58d29fd46b30eecd2b6ba760fc8b8a1641b/numpy-2.3.4-cp314-cp314t-win32.whl", hash = "sha256:863e3b5f4d9915aaf1b8ec79ae560ad21f0b8d5e3adc31e73126491bb86dee1d", size = 6485782, upload_time = "2025-10-15T16:17:48.872Z" },
{ url = "https://files.pythonhosted.org/packages/17/90/28fa6f9865181cb817c2471ee65678afa8a7e2a1fb16141473d5fa6bacc3/numpy-2.3.4-cp314-cp314t-win_amd64.whl", hash = "sha256:962064de37b9aef801d33bc579690f8bfe6c5e70e29b61783f60bcba838a14d6", size = 13113301, upload_time = "2025-10-15T16:17:50.938Z" },
{ url = "https://files.pythonhosted.org/packages/54/23/08c002201a8e7e1f9afba93b97deceb813252d9cfd0d3351caed123dcf97/numpy-2.3.4-cp314-cp314t-win_arm64.whl", hash = "sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29", size = 10547532, upload_time = "2025-10-15T16:17:53.48Z" },
{ url = "https://files.pythonhosted.org/packages/b1/b6/64898f51a86ec88ca1257a59c1d7fd077b60082a119affefcdf1dd0df8ca/numpy-2.3.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6e274603039f924c0fe5cb73438fa9246699c78a6df1bd3decef9ae592ae1c05", size = 21131552, upload_time = "2025-10-15T16:17:55.845Z" },
{ url = "https://files.pythonhosted.org/packages/ce/4c/f135dc6ebe2b6a3c77f4e4838fa63d350f85c99462012306ada1bd4bc460/numpy-2.3.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d149aee5c72176d9ddbc6803aef9c0f6d2ceeea7626574fc68518da5476fa346", size = 14377796, upload_time = "2025-10-15T16:17:58.308Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a4/f33f9c23fcc13dd8412fc8614559b5b797e0aba9d8e01dfa8bae10c84004/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:6d34ed9db9e6395bb6cd33286035f73a59b058169733a9db9f85e650b88df37e", size = 5306904, upload_time = "2025-10-15T16:18:00.596Z" },
{ url = "https://files.pythonhosted.org/packages/28/af/c44097f25f834360f9fb960fa082863e0bad14a42f36527b2a121abdec56/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:fdebe771ca06bb8d6abce84e51dca9f7921fe6ad34a0c914541b063e9a68928b", size = 6819682, upload_time = "2025-10-15T16:18:02.32Z" },
{ url = "https://files.pythonhosted.org/packages/c5/8c/cd283b54c3c2b77e188f63e23039844f56b23bba1712318288c13fe86baf/numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e92defe6c08211eb77902253b14fe5b480ebc5112bc741fd5e9cd0608f847", size = 14422300, upload_time = "2025-10-15T16:18:04.271Z" },
{ url = "https://files.pythonhosted.org/packages/b0/f0/8404db5098d92446b3e3695cf41c6f0ecb703d701cb0b7566ee2177f2eee/numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13b9062e4f5c7ee5c7e5be96f29ba71bc5a37fed3d1d77c37390ae00724d296d", size = 16760806, upload_time = "2025-10-15T16:18:06.668Z" },
{ url = "https://files.pythonhosted.org/packages/95/8e/2844c3959ce9a63acc7c8e50881133d86666f0420bcde695e115ced0920f/numpy-2.3.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81b3a59793523e552c4a96109dde028aa4448ae06ccac5a76ff6532a85558a7f", size = 12973130, upload_time = "2025-10-15T16:18:09.397Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload_time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload_time = "2025-04-19T11:48:57.875Z" },
]
[[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 = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload_time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload_time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload_time = "2025-11-12T13:05:09.333Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload_time = "2025-11-12T13:05:07.379Z" },
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage", extra = ["toml"] },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload_time = "2025-09-09T10:57:02.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload_time = "2025-09-09T10:57:00.695Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload_time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload_time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload_time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload_time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload_time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload_time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload_time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload_time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload_time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload_time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload_time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload_time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload_time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload_time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload_time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload_time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload_time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload_time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload_time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload_time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload_time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload_time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload_time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload_time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload_time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload_time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload_time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload_time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload_time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload_time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload_time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload_time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload_time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload_time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload_time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload_time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload_time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload_time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload_time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload_time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload_time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload_time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload_time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload_time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload_time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload_time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload_time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload_time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "ruff"
version = "0.14.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload_time = "2025-11-13T19:58:51.155Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload_time = "2025-11-13T19:57:54.894Z" },
{ url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload_time = "2025-11-13T19:57:59.181Z" },
{ url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload_time = "2025-11-13T19:58:02.056Z" },
{ url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload_time = "2025-11-13T19:58:05.172Z" },
{ url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload_time = "2025-11-13T19:58:08.345Z" },
{ url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload_time = "2025-11-13T19:58:11.419Z" },
{ url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload_time = "2025-11-13T19:58:14.551Z" },
{ url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload_time = "2025-11-13T19:58:17.518Z" },
{ url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload_time = "2025-11-13T19:58:20.533Z" },
{ url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload_time = "2025-11-13T19:58:23.345Z" },
{ url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload_time = "2025-11-13T19:58:26.537Z" },
{ url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload_time = "2025-11-13T19:58:29.822Z" },
{ url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload_time = "2025-11-13T19:58:32.923Z" },
{ url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload_time = "2025-11-13T19:58:35.952Z" },
{ url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload_time = "2025-11-13T19:58:39.002Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload_time = "2025-11-13T19:58:42.779Z" },
{ url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload_time = "2025-11-13T19:58:45.691Z" },
{ url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload_time = "2025-11-13T19:58:48.434Z" },
]
[[package]]
name = "scipy"
version = "1.16.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0a/ca/d8ace4f98322d01abcd52d381134344bf7b431eba7ed8b42bdea5a3c2ac9/scipy-1.16.3.tar.gz", hash = "sha256:01e87659402762f43bd2fee13370553a17ada367d42e7487800bf2916535aecb", size = 30597883, upload_time = "2025-10-28T17:38:54.068Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/5f/6f37d7439de1455ce9c5a556b8d1db0979f03a796c030bafdf08d35b7bf9/scipy-1.16.3-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:40be6cf99e68b6c4321e9f8782e7d5ff8265af28ef2cd56e9c9b2638fa08ad97", size = 36630881, upload_time = "2025-10-28T17:31:47.104Z" },
{ url = "https://files.pythonhosted.org/packages/7c/89/d70e9f628749b7e4db2aa4cd89735502ff3f08f7b9b27d2e799485987cd9/scipy-1.16.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:8be1ca9170fcb6223cc7c27f4305d680ded114a1567c0bd2bfcbf947d1b17511", size = 28941012, upload_time = "2025-10-28T17:31:53.411Z" },
{ url = "https://files.pythonhosted.org/packages/a8/a8/0e7a9a6872a923505dbdf6bb93451edcac120363131c19013044a1e7cb0c/scipy-1.16.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:bea0a62734d20d67608660f69dcda23e7f90fb4ca20974ab80b6ed40df87a005", size = 20931935, upload_time = "2025-10-28T17:31:57.361Z" },
{ url = "https://files.pythonhosted.org/packages/bd/c7/020fb72bd79ad798e4dbe53938543ecb96b3a9ac3fe274b7189e23e27353/scipy-1.16.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:2a207a6ce9c24f1951241f4693ede2d393f59c07abc159b2cb2be980820e01fb", size = 23534466, upload_time = "2025-10-28T17:32:01.875Z" },
{ url = "https://files.pythonhosted.org/packages/be/a0/668c4609ce6dbf2f948e167836ccaf897f95fb63fa231c87da7558a374cd/scipy-1.16.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:532fb5ad6a87e9e9cd9c959b106b73145a03f04c7d57ea3e6f6bb60b86ab0876", size = 33593618, upload_time = "2025-10-28T17:32:06.902Z" },
{ url = "https://files.pythonhosted.org/packages/ca/6e/8942461cf2636cdae083e3eb72622a7fbbfa5cf559c7d13ab250a5dbdc01/scipy-1.16.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0151a0749efeaaab78711c78422d413c583b8cdd2011a3c1d6c794938ee9fdb2", size = 35899798, upload_time = "2025-10-28T17:32:12.665Z" },
{ url = "https://files.pythonhosted.org/packages/79/e8/d0f33590364cdbd67f28ce79368b373889faa4ee959588beddf6daef9abe/scipy-1.16.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7180967113560cca57418a7bc719e30366b47959dd845a93206fbed693c867e", size = 36226154, upload_time = "2025-10-28T17:32:17.961Z" },
{ url = "https://files.pythonhosted.org/packages/39/c1/1903de608c0c924a1749c590064e65810f8046e437aba6be365abc4f7557/scipy-1.16.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:deb3841c925eeddb6afc1e4e4a45e418d19ec7b87c5df177695224078e8ec733", size = 38878540, upload_time = "2025-10-28T17:32:23.907Z" },
{ url = "https://files.pythonhosted.org/packages/f1/d0/22ec7036ba0b0a35bccb7f25ab407382ed34af0b111475eb301c16f8a2e5/scipy-1.16.3-cp311-cp311-win_amd64.whl", hash = "sha256:53c3844d527213631e886621df5695d35e4f6a75f620dca412bcd292f6b87d78", size = 38722107, upload_time = "2025-10-28T17:32:29.921Z" },
{ url = "https://files.pythonhosted.org/packages/7b/60/8a00e5a524bb3bf8898db1650d350f50e6cffb9d7a491c561dc9826c7515/scipy-1.16.3-cp311-cp311-win_arm64.whl", hash = "sha256:9452781bd879b14b6f055b26643703551320aa8d79ae064a71df55c00286a184", size = 25506272, upload_time = "2025-10-28T17:32:34.577Z" },
{ url = "https://files.pythonhosted.org/packages/40/41/5bf55c3f386b1643812f3a5674edf74b26184378ef0f3e7c7a09a7e2ca7f/scipy-1.16.3-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:81fc5827606858cf71446a5e98715ba0e11f0dbc83d71c7409d05486592a45d6", size = 36659043, upload_time = "2025-10-28T17:32:40.285Z" },
{ url = "https://files.pythonhosted.org/packages/1e/0f/65582071948cfc45d43e9870bf7ca5f0e0684e165d7c9ef4e50d783073eb/scipy-1.16.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:c97176013d404c7346bf57874eaac5187d969293bf40497140b0a2b2b7482e07", size = 28898986, upload_time = "2025-10-28T17:32:45.325Z" },
{ url = "https://files.pythonhosted.org/packages/96/5e/36bf3f0ac298187d1ceadde9051177d6a4fe4d507e8f59067dc9dd39e650/scipy-1.16.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2b71d93c8a9936046866acebc915e2af2e292b883ed6e2cbe5c34beb094b82d9", size = 20889814, upload_time = "2025-10-28T17:32:49.277Z" },
{ url = "https://files.pythonhosted.org/packages/80/35/178d9d0c35394d5d5211bbff7ac4f2986c5488b59506fef9e1de13ea28d3/scipy-1.16.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3d4a07a8e785d80289dfe66b7c27d8634a773020742ec7187b85ccc4b0e7b686", size = 23565795, upload_time = "2025-10-28T17:32:53.337Z" },
{ url = "https://files.pythonhosted.org/packages/fa/46/d1146ff536d034d02f83c8afc3c4bab2eddb634624d6529a8512f3afc9da/scipy-1.16.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0553371015692a898e1aa858fed67a3576c34edefa6b7ebdb4e9dde49ce5c203", size = 33349476, upload_time = "2025-10-28T17:32:58.353Z" },
{ url = "https://files.pythonhosted.org/packages/79/2e/415119c9ab3e62249e18c2b082c07aff907a273741b3f8160414b0e9193c/scipy-1.16.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72d1717fd3b5e6ec747327ce9bda32d5463f472c9dce9f54499e81fbd50245a1", size = 35676692, upload_time = "2025-10-28T17:33:03.88Z" },
{ url = "https://files.pythonhosted.org/packages/27/82/df26e44da78bf8d2aeaf7566082260cfa15955a5a6e96e6a29935b64132f/scipy-1.16.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1fb2472e72e24d1530debe6ae078db70fb1605350c88a3d14bc401d6306dbffe", size = 36019345, upload_time = "2025-10-28T17:33:09.773Z" },
{ url = "https://files.pythonhosted.org/packages/82/31/006cbb4b648ba379a95c87262c2855cd0d09453e500937f78b30f02fa1cd/scipy-1.16.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5192722cffe15f9329a3948c4b1db789fbb1f05c97899187dcf009b283aea70", size = 38678975, upload_time = "2025-10-28T17:33:15.809Z" },
{ url = "https://files.pythonhosted.org/packages/c2/7f/acbd28c97e990b421af7d6d6cd416358c9c293fc958b8529e0bd5d2a2a19/scipy-1.16.3-cp312-cp312-win_amd64.whl", hash = "sha256:56edc65510d1331dae01ef9b658d428e33ed48b4f77b1d51caf479a0253f96dc", size = 38555926, upload_time = "2025-10-28T17:33:21.388Z" },
{ url = "https://files.pythonhosted.org/packages/ce/69/c5c7807fd007dad4f48e0a5f2153038dc96e8725d3345b9ee31b2b7bed46/scipy-1.16.3-cp312-cp312-win_arm64.whl", hash = "sha256:a8a26c78ef223d3e30920ef759e25625a0ecdd0d60e5a8818b7513c3e5384cf2", size = 25463014, upload_time = "2025-10-28T17:33:25.975Z" },
{ url = "https://files.pythonhosted.org/packages/72/f1/57e8327ab1508272029e27eeef34f2302ffc156b69e7e233e906c2a5c379/scipy-1.16.3-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:d2ec56337675e61b312179a1ad124f5f570c00f920cc75e1000025451b88241c", size = 36617856, upload_time = "2025-10-28T17:33:31.375Z" },
{ url = "https://files.pythonhosted.org/packages/44/13/7e63cfba8a7452eb756306aa2fd9b37a29a323b672b964b4fdeded9a3f21/scipy-1.16.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:16b8bc35a4cc24db80a0ec836a9286d0e31b2503cb2fd7ff7fb0e0374a97081d", size = 28874306, upload_time = "2025-10-28T17:33:36.516Z" },
{ url = "https://files.pythonhosted.org/packages/15/65/3a9400efd0228a176e6ec3454b1fa998fbbb5a8defa1672c3f65706987db/scipy-1.16.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:5803c5fadd29de0cf27fa08ccbfe7a9e5d741bf63e4ab1085437266f12460ff9", size = 20865371, upload_time = "2025-10-28T17:33:42.094Z" },
{ url = "https://files.pythonhosted.org/packages/33/d7/eda09adf009a9fb81827194d4dd02d2e4bc752cef16737cc4ef065234031/scipy-1.16.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:b81c27fc41954319a943d43b20e07c40bdcd3ff7cf013f4fb86286faefe546c4", size = 23524877, upload_time = "2025-10-28T17:33:48.483Z" },
{ url = "https://files.pythonhosted.org/packages/7d/6b/3f911e1ebc364cb81320223a3422aab7d26c9c7973109a9cd0f27c64c6c0/scipy-1.16.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0c3b4dd3d9b08dbce0f3440032c52e9e2ab9f96ade2d3943313dfe51a7056959", size = 33342103, upload_time = "2025-10-28T17:33:56.495Z" },
{ url = "https://files.pythonhosted.org/packages/21/f6/4bfb5695d8941e5c570a04d9fcd0d36bce7511b7d78e6e75c8f9791f82d0/scipy-1.16.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7dc1360c06535ea6116a2220f760ae572db9f661aba2d88074fe30ec2aa1ff88", size = 35697297, upload_time = "2025-10-28T17:34:04.722Z" },
{ url = "https://files.pythonhosted.org/packages/04/e1/6496dadbc80d8d896ff72511ecfe2316b50313bfc3ebf07a3f580f08bd8c/scipy-1.16.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:663b8d66a8748051c3ee9c96465fb417509315b99c71550fda2591d7dd634234", size = 36021756, upload_time = "2025-10-28T17:34:13.482Z" },
{ url = "https://files.pythonhosted.org/packages/fe/bd/a8c7799e0136b987bda3e1b23d155bcb31aec68a4a472554df5f0937eef7/scipy-1.16.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eab43fae33a0c39006a88096cd7b4f4ef545ea0447d250d5ac18202d40b6611d", size = 38696566, upload_time = "2025-10-28T17:34:22.384Z" },
{ url = "https://files.pythonhosted.org/packages/cd/01/1204382461fcbfeb05b6161b594f4007e78b6eba9b375382f79153172b4d/scipy-1.16.3-cp313-cp313-win_amd64.whl", hash = "sha256:062246acacbe9f8210de8e751b16fc37458213f124bef161a5a02c7a39284304", size = 38529877, upload_time = "2025-10-28T17:35:51.076Z" },
{ url = "https://files.pythonhosted.org/packages/7f/14/9d9fbcaa1260a94f4bb5b64ba9213ceb5d03cd88841fe9fd1ffd47a45b73/scipy-1.16.3-cp313-cp313-win_arm64.whl", hash = "sha256:50a3dbf286dbc7d84f176f9a1574c705f277cb6565069f88f60db9eafdbe3ee2", size = 25455366, upload_time = "2025-10-28T17:35:59.014Z" },
{ url = "https://files.pythonhosted.org/packages/e2/a3/9ec205bd49f42d45d77f1730dbad9ccf146244c1647605cf834b3a8c4f36/scipy-1.16.3-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:fb4b29f4cf8cc5a8d628bc8d8e26d12d7278cd1f219f22698a378c3d67db5e4b", size = 37027931, upload_time = "2025-10-28T17:34:31.451Z" },
{ url = "https://files.pythonhosted.org/packages/25/06/ca9fd1f3a4589cbd825b1447e5db3a8ebb969c1eaf22c8579bd286f51b6d/scipy-1.16.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:8d09d72dc92742988b0e7750bddb8060b0c7079606c0d24a8cc8e9c9c11f9079", size = 29400081, upload_time = "2025-10-28T17:34:39.087Z" },
{ url = "https://files.pythonhosted.org/packages/6a/56/933e68210d92657d93fb0e381683bc0e53a965048d7358ff5fbf9e6a1b17/scipy-1.16.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:03192a35e661470197556de24e7cb1330d84b35b94ead65c46ad6f16f6b28f2a", size = 21391244, upload_time = "2025-10-28T17:34:45.234Z" },
{ url = "https://files.pythonhosted.org/packages/a8/7e/779845db03dc1418e215726329674b40576879b91814568757ff0014ad65/scipy-1.16.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:57d01cb6f85e34f0946b33caa66e892aae072b64b034183f3d87c4025802a119", size = 23929753, upload_time = "2025-10-28T17:34:51.793Z" },
{ url = "https://files.pythonhosted.org/packages/4c/4b/f756cf8161d5365dcdef9e5f460ab226c068211030a175d2fc7f3f41ca64/scipy-1.16.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:96491a6a54e995f00a28a3c3badfff58fd093bf26cd5fb34a2188c8c756a3a2c", size = 33496912, upload_time = "2025-10-28T17:34:59.8Z" },
{ url = "https://files.pythonhosted.org/packages/09/b5/222b1e49a58668f23839ca1542a6322bb095ab8d6590d4f71723869a6c2c/scipy-1.16.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cd13e354df9938598af2be05822c323e97132d5e6306b83a3b4ee6724c6e522e", size = 35802371, upload_time = "2025-10-28T17:35:08.173Z" },
{ url = "https://files.pythonhosted.org/packages/c1/8d/5964ef68bb31829bde27611f8c9deeac13764589fe74a75390242b64ca44/scipy-1.16.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:63d3cdacb8a824a295191a723ee5e4ea7768ca5ca5f2838532d9f2e2b3ce2135", size = 36190477, upload_time = "2025-10-28T17:35:16.7Z" },
{ url = "https://files.pythonhosted.org/packages/ab/f2/b31d75cb9b5fa4dd39a0a931ee9b33e7f6f36f23be5ef560bf72e0f92f32/scipy-1.16.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e7efa2681ea410b10dde31a52b18b0154d66f2485328830e45fdf183af5aefc6", size = 38796678, upload_time = "2025-10-28T17:35:26.354Z" },
{ url = "https://files.pythonhosted.org/packages/b4/1e/b3723d8ff64ab548c38d87055483714fefe6ee20e0189b62352b5e015bb1/scipy-1.16.3-cp313-cp313t-win_amd64.whl", hash = "sha256:2d1ae2cf0c350e7705168ff2429962a89ad90c2d49d1dd300686d8b2a5af22fc", size = 38640178, upload_time = "2025-10-28T17:35:35.304Z" },
{ url = "https://files.pythonhosted.org/packages/8e/f3/d854ff38789aca9b0cc23008d607ced9de4f7ab14fa1ca4329f86b3758ca/scipy-1.16.3-cp313-cp313t-win_arm64.whl", hash = "sha256:0c623a54f7b79dd88ef56da19bc2873afec9673a48f3b85b18e4d402bdd29a5a", size = 25803246, upload_time = "2025-10-28T17:35:42.155Z" },
{ url = "https://files.pythonhosted.org/packages/99/f6/99b10fd70f2d864c1e29a28bbcaa0c6340f9d8518396542d9ea3b4aaae15/scipy-1.16.3-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:875555ce62743e1d54f06cdf22c1e0bc47b91130ac40fe5d783b6dfa114beeb6", size = 36606469, upload_time = "2025-10-28T17:36:08.741Z" },
{ url = "https://files.pythonhosted.org/packages/4d/74/043b54f2319f48ea940dd025779fa28ee360e6b95acb7cd188fad4391c6b/scipy-1.16.3-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bb61878c18a470021fb515a843dc7a76961a8daceaaaa8bad1332f1bf4b54657", size = 28872043, upload_time = "2025-10-28T17:36:16.599Z" },
{ url = "https://files.pythonhosted.org/packages/4d/e1/24b7e50cc1c4ee6ffbcb1f27fe9f4c8b40e7911675f6d2d20955f41c6348/scipy-1.16.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2622206f5559784fa5c4b53a950c3c7c1cf3e84ca1b9c4b6c03f062f289ca26", size = 20862952, upload_time = "2025-10-28T17:36:22.966Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3a/3e8c01a4d742b730df368e063787c6808597ccb38636ed821d10b39ca51b/scipy-1.16.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7f68154688c515cdb541a31ef8eb66d8cd1050605be9dcd74199cbd22ac739bc", size = 23508512, upload_time = "2025-10-28T17:36:29.731Z" },
{ url = "https://files.pythonhosted.org/packages/1f/60/c45a12b98ad591536bfe5330cb3cfe1850d7570259303563b1721564d458/scipy-1.16.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3c820ddb80029fe9f43d61b81d8b488d3ef8ca010d15122b152db77dc94c22", size = 33413639, upload_time = "2025-10-28T17:36:37.982Z" },
{ url = "https://files.pythonhosted.org/packages/71/bc/35957d88645476307e4839712642896689df442f3e53b0fa016ecf8a3357/scipy-1.16.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d3837938ae715fc0fe3c39c0202de3a8853aff22ca66781ddc2ade7554b7e2cc", size = 35704729, upload_time = "2025-10-28T17:36:46.547Z" },
{ url = "https://files.pythonhosted.org/packages/3b/15/89105e659041b1ca11c386e9995aefacd513a78493656e57789f9d9eab61/scipy-1.16.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aadd23f98f9cb069b3bd64ddc900c4d277778242e961751f77a8cb5c4b946fb0", size = 36086251, upload_time = "2025-10-28T17:36:55.161Z" },
{ url = "https://files.pythonhosted.org/packages/1a/87/c0ea673ac9c6cc50b3da2196d860273bc7389aa69b64efa8493bdd25b093/scipy-1.16.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b7c5f1bda1354d6a19bc6af73a649f8285ca63ac6b52e64e658a5a11d4d69800", size = 38716681, upload_time = "2025-10-28T17:37:04.1Z" },
{ url = "https://files.pythonhosted.org/packages/91/06/837893227b043fb9b0d13e4bd7586982d8136cb249ffb3492930dab905b8/scipy-1.16.3-cp314-cp314-win_amd64.whl", hash = "sha256:e5d42a9472e7579e473879a1990327830493a7047506d58d73fc429b84c1d49d", size = 39358423, upload_time = "2025-10-28T17:38:20.005Z" },
{ url = "https://files.pythonhosted.org/packages/95/03/28bce0355e4d34a7c034727505a02d19548549e190bedd13a721e35380b7/scipy-1.16.3-cp314-cp314-win_arm64.whl", hash = "sha256:6020470b9d00245926f2d5bb93b119ca0340f0d564eb6fbaad843eaebf9d690f", size = 26135027, upload_time = "2025-10-28T17:38:24.966Z" },
{ url = "https://files.pythonhosted.org/packages/b2/6f/69f1e2b682efe9de8fe9f91040f0cd32f13cfccba690512ba4c582b0bc29/scipy-1.16.3-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:e1d27cbcb4602680a49d787d90664fa4974063ac9d4134813332a8c53dbe667c", size = 37028379, upload_time = "2025-10-28T17:37:14.061Z" },
{ url = "https://files.pythonhosted.org/packages/7c/2d/e826f31624a5ebbab1cd93d30fd74349914753076ed0593e1d56a98c4fb4/scipy-1.16.3-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:9b9c9c07b6d56a35777a1b4cc8966118fb16cfd8daf6743867d17d36cfad2d40", size = 29400052, upload_time = "2025-10-28T17:37:21.709Z" },
{ url = "https://files.pythonhosted.org/packages/69/27/d24feb80155f41fd1f156bf144e7e049b4e2b9dd06261a242905e3bc7a03/scipy-1.16.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:3a4c460301fb2cffb7f88528f30b3127742cff583603aa7dc964a52c463b385d", size = 21391183, upload_time = "2025-10-28T17:37:29.559Z" },
{ url = "https://files.pythonhosted.org/packages/f8/d3/1b229e433074c5738a24277eca520a2319aac7465eea7310ea6ae0e98ae2/scipy-1.16.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f667a4542cc8917af1db06366d3f78a5c8e83badd56409f94d1eac8d8d9133fa", size = 23930174, upload_time = "2025-10-28T17:37:36.306Z" },
{ url = "https://files.pythonhosted.org/packages/16/9d/d9e148b0ec680c0f042581a2be79a28a7ab66c0c4946697f9e7553ead337/scipy-1.16.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f379b54b77a597aa7ee5e697df0d66903e41b9c85a6dd7946159e356319158e8", size = 33497852, upload_time = "2025-10-28T17:37:42.228Z" },
{ url = "https://files.pythonhosted.org/packages/2f/22/4e5f7561e4f98b7bea63cf3fd7934bff1e3182e9f1626b089a679914d5c8/scipy-1.16.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4aff59800a3b7f786b70bfd6ab551001cb553244988d7d6b8299cb1ea653b353", size = 35798595, upload_time = "2025-10-28T17:37:48.102Z" },
{ url = "https://files.pythonhosted.org/packages/83/42/6644d714c179429fc7196857866f219fef25238319b650bb32dde7bf7a48/scipy-1.16.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:da7763f55885045036fabcebd80144b757d3db06ab0861415d1c3b7c69042146", size = 36186269, upload_time = "2025-10-28T17:37:53.72Z" },
{ url = "https://files.pythonhosted.org/packages/ac/70/64b4d7ca92f9cf2e6fc6aaa2eecf80bb9b6b985043a9583f32f8177ea122/scipy-1.16.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa6eea95283b2b8079b821dc11f50a17d0571c92b43e2b5b12764dc5f9b285d", size = 38802779, upload_time = "2025-10-28T17:37:59.393Z" },
{ url = "https://files.pythonhosted.org/packages/61/82/8d0e39f62764cce5ffd5284131e109f07cf8955aef9ab8ed4e3aa5e30539/scipy-1.16.3-cp314-cp314t-win_amd64.whl", hash = "sha256:d9f48cafc7ce94cf9b15c6bffdc443a81a27bf7075cf2dcd5c8b40f85d10c4e7", size = 39471128, upload_time = "2025-10-28T17:38:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/64/47/a494741db7280eae6dc033510c319e34d42dd41b7ac0c7ead39354d1a2b5/scipy-1.16.3-cp314-cp314t-win_arm64.whl", hash = "sha256:21d9d6b197227a12dcbf9633320a4e34c6b0e51c57268df255a0942983bac562", size = 26464127, upload_time = "2025-10-28T17:38:11.34Z" },
]
[[package]]
name = "tomli"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload_time = "2025-10-08T22:01:47.119Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload_time = "2025-10-08T22:01:00.137Z" },
{ url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload_time = "2025-10-08T22:01:01.63Z" },
{ url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload_time = "2025-10-08T22:01:02.543Z" },
{ url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload_time = "2025-10-08T22:01:03.836Z" },
{ url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload_time = "2025-10-08T22:01:04.834Z" },
{ url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload_time = "2025-10-08T22:01:05.84Z" },
{ url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload_time = "2025-10-08T22:01:06.896Z" },
{ url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload_time = "2025-10-08T22:01:08.107Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload_time = "2025-10-08T22:01:09.082Z" },
{ url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload_time = "2025-10-08T22:01:10.266Z" },
{ url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload_time = "2025-10-08T22:01:11.332Z" },
{ url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload_time = "2025-10-08T22:01:12.498Z" },
{ url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload_time = "2025-10-08T22:01:13.551Z" },
{ url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload_time = "2025-10-08T22:01:14.614Z" },
{ url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload_time = "2025-10-08T22:01:15.629Z" },
{ url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload_time = "2025-10-08T22:01:16.51Z" },
{ url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload_time = "2025-10-08T22:01:17.964Z" },
{ url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload_time = "2025-10-08T22:01:18.959Z" },
{ url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload_time = "2025-10-08T22:01:20.106Z" },
{ url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload_time = "2025-10-08T22:01:21.164Z" },
{ url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload_time = "2025-10-08T22:01:22.417Z" },
{ url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload_time = "2025-10-08T22:01:23.859Z" },
{ url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload_time = "2025-10-08T22:01:24.893Z" },
{ url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload_time = "2025-10-08T22:01:26.153Z" },
{ url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload_time = "2025-10-08T22:01:27.06Z" },
{ url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload_time = "2025-10-08T22:01:28.059Z" },
{ url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload_time = "2025-10-08T22:01:29.066Z" },
{ url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload_time = "2025-10-08T22:01:31.98Z" },
{ url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload_time = "2025-10-08T22:01:32.989Z" },
{ url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload_time = "2025-10-08T22:01:34.052Z" },
{ url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload_time = "2025-10-08T22:01:35.082Z" },
{ url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload_time = "2025-10-08T22:01:36.057Z" },
{ url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload_time = "2025-10-08T22:01:37.27Z" },
{ url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload_time = "2025-10-08T22:01:38.235Z" },
{ url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload_time = "2025-10-08T22:01:39.712Z" },
{ url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload_time = "2025-10-08T22:01:40.773Z" },
{ url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload_time = "2025-10-08T22:01:41.824Z" },
{ url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload_time = "2025-10-08T22:01:43.177Z" },
{ url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload_time = "2025-10-08T22:01:44.233Z" },
{ url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload_time = "2025-10-08T22:01:45.234Z" },
{ url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload_time = "2025-10-08T22:01:46.04Z" },
]
[[package]]
name = "ty"
version = "0.0.1a26"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/39/39/b4b4ecb6ca6d7e937fa56f0b92a8f48d7719af8fe55bdbf667638e9f93e2/ty-0.0.1a26.tar.gz", hash = "sha256:65143f8efeb2da1644821b710bf6b702a31ddcf60a639d5a576db08bded91db4", size = 4432154, upload_time = "2025-11-10T18:02:30.142Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/6a/661833ecacc4d994f7e30a7f1307bfd3a4a91392a6b03fb6a018723e75b8/ty-0.0.1a26-py3-none-linux_armv6l.whl", hash = "sha256:09208dca99bb548e9200136d4d42618476bfe1f4d2066511f2c8e2e4dfeced5e", size = 9173869, upload_time = "2025-11-10T18:01:46.012Z" },
{ url = "https://files.pythonhosted.org/packages/66/a8/32ea50f064342de391a7267f84349287e2f1c2eb0ad4811d6110916179d6/ty-0.0.1a26-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:91d12b66c91a1b82e698a2aa73fe043a1a9da83ff0dfd60b970500bee0963b91", size = 8973420, upload_time = "2025-11-10T18:01:49.32Z" },
{ url = "https://files.pythonhosted.org/packages/d1/f6/6659d55940cd5158a6740ae46a65be84a7ee9167738033a9b1259c36eef5/ty-0.0.1a26-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5bc6dfcea5477c81ad01d6a29ebc9bfcbdb21c34664f79c9e1b84be7aa8f289", size = 8528888, upload_time = "2025-11-10T18:01:51.511Z" },
{ url = "https://files.pythonhosted.org/packages/79/c9/4cbe7295013cc412b4f100b509aaa21982c08c59764a2efa537ead049345/ty-0.0.1a26-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40e5d15635e9918924138e8d3fb1cbf80822dfb8dc36ea8f3e72df598c0c4bea", size = 8801867, upload_time = "2025-11-10T18:01:53.888Z" },
{ url = "https://files.pythonhosted.org/packages/ed/b3/25099b219a6444c4b29f175784a275510c1cd85a23a926d687ab56915027/ty-0.0.1a26-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86dc147ed0790c7c8fd3f0d6c16c3c5135b01e99c440e89c6ca1e0e592bb6682", size = 8975519, upload_time = "2025-11-10T18:01:56.231Z" },
{ url = "https://files.pythonhosted.org/packages/73/3e/3ad570f4f592cb1d11982dd2c426c90d2aa9f3d38bf77a7e2ce8aa614302/ty-0.0.1a26-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fbe0e07c9d5e624edfc79a468f2ef191f9435581546a5bb6b92713ddc86ad4a6", size = 9331932, upload_time = "2025-11-10T18:01:58.476Z" },
{ url = "https://files.pythonhosted.org/packages/04/fa/62c72eead0302787f9cc0d613fc671107afeecdaf76ebb04db8f91bb9f7e/ty-0.0.1a26-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0dcebbfe9f24b43d98a078f4a41321ae7b08bea40f5c27d81394b3f54e9f7fb5", size = 9921353, upload_time = "2025-11-10T18:02:00.749Z" },
{ url = "https://files.pythonhosted.org/packages/6c/1f/3b329c4b60d878704e09eb9d05467f911f188e699961c044b75932893e0a/ty-0.0.1a26-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0901b75afc7738224ffc98bbc8ea03a20f167a2a83a4b23a6550115e8b3ddbc6", size = 9700800, upload_time = "2025-11-10T18:02:03.544Z" },
{ url = "https://files.pythonhosted.org/packages/92/24/13fcba20dd86a7c3f83c814279aa3eb6a29c5f1b38a3b3a4a0fd22159189/ty-0.0.1a26-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4788f34d384c132977958d76fef7f274f8d181b22e33933c4d16cff2bb5ca3b9", size = 9728289, upload_time = "2025-11-10T18:02:06.386Z" },
{ url = "https://files.pythonhosted.org/packages/40/7a/798894ff0b948425570b969be35e672693beeb6b852815b7340bc8de1575/ty-0.0.1a26-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b98851c11c560ce63cd972ed9728aa079d9cf40483f2cdcf3626a55849bfe107", size = 9279735, upload_time = "2025-11-10T18:02:09.425Z" },
{ url = "https://files.pythonhosted.org/packages/1a/54/71261cc1b8dc7d3c4ad92a83b4d1681f5cb7ea5965ebcbc53311ae8c6424/ty-0.0.1a26-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c20b4625a20059adecd86fe2c4df87cd6115fea28caee45d3bdcf8fb83d29510", size = 8767428, upload_time = "2025-11-10T18:02:11.956Z" },
{ url = "https://files.pythonhosted.org/packages/8e/07/b248b73a640badba2b301e6845699b7dd241f40a321b9b1bce684d440f70/ty-0.0.1a26-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d9909e96276f8d16382d285db92ae902174cae842aa953003ec0c06642db2f8a", size = 9009170, upload_time = "2025-11-10T18:02:14.878Z" },
{ url = "https://files.pythonhosted.org/packages/f8/35/ec8353f2bb7fd2f41bca6070b29ecb58e2de9af043e649678b8c132d5439/ty-0.0.1a26-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a76d649ceefe9baa9bbae97d217bee076fd8eeb2a961f66f1dff73cc70af4ac8", size = 9119215, upload_time = "2025-11-10T18:02:18.329Z" },
{ url = "https://files.pythonhosted.org/packages/70/48/db49fe1b7e66edf90dc285869043f99c12aacf7a99c36ee760e297bac6d5/ty-0.0.1a26-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a0ee0f6366bcf70fae114e714d45335cacc8daa936037441e02998a9110b7a29", size = 9398655, upload_time = "2025-11-10T18:02:21.031Z" },
{ url = "https://files.pythonhosted.org/packages/10/f8/d869492bdbb21ae8cf4c99b02f20812bbbf49aa187cfeb387dfaa03036a8/ty-0.0.1a26-py3-none-win32.whl", hash = "sha256:86689b90024810cac7750bf0c6e1652e4b4175a9de7b82b8b1583202aeb47287", size = 8645669, upload_time = "2025-11-10T18:02:23.23Z" },
{ url = "https://files.pythonhosted.org/packages/b4/18/8a907575d2b335afee7556cb92233ebb5efcefe17752fc9dcab21cffb23b/ty-0.0.1a26-py3-none-win_amd64.whl", hash = "sha256:829e6e6dbd7d9d370f97b2398b4804552554bdcc2d298114fed5e2ea06cbc05c", size = 9442975, upload_time = "2025-11-10T18:02:25.68Z" },
{ url = "https://files.pythonhosted.org/packages/e9/22/af92dcfdd84b78dd97ac6b7154d6a763781f04a400140444885c297cc213/ty-0.0.1a26-py3-none-win_arm64.whl", hash = "sha256:b8f431c784d4cf5b4195a3521b2eca9c15902f239b91154cb920da33f943c62b", size = 8958958, upload_time = "2025-11-10T18:02:28.071Z" },
]