From 06a4506767b83961cc496ec4e1d81b1611ad1e78 Mon Sep 17 00:00:00 2001 From: Melvin Strobl Date: Sat, 15 Aug 2026 17:23:36 +0200 Subject: [PATCH] Add the flow API: typed messages, git-backed store, REST and live events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the flow engine reachable from the API, which is what M3 needs before any of it can reach the browser. - app/flow is a package now; the prototype's watch-dir scripts and the matplotlib/networkx visualiser are gone with their dependencies. - Messages carry a serializable dtype instead of a live Python type, and a port name, so the graph can speak qualified names while node functions keep local arguments. Redis state is JSON, not pickle. - Message names are namespaced per flow ("heating.temp"); a bare name resolves to its own flow, a dotted one crosses flows. - Several nodes may provide the same message: producers are a list, so fan-in is a real edge instead of a silently dropped one. - Flows are stored as flow.json plus node sources in a git repository, one commit per save, with identical saves skipped so autosave stays quiet. - Node failures are isolated and reported per node; validate() returns cycles and unconnected inputs instead of raising deep in a run. - Credentials live in an encrypted store and are referenced as {"$secret": …}. - Engine events reach websocket clients through a bus, so values, node status and execution show up live. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016WzrvW7rjQbynnhF6pxh6i --- .gitignore | 4 +- NOTEPAD.md | 28 +- backend/app/api/deps.py | 33 +- backend/app/api/main.py | 5 +- backend/app/api/routes/flows.py | 295 +++++ backend/app/api/routes/secrets.py | 51 + backend/app/core/config.py | 9 + backend/app/flow/__init__.py | 26 + backend/app/flow/controller.py | 1047 +++++------------ backend/app/flow/dag_generator.py | 218 ---- backend/app/flow/events.py | 64 + backend/app/flow/messages.py | 112 ++ backend/app/flow/nodes.py | 771 ++++++------ backend/app/flow/nodes/alarm_test.py | 20 - backend/app/flow/nodes/delay_test.py | 25 - backend/app/flow/nodes/influx_test.py | 35 - backend/app/flow/nodes/mqtt_test_a.py | 25 - backend/app/flow/nodes/mqtt_test_b.py | 25 - backend/app/flow/nodes/notify_test.py | 30 - backend/app/flow/nodes/random_test.py | 32 - backend/app/flow/nodes/rate_test.py | 26 - backend/app/flow/nodes_example/alert_node.py | 44 - .../flow/nodes_example/comfort_calculator.py | 46 - backend/app/flow/nodes_example/http_sender.py | 35 - .../app/flow/nodes_example/http_trigger.py | 30 - .../app/flow/nodes_example/humidity_sensor.py | 29 - .../app/flow/nodes_example/influxdb_reader.py | 52 - .../app/flow/nodes_example/influxdb_writer.py | 54 - .../app/flow/nodes_example/mqtt_publisher.py | 36 - .../app/flow/nodes_example/mqtt_subscriber.py | 31 - .../flow/nodes_example/temperature_sensor.py | 37 - backend/app/flow/pipeline.py | 642 ++++------ backend/app/flow/schemas.py | 118 ++ backend/app/flow/secrets.py | 115 ++ backend/app/flow/state.py | 15 +- backend/app/flow/store.py | 154 +++ backend/app/flow/test.py | 73 -- backend/app/flow/test_controller.py | 194 --- backend/app/flow/test_http_node.py | 270 ----- backend/app/flow/test_influxdb_node.py | 447 ------- backend/app/flow/test_mqtt_node.py | 334 ------ backend/app/flow/test_synchronous_nodes.py | 512 -------- backend/app/flow/util.py | 47 - backend/app/main.py | 38 + backend/pyproject.toml | 27 +- backend/tests/api/routes/test_flows.py | 146 +++ backend/tests/conftest.py | 9 + backend/tests/flow/__init__.py | 0 backend/tests/flow/conftest.py | 9 + backend/tests/flow/test_messages.py | 55 + backend/tests/flow/test_pipeline.py | 152 +++ backend/tests/flow/test_store.py | 75 ++ backend/tests/flow/test_synchronous_nodes.py | 111 ++ uv.lock | 746 +----------- 54 files changed, 2586 insertions(+), 4978 deletions(-) create mode 100644 backend/app/api/routes/flows.py create mode 100644 backend/app/api/routes/secrets.py create mode 100644 backend/app/flow/__init__.py delete mode 100644 backend/app/flow/dag_generator.py create mode 100644 backend/app/flow/events.py create mode 100644 backend/app/flow/messages.py delete mode 100644 backend/app/flow/nodes/alarm_test.py delete mode 100644 backend/app/flow/nodes/delay_test.py delete mode 100644 backend/app/flow/nodes/influx_test.py delete mode 100644 backend/app/flow/nodes/mqtt_test_a.py delete mode 100644 backend/app/flow/nodes/mqtt_test_b.py delete mode 100644 backend/app/flow/nodes/notify_test.py delete mode 100644 backend/app/flow/nodes/random_test.py delete mode 100644 backend/app/flow/nodes/rate_test.py delete mode 100644 backend/app/flow/nodes_example/alert_node.py delete mode 100644 backend/app/flow/nodes_example/comfort_calculator.py delete mode 100644 backend/app/flow/nodes_example/http_sender.py delete mode 100644 backend/app/flow/nodes_example/http_trigger.py delete mode 100644 backend/app/flow/nodes_example/humidity_sensor.py delete mode 100644 backend/app/flow/nodes_example/influxdb_reader.py delete mode 100644 backend/app/flow/nodes_example/influxdb_writer.py delete mode 100644 backend/app/flow/nodes_example/mqtt_publisher.py delete mode 100644 backend/app/flow/nodes_example/mqtt_subscriber.py delete mode 100644 backend/app/flow/nodes_example/temperature_sensor.py create mode 100644 backend/app/flow/schemas.py create mode 100644 backend/app/flow/secrets.py create mode 100644 backend/app/flow/store.py delete mode 100644 backend/app/flow/test.py delete mode 100644 backend/app/flow/test_controller.py delete mode 100644 backend/app/flow/test_http_node.py delete mode 100644 backend/app/flow/test_influxdb_node.py delete mode 100644 backend/app/flow/test_mqtt_node.py delete mode 100644 backend/app/flow/test_synchronous_nodes.py delete mode 100644 backend/app/flow/util.py create mode 100644 backend/tests/api/routes/test_flows.py create mode 100644 backend/tests/flow/__init__.py create mode 100644 backend/tests/flow/conftest.py create mode 100644 backend/tests/flow/test_messages.py create mode 100644 backend/tests/flow/test_pipeline.py create mode 100644 backend/tests/flow/test_store.py create mode 100644 backend/tests/flow/test_synchronous_nodes.py diff --git a/.gitignore b/.gitignore index f385039..b6ce6d4 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,8 @@ secrets/ __pycache__/ *.py[cod] -# Rendered pipeline graphs (matplotlib output from backend/app/flow). -pipeline.png +# The local flow store (its own git repository) and the encrypted secrets file. +/backend/flow-data/ # Output of `make verify` (root Makefile). /frontend/screenshots/ diff --git a/NOTEPAD.md b/NOTEPAD.md index 98f6566..74ba8d7 100644 --- a/NOTEPAD.md +++ b/NOTEPAD.md @@ -8,29 +8,33 @@ Deferring because out of scope is fine, but don't mention deferring than. ## Open -- CHORE/FLOW: `backend/app/flow/` is not an importable package. There is no `__init__.py` - and siblings are imported top-level (`from util import …` in `nodes.py:6`, `from nodes - import …` in `controller.py:23-26` and `pipeline.py:17-19`), so the modules only run with - the cwd set to `app/flow/`. Convert to `from app.flow.util import …`. -- CHORE/FLOW: the flow test files live in `backend/app/flow/test*.py`, inside the package - rather than `backend/tests/`, so `coverage run -m pytest tests/` never collects them. - CHORE/INFRA: port the n3xd CI workflows (pre-commit, backend tests, Playwright shards, compose smoke) to `.gitea/workflows/`. The Gitea instance runs Gitea Actions and needs CI enabled per repository. -- FEAT/UI: `@xyflow/react` and a Monaco editor belong here once the node canvas starts. - The website already demos `@xyflow/react` on its features page. +- CHORE/FLOW: `app/flow/nodes.py` and `state.py` are excluded from strict mypy + (`[[tool.mypy.overrides]]` in `pyproject.toml`). The node classes still carry prototype + typing and redis-py types its calls as sync/async unions. Shrink the exclusion as each + integration is revisited. +- FEAT/API: per-hook shared secrets for `HttpNode` webhooks. They are mounted unauthenticated + under `/hooks/{flow}/…` because devices cannot present a JWT. +- FEAT/FLOW: state does not survive a restart — the engine runs on `MemoryState` unless + `REDIS_HOST` is set, and there is no Redis compose service yet. +- CHORE/FLOW: rotating `SECRET_KEY` makes the stored secrets unreadable, since the Fernet key + is derived from it. Needs a re-encrypt path before anyone rotates in production. +- PERF/FLOW: every save rebuilds the whole pipeline. Fine at the current flow count; rebuild + only the touched flow when it starts to show. - FEAT/UI: reintroduce `--chart-*` tokens as one designed sequential scale when the first chart lands. The stock shadcn five were dropped because their light and dark values are unrelated hues. -- CHORE/API: `backend/app/models.py` still holds only the template's `User` and `Item`. - `Item` should go once flow persistence replaces it. +- CHORE/API: `backend/app/models.py` still holds the template's `Item` model. It has no + purpose now that flows are persisted and can go with its routes and tests. - PERF/UI: both frontend bundles exceed the 500 kB warning threshold. Revisit chunking once the canvas and editor are in, since they will dominate. ## Blocked -- FEAT/INFRA: Redis, MQTT broker and InfluxDB compose services. Blocked on the flow engine - being reachable from the API — no runtime path imports them today. +- FEAT/INFRA: MQTT broker and InfluxDB compose services for local development. The node types + exist; a local broker would make them testable without external hardware. - CHORE/INFRA: `bun install` inside the frontend Docker build intermittently fails with "Fail extracting tarball" for several packages at once, and succeeds on a plain rebuild. It looks like concurrent extraction under memory pressure. Pin down or retry in the diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index c2b83c8..c25ebd2 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -2,7 +2,7 @@ from collections.abc import Generator from typing import Annotated import jwt -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordBearer from jwt.exceptions import InvalidTokenError from pydantic import ValidationError @@ -11,6 +11,7 @@ from sqlmodel import Session from app.core import security from app.core.config import settings from app.core.db import engine +from app.flow.controller import FlowController from app.models import TokenPayload, User reusable_oauth2 = OAuth2PasswordBearer( @@ -27,6 +28,24 @@ SessionDep = Annotated[Session, Depends(get_db)] TokenDep = Annotated[str, Depends(reusable_oauth2)] +def user_from_token(session: Session, token: str) -> User | None: + """Resolve a bearer token to its user, or None if it does not hold up. + + Shared with the websocket, which cannot use the HTTP security scheme. + """ + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] + ) + token_data = TokenPayload(**payload) + except (InvalidTokenError, ValidationError): + return None + user = session.get(User, token_data.sub) + if user is None or not user.is_active: + return None + return user + + def get_current_user(session: SessionDep, token: TokenDep) -> User: try: payload = jwt.decode( @@ -49,6 +68,18 @@ def get_current_user(session: SessionDep, token: TokenDep) -> User: CurrentUser = Annotated[User, Depends(get_current_user)] +def get_flow_controller(request: Request) -> FlowController: + controller: FlowController | None = getattr( + request.app.state, "flow_controller", None + ) + if controller is None: + raise HTTPException(status_code=503, detail="The flow engine is not running") + return controller + + +FlowControllerDep = Annotated[FlowController, Depends(get_flow_controller)] + + def get_current_active_superuser(current_user: CurrentUser) -> User: if not current_user.is_superuser: raise HTTPException( diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8..8c7a3a2 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils +from app.api.routes import flows, items, login, private, secrets, users, utils from app.core.config import settings api_router = APIRouter() @@ -8,6 +8,9 @@ api_router.include_router(login.router) api_router.include_router(users.router) api_router.include_router(utils.router) api_router.include_router(items.router) +api_router.include_router(flows.router) +api_router.include_router(flows.ws_router) +api_router.include_router(secrets.router) if settings.ENVIRONMENT == "local": diff --git a/backend/app/api/routes/flows.py b/backend/app/api/routes/flows.py new file mode 100644 index 0000000..b991c0f --- /dev/null +++ b/backend/app/api/routes/flows.py @@ -0,0 +1,295 @@ +"""The flow API. Everything the editor can do is available here first.""" + +import asyncio +from typing import Any + +from fastapi import ( + APIRouter, + Depends, + HTTPException, + WebSocket, + WebSocketDisconnect, +) +from fastapi.concurrency import run_in_threadpool +from pydantic import BaseModel +from sqlmodel import Session + +from app.api.deps import FlowControllerDep, get_current_user, user_from_token +from app.core.db import engine +from app.flow.controller import FlowController +from app.flow.events import event_bus +from app.flow.messages import qualify +from app.flow.pipeline import ValidationIssue +from app.flow.schemas import ( + FlowDef, + FlowsPublic, + FlowStatePublic, + FlowSummary, + MessageValue, + NodeSource, + NodeStatusPublic, + NodeTypeInfo, +) +from app.flow.store import FlowNotFound +from app.models import Message + +router = APIRouter( + prefix="/flows", tags=["flows"], dependencies=[Depends(get_current_user)] +) +# The websocket authenticates from its query string, so it stays off the +# router that enforces the HTTP bearer scheme. +ws_router = APIRouter(prefix="/flows", tags=["flows"]) + + +class FlowDetail(BaseModel): + """A flow plus how it is currently doing.""" + + definition: FlowDef + nodes: list[NodeStatusPublic] = [] + issues: list[ValidationIssue] = [] + + +class ValidationResult(BaseModel): + issues: list[ValidationIssue] = [] + + +class RunRequest(BaseModel): + inputs: dict[str, Any] = {} + + +class TriggerRequest(BaseModel): + values: dict[str, Any] = {} + + +def _detail(controller: FlowController, definition: FlowDef) -> FlowDetail: + return FlowDetail( + definition=definition, + nodes=controller.node_statuses(definition.name), + issues=controller.flow_issues(definition.name), + ) + + +def _read_flow(controller: FlowController, name: str) -> FlowDef: + try: + return controller.store.read_flow(name) + except FlowNotFound: + raise HTTPException(status_code=404, detail=f"No flow named '{name}'") + + +def _flow_state(controller: FlowController, name: str) -> FlowStatePublic: + return FlowStatePublic( + values={ + key: MessageValue(**value) for key, value in controller.values(name).items() + }, + nodes=controller.node_statuses(name), + ) + + +# ----------------------------------------------------------------------------- +# Flows +# ----------------------------------------------------------------------------- + + +@router.get("/", response_model=FlowsPublic) +def read_flows(controller: FlowControllerDep) -> Any: + """List every flow.""" + summaries = [] + for definition in controller.store.read_all(): + statuses = controller.node_statuses(definition.name) + summaries.append( + FlowSummary( + name=definition.name, + title=definition.title, + node_count=len(definition.nodes), + error_count=sum(1 for s in statuses if s.status == "error"), + ) + ) + return FlowsPublic(data=summaries, count=len(summaries)) + + +@router.get("/node-types", response_model=list[NodeTypeInfo]) +def read_node_types() -> Any: + """The node types that can be placed on a canvas.""" + from app.flow.controller import node_type_info + + return node_type_info() + + +@router.get("/{name}", response_model=FlowDetail) +def read_flow(name: str, controller: FlowControllerDep) -> Any: + """Read one flow, with the state of its nodes.""" + return _detail(controller, _read_flow(controller, name)) + + +@router.put("/{name}", response_model=FlowDetail) +async def save_flow( + name: str, + definition: FlowDef, + controller: FlowControllerDep, +) -> Any: + """Create or replace a flow. Saving the same content again changes nothing.""" + if definition.name != name: + raise HTTPException( + status_code=400, detail="The flow name in the body must match the URL" + ) + + duplicates = {n.id for n in definition.nodes} + if len(duplicates) != len(definition.nodes): + raise HTTPException(status_code=400, detail="Node names must be unique") + + changed = await run_in_threadpool(controller.store.write_flow, definition) + if changed: + await controller.reload() + return _detail(controller, definition) + + +@router.delete("/{name}", response_model=Message) +async def delete_flow(name: str, controller: FlowControllerDep) -> Any: + """Delete a flow and everything in it.""" + try: + await run_in_threadpool(controller.store.delete_flow, name) + except FlowNotFound: + raise HTTPException(status_code=404, detail=f"No flow named '{name}'") + await controller.reload() + return Message(message=f"Deleted flow '{name}'") + + +# ----------------------------------------------------------------------------- +# Node source +# ----------------------------------------------------------------------------- + + +@router.get("/{name}/nodes/{node_id}/source", response_model=NodeSource) +def read_node_source( + name: str, + node_id: str, + controller: FlowControllerDep, +) -> Any: + """Read a node's Python source.""" + _read_flow(controller, name) + return NodeSource(code=controller.store.read_node_source(name, node_id)) + + +@router.put("/{name}/nodes/{node_id}/source", response_model=NodeStatusPublic) +async def save_node_source( + name: str, + node_id: str, + source: NodeSource, + controller: FlowControllerDep, +) -> Any: + """Save a node's source and report whether it loads.""" + _read_flow(controller, name) + changed = await run_in_threadpool( + controller.store.write_node_source, name, node_id, source.code + ) + if changed: + await controller.reload() + + status = next( + (s for s in controller.node_statuses(name) if s.id == f"{name}.{node_id}"), + None, + ) + return status or NodeStatusPublic(id=f"{name}.{node_id}") + + +# ----------------------------------------------------------------------------- +# Validation and execution +# ----------------------------------------------------------------------------- + + +@router.post("/{name}/validate", response_model=ValidationResult) +def validate_flow(name: str, controller: FlowControllerDep) -> Any: + """Report what would keep this flow from running.""" + _read_flow(controller, name) + return ValidationResult(issues=controller.flow_issues(name)) + + +@router.post("/{name}/run", response_model=FlowStatePublic) +async def run_flow( + name: str, + body: RunRequest, + controller: FlowControllerDep, +) -> Any: + """Run every node of a flow once.""" + _read_flow(controller, name) + inputs = {qualify(name, key): value for key, value in body.inputs.items()} + await run_in_threadpool(controller.run_flow, name, inputs) + return _flow_state(controller, name) + + +@router.post("/{name}/nodes/{node_id}/trigger", response_model=FlowStatePublic) +async def trigger_node( + name: str, + node_id: str, + body: TriggerRequest, + controller: FlowControllerDep, +) -> Any: + """Feed values into a single node.""" + try: + await run_in_threadpool( + controller.trigger_node, f"{name}.{node_id}", body.values + ) + except KeyError: + raise HTTPException( + status_code=404, detail=f"No node named '{node_id}' in flow '{name}'" + ) + return _flow_state(controller, name) + + +@router.get("/{name}/state", response_model=FlowStatePublic) +def read_flow_state(name: str, controller: FlowControllerDep) -> Any: + """The last value seen on every message of this flow.""" + _read_flow(controller, name) + return _flow_state(controller, name) + + +# ----------------------------------------------------------------------------- +# Live updates +# ----------------------------------------------------------------------------- + + +@ws_router.websocket("/ws") +async def flow_events(websocket: WebSocket, token: str = "") -> None: + """Stream values, node status and execution events as they happen. + + The token goes in the query string because browsers cannot set headers on + a websocket handshake. + """ + with Session(engine) as session: + user = user_from_token(session, token) + if user is None: + await websocket.close(code=1008) + return + + await websocket.accept() + + controller: FlowController | None = getattr( + websocket.app.state, "flow_controller", None + ) + if controller is not None: + await websocket.send_json( + { + "type": "snapshot", + "values": controller.values(), + "nodes": [s.model_dump() for s in controller.node_statuses()], + "issues": [i.model_dump() for i in controller.issues], + } + ) + + async with event_bus.subscribe() as queue: + receiver = asyncio.create_task(websocket.receive_text()) + try: + while True: + sender = asyncio.create_task(queue.get()) + done, _ = await asyncio.wait( + {sender, receiver}, return_when=asyncio.FIRST_COMPLETED + ) + if receiver in done: + # The client went away. + sender.cancel() + break + await websocket.send_json(sender.result()) + except WebSocketDisconnect: + pass + finally: + receiver.cancel() diff --git a/backend/app/api/routes/secrets.py b/backend/app/api/routes/secrets.py new file mode 100644 index 0000000..0ddc0a2 --- /dev/null +++ b/backend/app/api/routes/secrets.py @@ -0,0 +1,51 @@ +"""Credentials for node integrations. + +Values go in and are never handed back out — the API only ever lists names. +""" + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.concurrency import run_in_threadpool +from pydantic import BaseModel + +from app.api.deps import get_current_user +from app.flow.secrets import SecretNotFound, get_secrets +from app.models import Message + +router = APIRouter( + prefix="/secrets", tags=["secrets"], dependencies=[Depends(get_current_user)] +) + + +class SecretNames(BaseModel): + data: list[str] + count: int + + +class SecretValue(BaseModel): + value: str + + +@router.get("/", response_model=SecretNames) +def read_secrets() -> Any: + """List the names of stored secrets.""" + names = get_secrets().list() + return SecretNames(data=names, count=len(names)) + + +@router.put("/{name}", response_model=Message) +async def save_secret(name: str, body: SecretValue) -> Any: + """Store a secret under a name that nodes can reference.""" + await run_in_threadpool(get_secrets().set, name, body.value) + return Message(message=f"Saved secret '{name}'") + + +@router.delete("/{name}", response_model=Message) +async def delete_secret(name: str) -> Any: + """Delete a secret.""" + try: + await run_in_threadpool(get_secrets().delete, name) + except SecretNotFound: + raise HTTPException(status_code=404, detail=f"No secret named '{name}'") + return Message(message=f"Deleted secret '{name}'") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 650b9f7..7c68064 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,5 +1,6 @@ import secrets import warnings +from pathlib import Path from typing import Annotated, Any, Literal from pydantic import ( @@ -37,6 +38,14 @@ class Settings(BaseSettings): FRONTEND_HOST: str = "http://localhost:5173" ENVIRONMENT: Literal["local", "staging", "production"] = "local" + # Flows live on disk as a git repository; secrets stay outside it. + FLOWS_DIR: Path = Path("flow-data/flows") + SECRETS_FILE: Path = Path("flow-data/secrets.enc") + FLOW_MAX_WORKERS: int = 4 + # Without a Redis host the engine keeps its state in memory. + REDIS_HOST: str | None = None + REDIS_PORT: int = 6379 + BACKEND_CORS_ORIGINS: Annotated[ list[AnyUrl] | str, BeforeValidator(parse_cors) ] = [] diff --git a/backend/app/flow/__init__.py b/backend/app/flow/__init__.py new file mode 100644 index 0000000..66dd1d9 --- /dev/null +++ b/backend/app/flow/__init__.py @@ -0,0 +1,26 @@ +"""The flow engine: nodes, the pipeline that runs them, and their storage.""" + +from app.flow.controller import FlowController, NodeStatus +from app.flow.events import EventBus, event_bus +from app.flow.messages import DType, MessageSpec, qualify +from app.flow.nodes import Node +from app.flow.pipeline import Pipeline, ValidationIssue +from app.flow.state import MemoryState, RedisState, StateBackend +from app.flow.store import FlowStore + +__all__ = [ + "DType", + "EventBus", + "FlowController", + "FlowStore", + "MemoryState", + "MessageSpec", + "Node", + "NodeStatus", + "Pipeline", + "RedisState", + "StateBackend", + "ValidationIssue", + "event_bus", + "qualify", +] diff --git a/backend/app/flow/controller.py b/backend/app/flow/controller.py index 88b25fe..f4af4ed 100644 --- a/backend/app/flow/controller.py +++ b/backend/app/flow/controller.py @@ -1,813 +1,392 @@ -""" -Pipeline Controller with file watching for dynamic node updates. +"""Turns stored flows into a running pipeline. -This module provides a PipelineController class that manages a pipeline, -watches for changes in node files, and dynamically updates the graph. -Handles errors gracefully to keep the pipeline intact when nodes have issues. +The controller is the only thing that builds nodes: it reads flow definitions +from the store, instantiates each node from its type, and rebuilds the shared +pipeline. A node that fails to load is reported and skipped — one broken node +never stops the rest. """ from __future__ import annotations import asyncio import hashlib -import importlib.util import logging import sys import traceback +from collections.abc import Callable from dataclasses import dataclass, field from enum import Enum -from pathlib import Path -from typing import Any, Callable, TYPE_CHECKING -from watchfiles import awatch, Change +from types import ModuleType +from typing import Any, cast -from nodes import Node, HttpNode, MqttNode, InfluxDbNode, DelayNode -from pipeline import Pipeline -from state import StateBackend, MemoryState, RedisState -from util import Message +from fastapi import FastAPI -if TYPE_CHECKING: - from fastapi import FastAPI - - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +from app.flow.events import EventBus +from app.flow.messages import MessageSpec, qualify +from app.flow.nodes import ( + DelayNode, + HttpNode, + InfluxDbNode, + MLPNode, + MqttNode, + Node, ) +from app.flow.pipeline import Pipeline, ValidationIssue +from app.flow.schemas import NodeDef, NodeStatusPublic, NodeTypeInfo +from app.flow.secrets import SecretNotFound, resolve_params +from app.flow.state import MemoryState, StateBackend +from app.flow.store import FlowStore + logger = logging.getLogger(__name__) +HOOK_PREFIX = "/hooks" -class NodeStatus(Enum): - """Status of a loaded node module.""" +class NodeStatus(str, Enum): ACTIVE = "active" ERROR = "error" - DISABLED = "disabled" @dataclass class LoadedNode: - """Represents a loaded node with metadata.""" + """A node as the editor sees it: built, or failed with a reason.""" - node: Node | None - file_path: Path - file_hash: str - status: NodeStatus - error_message: str | None = None - last_modified: float = 0.0 + id: str + flow: str + status: NodeStatus = NodeStatus.ACTIVE + node: Node | None = None + error: str | None = None @dataclass -class PipelineState: - """Holds the current state of the pipeline controller.""" +class NodeType: + """A node type the editor can place on a canvas.""" - nodes: dict[str, LoadedNode] = field(default_factory=dict) - pipeline: Pipeline | None = None - is_running: bool = False + title: str + description: str + # Constructor signatures differ per node type. + cls: Any + has_source: bool = False + params_schema: dict[str, Any] = field(default_factory=dict) -class PipelineController: +def _schema_of(cls: Any) -> dict[str, Any]: + params = getattr(cls, "Params", None) + return params.model_json_schema() if params is not None else {} + + +NODE_TYPES: dict[str, NodeType] = { + "python": NodeType( + title="Function", + description="Your own Python code, run on every incoming message.", + cls=Node, + has_source=True, + ), + "mqtt": NodeType( + title="MQTT", + description="Subscribe to topics, or publish what arrives on its inputs.", + cls=MqttNode, + params_schema=_schema_of(MqttNode), + ), + "http": NodeType( + title="HTTP", + description="Receive data on a webhook, or send it to a URL.", + cls=HttpNode, + params_schema=_schema_of(HttpNode), + ), + "influxdb": NodeType( + title="InfluxDB", + description="Write measurements to a bucket, or read them back.", + cls=InfluxDbNode, + params_schema=_schema_of(InfluxDbNode), + ), + "delay": NodeType( + title="Delay & schedule", + description="Hold messages back, limit their rate, or emit on a schedule.", + cls=DelayNode, + params_schema=_schema_of(DelayNode), + ), + "mlp": NodeType( + title="Perceptron", + description="A small neural layer over its numeric inputs.", + cls=MLPNode, + params_schema=_schema_of(MLPNode), + ), +} + + +def node_type_info() -> list[NodeTypeInfo]: + return [ + NodeTypeInfo( + type=key, + title=spec.title, + description=spec.description, + params_schema=spec.params_schema, + has_source=spec.has_source, + ) + for key, spec in NODE_TYPES.items() + ] + + +def _load_function(flow: str, node_id: str, code: str) -> Callable[..., Any]: + """Compile a node's source and return the function to run. + + A node file defines ``process(...)``; if it defines exactly one public + function under another name, that one is used. """ - Controller for managing a dynamic pipeline with file watching. + digest = hashlib.md5(code.encode()).hexdigest()[:8] + module_name = f"_fluksio_node_{flow}_{node_id}_{digest}" - Watches a directory for node files, dynamically loads/unloads nodes, - and rebuilds the pipeline graph when changes are detected. Errors in - individual nodes don't affect the rest of the pipeline. + module = ModuleType(module_name) + module.__dict__["__name__"] = module_name + sys.modules[module_name] = module + exec(compile(code, f"", "exec"), module.__dict__) - :param nodes_dir: Directory containing node files. - :type nodes_dir: str | Path - :param state_backend: State backend for the pipeline. - :type state_backend: StateBackend | None - :param max_workers: Maximum workers for parallel execution. - :type max_workers: int - :param node_params: Default parameters passed to nodes. - :type node_params: dict | None + if callable(getattr(module, "process", None)): + return cast(Callable[..., Any], module.process) - :example: - >>> controller = PipelineController( - ... nodes_dir="./nodes", - ... state_backend=RedisState(host="localhost"), - ... max_workers=4, - ... ) - >>> await controller.start() - """ + functions = [ + value + for name, value in vars(module).items() + if callable(value) + and not name.startswith("_") + and getattr(value, "__module__", None) == module_name + ] + if len(functions) == 1: + return cast(Callable[..., Any], functions[0]) + raise ValueError( + "Define a function named 'process' — this file has " + f"{len(functions)} functions to choose from." + ) + + +class FlowController: + """Owns the running pipeline and keeps it in step with the store.""" def __init__( self, - nodes_dir: str | Path, - state_backend: StateBackend | None = None, - max_workers: int = 4, - node_params: dict | None = None, - fastapi_app: "FastAPI | None" = None, + store: FlowStore, + state: StateBackend | None = None, + events: EventBus | None = None, + max_workers: int | None = None, + fastapi_app: FastAPI | None = None, ) -> None: - self.nodes_dir = Path(nodes_dir) - self.state_backend = state_backend or MemoryState() + self.store = store + self.state = state or MemoryState() + self.events = events self.max_workers = max_workers - self.node_params = node_params or {} - self._fastapi_app: "FastAPI | None" = fastapi_app + self.app = fastapi_app - self._state = PipelineState() - self._watch_task: asyncio.Task | None = None - self._rebuild_lock = asyncio.Lock() - self._shutdown_event = asyncio.Event() - - # Callbacks for external integrations (e.g., frontend notifications) - self._on_node_loaded: list[Callable[[str, Node], None]] = [] - self._on_node_error: list[Callable[[str, str], None]] = [] - self._on_node_removed: list[Callable[[str], None]] = [] - self._on_pipeline_rebuilt: list[Callable[[Pipeline], None]] = [] + self.pipeline: Pipeline | None = None + self.loaded: dict[str, LoadedNode] = {} + self.issues: list[ValidationIssue] = [] + self._lock = asyncio.Lock() # ------------------------------------------------------------------------- - # Event Callbacks - # ------------------------------------------------------------------------- - - def on_node_loaded(self, callback: Callable[[str, Node], None]) -> None: - """Register a callback for when a node is successfully loaded.""" - self._on_node_loaded.append(callback) - - def on_node_error(self, callback: Callable[[str, str], None]) -> None: - """Register a callback for when a node fails to load.""" - self._on_node_error.append(callback) - - def on_node_removed(self, callback: Callable[[str], None]) -> None: - """Register a callback for when a node is removed.""" - self._on_node_removed.append(callback) - - def on_pipeline_rebuilt(self, callback: Callable[[Pipeline], None]) -> None: - """Register a callback for when the pipeline is rebuilt.""" - self._on_pipeline_rebuilt.append(callback) - - # ------------------------------------------------------------------------- - # Node Loading - # ------------------------------------------------------------------------- - - def _compute_file_hash(self, file_path: Path) -> str: - """Compute MD5 hash of file contents for change detection.""" - content = file_path.read_bytes() - return hashlib.md5(content).hexdigest() - - def _load_node_from_file(self, file_path: Path) -> LoadedNode: - """ - Load a node from a Python file. - - The file should define either: - - A `create_node(params: dict) -> Node` function - - A `node` variable of type Node - - A class that inherits from Node - - :param file_path: Path to the Python file. - :type file_path: Path - :returns: LoadedNode with the loaded node or error info. - :rtype: LoadedNode - """ - file_hash = self._compute_file_hash(file_path) - module_name = f"_dynamic_node_{file_path.stem}_{file_hash[:8]}" - - try: - # Load the module dynamically - spec = importlib.util.spec_from_file_location(module_name, file_path) - if spec is None or spec.loader is None: - raise ImportError(f"Cannot load spec for {file_path}") - - module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module - spec.loader.exec_module(module) - - # Try different ways to get the node - node: Node | None = None - - # Option 1: create_node function - if hasattr(module, "create_node"): - node = module.create_node(self.node_params) - if not isinstance(node, Node): - raise TypeError( - f"create_node() must return a Node, got {type(node)}" - ) - - # Option 2: node variable - elif hasattr(module, "node"): - node = module.node - if not isinstance(node, Node): - raise TypeError(f"'node' must be a Node instance, got {type(node)}") - - # Option 3: Node subclass - else: - node_classes = [ - obj - for name, obj in vars(module).items() - if isinstance(obj, type) - and issubclass(obj, Node) - and obj is not Node - and not name.startswith("_") - ] - if node_classes: - # Use the first non-private Node subclass found - node = node_classes[0]( - f=lambda params, **kwargs: kwargs, # Default passthrough - requires=[], - provides=[], - params=self.node_params, - name=file_path.stem, - ) - - if node is None: - raise ValueError( - f"No node found in {file_path}. " - "Define create_node(params), a 'node' variable, or a Node subclass." - ) - - # Register HTTP routes for HttpNode trigger nodes - if isinstance(node, HttpNode) and node.mode == HttpNode.Mode.TRIGGER: - if self._fastapi_app is not None: - node.register_route(self._fastapi_app) - else: - logger.warning( - f"HttpNode '{node.name}' is a trigger but no FastAPI app provided" - ) - - # Track MQTT subscriber nodes for later subscription start - # (subscriptions are started in _rebuild_pipeline after pipeline is built) - if isinstance(node, MqttNode) and node.mode == MqttNode.Mode.SUBSCRIBER: - logger.info( - f"MqttNode '{node.name}' is a subscriber, will start after pipeline rebuild" - ) - - # Track DelayNode cron jobs for later start - # (cron jobs are started in _rebuild_pipeline after pipeline is built) - if isinstance(node, DelayNode) and node.cron_expr: - logger.info( - f"DelayNode '{node.name}' has cron '{node.cron_expr}', will start after pipeline rebuild" - ) - - logger.info(f"Loaded node '{node.name}' from {file_path.name}") - - return LoadedNode( - node=node, - file_path=file_path, - file_hash=file_hash, - status=NodeStatus.ACTIVE, - last_modified=file_path.stat().st_mtime, - ) - - except Exception as e: - error_msg = f"{type(e).__name__}: {e}\n{traceback.format_exc()}" - logger.error(f"Failed to load node from {file_path.name}: {e}") - - return LoadedNode( - node=None, - file_path=file_path, - file_hash=file_hash, - status=NodeStatus.ERROR, - error_message=error_msg, - last_modified=file_path.stat().st_mtime if file_path.exists() else 0, - ) - finally: - # Clean up the module from sys.modules to allow reloading - if module_name in sys.modules: - del sys.modules[module_name] - - def _unload_node(self, node_id: str) -> None: - """ - Remove a node from the controller state (sync version). - - :param node_id: The ID of the node to unload. - :type node_id: str - """ - if node_id in self._state.nodes: - loaded_node = self._state.nodes.pop(node_id) - - # Unregister HTTP routes for HttpNode trigger nodes - if ( - loaded_node.node is not None - and isinstance(loaded_node.node, HttpNode) - and loaded_node.node.mode == HttpNode.Mode.TRIGGER - and self._fastapi_app is not None - ): - loaded_node.node.unregister_route(self._fastapi_app) - - # Note: MQTT subscriptions need async cleanup, handled in _unload_node_async - - logger.info(f"Unloaded node '{node_id}'") - for callback in self._on_node_removed: - try: - callback(node_id) - except Exception as e: - logger.error(f"Error in on_node_removed callback: {e}") - - async def _unload_node_async(self, node_id: str) -> None: - """ - Remove a node from the controller state (async version). - - This version properly handles async cleanup like stopping MQTT subscriptions. - - :param node_id: The ID of the node to unload. - :type node_id: str - """ - if node_id in self._state.nodes: - loaded_node = self._state.nodes.pop(node_id) - - # Unregister HTTP routes for HttpNode trigger nodes - if ( - loaded_node.node is not None - and isinstance(loaded_node.node, HttpNode) - and loaded_node.node.mode == HttpNode.Mode.TRIGGER - and self._fastapi_app is not None - ): - loaded_node.node.unregister_route(self._fastapi_app) - - # Stop MQTT subscriptions for MqttNode subscriber nodes - if ( - loaded_node.node is not None - and isinstance(loaded_node.node, MqttNode) - and loaded_node.node.mode == MqttNode.Mode.SUBSCRIBER - ): - try: - await loaded_node.node.stop_subscription() - except Exception as e: - logger.error( - f"Error stopping MQTT subscription for '{node_id}': {e}" - ) - - # Stop cron jobs for DelayNode nodes - if ( - loaded_node.node is not None - and isinstance(loaded_node.node, DelayNode) - and loaded_node.node.cron_expr - ): - try: - await loaded_node.node.stop_cron() - except Exception as e: - logger.error(f"Error stopping cron for '{node_id}': {e}") - - logger.info(f"Unloaded node '{node_id}'") - for callback in self._on_node_removed: - try: - callback(node_id) - except Exception as e: - logger.error(f"Error in on_node_removed callback: {e}") - - # ------------------------------------------------------------------------- - # Pipeline Building - # ------------------------------------------------------------------------- - - async def _rebuild_pipeline(self) -> None: - """ - Rebuild the pipeline from currently loaded nodes. - - Only includes nodes with ACTIVE status. The pipeline is rebuilt - atomically to avoid inconsistent states. Also starts MQTT subscriptions - for subscriber nodes. - """ - async with self._rebuild_lock: - active_nodes = [ - ln.node - for ln in self._state.nodes.values() - if ln.status == NodeStatus.ACTIVE and ln.node is not None - ] - - if not active_nodes: - logger.warning("No active nodes to build pipeline") - self._state.pipeline = None - return - - try: - # Build new pipeline - new_pipeline = Pipeline( - nodes=active_nodes, - max_workers=self.max_workers, - state=self.state_backend, - ) - - self._state.pipeline = new_pipeline - logger.info(f"Pipeline rebuilt with {len(active_nodes)} nodes") - - # Start MQTT subscriptions for subscriber nodes - for node in active_nodes: - if ( - isinstance(node, MqttNode) - and node.mode == MqttNode.Mode.SUBSCRIBER - ): - if not node.is_subscribed: - try: - await node.start_subscription() - except Exception as e: - logger.error( - f"Failed to start MQTT subscription for '{node.name}': {e}" - ) - - # Start cron jobs for DelayNode nodes - for node in active_nodes: - if isinstance(node, DelayNode) and node.cron_expr: - if node._cron_task is None: - try: - await node.start_cron() - except Exception as e: - logger.error( - f"Failed to start cron for '{node.name}': {e}" - ) - - # Notify callbacks - for callback in self._on_pipeline_rebuilt: - try: - callback(new_pipeline) - except Exception as e: - logger.error(f"Error in on_pipeline_rebuilt callback: {e}") - - except Exception as e: - logger.error(f"Failed to rebuild pipeline: {e}") - # Keep the old pipeline intact - - # ------------------------------------------------------------------------- - # File Watching - # ------------------------------------------------------------------------- - - async def _scan_nodes_directory(self) -> None: - """Scan the nodes directory and load all Python files.""" - if not self.nodes_dir.exists(): - logger.warning(f"Nodes directory does not exist: {self.nodes_dir}") - self.nodes_dir.mkdir(parents=True, exist_ok=True) - return - - python_files = list(self.nodes_dir.glob("*.py")) - logger.info(f"Found {len(python_files)} Python files in {self.nodes_dir}") - - for file_path in python_files: - if file_path.name.startswith("_"): - continue # Skip private/internal files - - node_id = file_path.stem - loaded_node = self._load_node_from_file(file_path) - self._state.nodes[node_id] = loaded_node - - # Trigger callbacks - if loaded_node.status == NodeStatus.ACTIVE and loaded_node.node: - for callback in self._on_node_loaded: - try: - callback(node_id, loaded_node.node) - except Exception as e: - logger.error(f"Error in on_node_loaded callback: {e}") - elif loaded_node.status == NodeStatus.ERROR: - for callback in self._on_node_error: - try: - callback(node_id, loaded_node.error_message or "Unknown error") - except Exception as e: - logger.error(f"Error in on_node_error callback: {e}") - - await self._rebuild_pipeline() - - async def _handle_file_change(self, change_type: Change, file_path: Path) -> None: - """Handle a file change event.""" - if not file_path.suffix == ".py" or file_path.name.startswith("_"): - return - - node_id = file_path.stem - - if change_type == Change.deleted: - await self._unload_node_async(node_id) - await self._rebuild_pipeline() - return - - # Added or modified - if node_id in self._state.nodes: - # Check if file actually changed - current_hash = self._state.nodes[node_id].file_hash - try: - new_hash = self._compute_file_hash(file_path) - if new_hash == current_hash: - return # No actual change - except FileNotFoundError: - return - - # Unload existing node before reloading (for proper cleanup) - await self._unload_node_async(node_id) - - # Load/reload the node - loaded_node = self._load_node_from_file(file_path) - self._state.nodes[node_id] = loaded_node - - # Trigger callbacks - if loaded_node.status == NodeStatus.ACTIVE and loaded_node.node: - for callback in self._on_node_loaded: - try: - callback(node_id, loaded_node.node) - except Exception as e: - logger.error(f"Error in on_node_loaded callback: {e}") - elif loaded_node.status == NodeStatus.ERROR: - for callback in self._on_node_error: - try: - callback(node_id, loaded_node.error_message or "Unknown error") - except Exception as e: - logger.error(f"Error in on_node_error callback: {e}") - - await self._rebuild_pipeline() - - async def _watch_files(self) -> None: - """Watch the nodes directory for changes.""" - logger.info(f"Starting file watcher for {self.nodes_dir}") - - try: - async for changes in awatch( - self.nodes_dir, stop_event=self._shutdown_event - ): - for change_type, path_str in changes: - file_path = Path(path_str) - logger.info(f"File change detected: {change_type.name} {file_path}") - await self._handle_file_change(change_type, file_path) - except asyncio.CancelledError: - logger.info("File watcher cancelled") - except Exception as e: - logger.error(f"File watcher error: {e}") - - # ------------------------------------------------------------------------- - # Public API + # Lifecycle # ------------------------------------------------------------------------- async def start(self) -> None: - """ - Start the pipeline controller. - - Performs initial scan of the nodes directory and starts the file watcher. - """ - if self._state.is_running: - logger.warning("Controller is already running") - return - - self._state.is_running = True - self._shutdown_event.clear() - - # Initial scan - await self._scan_nodes_directory() - - # Start file watcher - self._watch_task = asyncio.create_task(self._watch_files()) - logger.info("Pipeline controller started") + await self.reload() async def stop(self) -> None: - """Stop the pipeline controller, file watcher, and all subscriptions.""" - if not self._state.is_running: - return + await self._teardown() - logger.info("Stopping pipeline controller...") - self._shutdown_event.set() + async def reload(self) -> None: + """Rebuild the whole pipeline from what is currently stored.""" + async with self._lock: + await self._teardown() - # Stop all MQTT subscriptions - for node_id, loaded_node in list(self._state.nodes.items()): - if ( - loaded_node.node is not None - and isinstance(loaded_node.node, MqttNode) - and loaded_node.node.mode == MqttNode.Mode.SUBSCRIBER - ): - try: - await loaded_node.node.stop_subscription() - except Exception as e: - logger.error( - f"Error stopping MQTT subscription for '{node_id}': {e}" - ) + nodes: list[Node] = [] + loaded: dict[str, LoadedNode] = {} + initial_values: dict[str, Any] = {} + known_inputs: set[str] = set() - # Stop all cron jobs - for node_id, loaded_node in list(self._state.nodes.items()): - if ( - loaded_node.node is not None - and isinstance(loaded_node.node, DelayNode) - and loaded_node.node.cron_expr - ): - try: - await loaded_node.node.stop_cron() - except Exception as e: - logger.error(f"Error stopping cron for '{node_id}': {e}") + for flow in self.store.read_all(): + for node_def in flow.nodes: + entry = self._build_node(flow.name, node_def) + loaded[entry.id] = entry + if entry.node is not None: + nodes.append(entry.node) + for flow_input in flow.inputs: + name = qualify(flow.name, flow_input.spec.name) + if not name: + continue + known_inputs.add(name) + if flow_input.initial is not None: + initial_values[name] = flow_input.initial - if self._watch_task: - self._watch_task.cancel() - try: - await self._watch_task - except asyncio.CancelledError: - pass - self._watch_task = None + self.loaded = loaded + self.pipeline = Pipeline( + nodes=nodes, + state=self.state, + events=self.events, + max_workers=self.max_workers, + initial_values=initial_values, + ) + self.issues = self.pipeline.validate(known_inputs) + self.issues += [ + ValidationIssue( + code="node_error", + message=entry.error or "This node could not be loaded.", + flow=entry.flow, + node=entry.id, + ) + for entry in loaded.values() + if entry.status is NodeStatus.ERROR + ] - self._state.is_running = False - logger.info("Pipeline controller stopped") + await self._activate() - async def reload_node(self, node_id: str) -> bool: - """ - Manually reload a specific node. - - :param node_id: The ID (filename stem) of the node to reload. - :type node_id: str - :returns: True if reload was successful. - :rtype: bool - """ - file_path = self.nodes_dir / f"{node_id}.py" - if not file_path.exists(): - logger.error(f"Node file not found: {file_path}") - return False - - await self._handle_file_change(Change.modified, file_path) - return ( - self._state.nodes.get( - node_id, - LoadedNode( - node=None, - file_path=file_path, - file_hash="", - status=NodeStatus.ERROR, - ), - ).status - == NodeStatus.ACTIVE + self._publish( + { + "type": "pipeline_rebuilt", + "issues": [issue.model_dump() for issue in self.issues], + "nodes": [status.model_dump() for status in self.node_statuses()], + } ) - async def reload_all(self) -> None: - """Reload all nodes from the nodes directory.""" - self._state.nodes.clear() - await self._scan_nodes_directory() + async def _teardown(self) -> None: + """Stop everything the previous pipeline started.""" + for entry in self.loaded.values(): + node = entry.node + if node is None: + continue + try: + if isinstance(node, MqttNode) and node.is_subscribed: + await node.stop_subscription() + elif isinstance(node, DelayNode) and node.cron_expr: + await node.stop_cron() + if ( + isinstance(node, HttpNode) + and node.mode == HttpNode.Mode.TRIGGER + and self.app is not None + ): + node.unregister_route(self.app) + except Exception: + logger.exception("Error stopping node '%s'", entry.id) - def add_node_from_code(self, node_id: str, code: str) -> bool: - """ - Add or update a node from code string. + async def _activate(self) -> None: + """Start subscriptions, schedules and webhooks of the new pipeline.""" + for entry in self.loaded.values(): + node = entry.node + if node is None: + continue + try: + if isinstance(node, MqttNode) and node.mode == MqttNode.Mode.SUBSCRIBER: + await node.start_subscription() + elif isinstance(node, DelayNode) and node.cron_expr: + await node.start_cron() + elif ( + isinstance(node, HttpNode) + and node.mode == HttpNode.Mode.TRIGGER + and self.app is not None + ): + node.register_route(self.app) + except Exception as exc: + logger.exception("Error starting node '%s'", entry.id) + entry.status = NodeStatus.ERROR + entry.error = f"{type(exc).__name__}: {exc}" - This is the entry point for frontend-provided node code. - - :param node_id: Unique identifier for the node. - :type node_id: str - :param code: Python code defining the node. - :type code: str - :returns: True if node was successfully added. - :rtype: bool - """ - file_path = self.nodes_dir / f"{node_id}.py" + # ------------------------------------------------------------------------- + # Building + # ------------------------------------------------------------------------- + def _build_node(self, flow: str, node_def: NodeDef) -> LoadedNode: + node_id = f"{flow}.{node_def.id}" + entry = LoadedNode(id=node_id, flow=flow) try: - # Write the code to file - file_path.write_text(code) - logger.info(f"Wrote node code to {file_path}") - return True - except Exception as e: - logger.error(f"Failed to write node file: {e}") - return False + node_type = NODE_TYPES.get(node_def.type) + if node_type is None: + raise ValueError(f"Unknown node type '{node_def.type}'") - def remove_node(self, node_id: str) -> bool: - """ - Remove a node by deleting its file. + params = resolve_params(node_def.params) - :param node_id: The ID of the node to remove. - :type node_id: str - :returns: True if node was removed. - :rtype: bool - """ - file_path = self.nodes_dir / f"{node_id}.py" + if node_type.has_source: + code = self.store.read_node_source(flow, node_def.id) + node = Node( + f=_load_function(flow, node_def.id, code), + requires=_bound(node_def.requires), + provides=_bound(node_def.provides), + params=params, + name=node_def.id, + ) + else: + node = node_type.cls( + requires=_bound(node_def.requires), + provides=_bound(node_def.provides), + params=params, + name=node_def.id, + ) - try: - if file_path.exists(): - file_path.unlink() - logger.info(f"Removed node file {file_path}") - return True - except Exception as e: - logger.error(f"Failed to remove node file: {e}") - return False + node.assign_flow(flow, node_def.id) - @property - def pipeline(self) -> Pipeline | None: - """Get the current pipeline instance.""" - return self._state.pipeline + if isinstance(node, HttpNode) and node.mode == HttpNode.Mode.TRIGGER: + # Webhooks live under their flow, away from the JSON API. + node.url = f"{HOOK_PREFIX}/{flow}/{node.url.lstrip('/')}" - @property - def nodes(self) -> dict[str, LoadedNode]: - """Get all loaded nodes with their status.""" - return self._state.nodes.copy() + entry.node = node + except SecretNotFound as exc: + entry.status = NodeStatus.ERROR + entry.error = str(exc) + except Exception as exc: + logger.warning("Node '%s' failed to load: %s", node_id, exc) + entry.status = NodeStatus.ERROR + entry.error = _short_traceback(exc) + return entry - @property - def active_nodes(self) -> list[Node]: - """Get list of currently active nodes.""" + # ------------------------------------------------------------------------- + # Queries + # ------------------------------------------------------------------------- + + def node_statuses(self, flow: str | None = None) -> list[NodeStatusPublic]: return [ - ln.node - for ln in self._state.nodes.values() - if ln.status == NodeStatus.ACTIVE and ln.node is not None + NodeStatusPublic(id=entry.id, status=entry.status.value, error=entry.error) + for entry in self.loaded.values() + if flow is None or entry.flow == flow ] - @property - def error_nodes(self) -> dict[str, str]: - """Get nodes that failed to load with their error messages.""" - return { - node_id: ln.error_message or "Unknown error" - for node_id, ln in self._state.nodes.items() - if ln.status == NodeStatus.ERROR - } + def flow_issues(self, flow: str) -> list[ValidationIssue]: + return [issue for issue in self.issues if not issue.flow or issue.flow == flow] - def get_node_status(self, node_id: str) -> NodeStatus | None: - """Get the status of a specific node.""" - if node_id in self._state.nodes: - return self._state.nodes[node_id].status - return None + def values(self, flow: str | None = None) -> dict[str, dict[str, Any]]: + return self.pipeline.values(flow) if self.pipeline else {} - def trigger_node(self, node_id: str, inputs: dict | None = None) -> Any: - """ - Trigger a node by its ID. + def get_node(self, node_id: str) -> Node | None: + entry = self.loaded.get(node_id) + return entry.node if entry else None - :param node_id: The ID of the node to trigger. - :type node_id: str - :param inputs: Optional input values. - :type inputs: dict | None - :returns: Result of the node execution. - :rtype: Any - """ - if self._state.pipeline is None: - raise RuntimeError("Pipeline not initialized") + # ------------------------------------------------------------------------- + # Execution + # ------------------------------------------------------------------------- - loaded_node = self._state.nodes.get(node_id) - if loaded_node is None or loaded_node.node is None: - raise ValueError(f"Node '{node_id}' not found or not active") + def run_flow(self, flow: str, inputs: dict[str, Any] | None = None) -> None: + """Run every node of one flow. Blocking — call from a worker thread.""" + if self.pipeline is None: + return + self.pipeline.run(inputs or {}, nodes=self.pipeline.flow_nodes(flow)) - return loaded_node.node.trigger(inputs) - - def set_fastapi_app(self, app: "FastAPI") -> None: - """ - Set the FastAPI application for HTTP node route registration. - - This method can be called after initialization to attach a FastAPI - app. It will register routes for any existing HTTP trigger nodes. - - :param app: The FastAPI application instance. - :type app: FastAPI - """ - self._fastapi_app = app - - # Register routes for any existing HTTP trigger nodes - for node_id, loaded_node in self._state.nodes.items(): - if ( - loaded_node.node is not None - and loaded_node.status == NodeStatus.ACTIVE - and isinstance(loaded_node.node, HttpNode) - and loaded_node.node.mode == HttpNode.Mode.TRIGGER - ): - loaded_node.node.register_route(app) - logger.info(f"Registered HTTP route for existing node '{node_id}'") - - -async def main(): - """ - Main function to demonstrate the PipelineController usage. - - Sets up Redis or falls back to MemoryState, creates a controller, - and watches for node changes in the nodes directory. - """ - try: - state = RedisState( - host="localhost", - port=6379, - namespace="fluksio_controller_test", - ttl=3600, - ) - if state.ping(): - logger.info("Using Redis state backend") + def trigger_node(self, node_id: str, values: dict[str, Any] | None = None) -> None: + """Feed values into one node. Blocking — call from a worker thread.""" + node = self.get_node(node_id) + if node is None: + raise KeyError(node_id) + if node.requires and values: + node.trigger(values) else: - raise ConnectionError("Redis ping failed") - except Exception as e: - logger.warning("Redis not available (%s), using MemoryState", e) - state = MemoryState() + node.inject(values or {}) - # Create controller pointing to example nodes - nodes_dir = Path(__file__).parent / "nodes" - - controller = PipelineController( - nodes_dir=nodes_dir, - state_backend=state, - max_workers=4, - ) - - # Register callbacks to see what's happening - controller.on_node_loaded( - lambda nid, node: logger.info( - "Loaded node '%s': %d inputs, %d outputs", - nid, - len(node.requires), - len(node.provides), - ) - ) - controller.on_node_error( - lambda nid, err: logger.error("Error in node '%s': %s", nid, err.split("\n")[0]) - ) - controller.on_node_removed(lambda nid: logger.info("Removed node '%s'", nid)) - controller.on_pipeline_rebuilt( - lambda p: logger.info( - "Pipeline rebuilt: %d nodes, %d dependencies", - len(p.nodes), - len(p.dependencies), - ) - ) - - await controller.start() - - # Show current state - logger.info("--- Current State ---") - logger.info("Active nodes: %s", [n.name for n in controller.active_nodes]) - logger.info("Error nodes: %s", list(controller.error_nodes.keys())) - - while True: - await asyncio.sleep(1) - controller.pipeline.visualize() + def _publish(self, event: dict[str, Any]) -> None: + if self.events is not None: + self.events.publish(event) -if __name__ == "__main__": - try: - asyncio.run(main()) - except KeyboardInterrupt: - logger.info("Main process interrupted by user") +def _bound(specs: list[MessageSpec]) -> list[MessageSpec]: + """Ports without a message name are not wired into the graph.""" + return [spec for spec in specs if spec.name] + + +def _short_traceback(exc: Exception) -> str: + """The last frames of a failure, which is what a node author needs.""" + lines = traceback.format_exception(type(exc), exc, exc.__traceback__) + return "".join(lines[-3:]).strip() diff --git a/backend/app/flow/dag_generator.py b/backend/app/flow/dag_generator.py deleted file mode 100644 index a39a4e2..0000000 --- a/backend/app/flow/dag_generator.py +++ /dev/null @@ -1,218 +0,0 @@ -""" -DAG generation utilities for testing pipelines. - -This module provides functions to generate random directed acyclic graphs (DAGs) -with guaranteed properties like connectivity and no cycles. -""" - -from __future__ import annotations - -import networkx as nx -import numpy as np -from typing import Callable - -from util import Message, NodeParams -from nodes import Node, MLPNode - - -def generate_dag( - num_nodes: int, - edge_probability: float = 0.3, - seed: int | None = None, -) -> nx.DiGraph: - """ - Generate a random DAG using the Erdős-Rényi model with topological ordering. - - Creates edges only from lower-indexed to higher-indexed nodes to guarantee - acyclicity, then removes isolated nodes to ensure connectivity. - - :param num_nodes: Number of nodes in the graph. - :type num_nodes: int - :param edge_probability: Probability of edge between any two nodes. - :type edge_probability: float - :param seed: Random seed for reproducibility. - :type seed: int | None - :returns: A random DAG. - :rtype: nx.DiGraph - """ - rng = np.random.default_rng(seed) - G = nx.DiGraph() - G.add_nodes_from(range(num_nodes)) - - # Add edges only from lower to higher index (guarantees DAG) - for i in range(num_nodes): - for j in range(i + 1, num_nodes): - if rng.random() < edge_probability: - G.add_edge(i, j) - - # Ensure connectivity: connect isolated nodes - _ensure_connected(G, rng) - - return G - - -def _ensure_connected(G: nx.DiGraph, rng: np.random.Generator) -> None: - """ - Ensure the DAG is weakly connected by adding edges. - - :param G: The graph to modify in place. - :type G: nx.DiGraph - :param rng: Random number generator. - :type rng: np.random.Generator - """ - # Get weakly connected components - components = list(nx.weakly_connected_components(G)) - - if len(components) <= 1: - return - - # Sort nodes in each component by index for DAG-safe edge addition - sorted_components = [sorted(c) for c in components] - - # Connect components by adding edge from max of one to min of next - for i in range(len(sorted_components) - 1): - src = sorted_components[i][-1] # Last (highest) node in component - dst = sorted_components[i + 1][0] # First (lowest) node in next component - - # Ensure edge direction maintains DAG property - if src < dst: - G.add_edge(src, dst) - else: - G.add_edge(dst, src) - - -def dag_to_pipeline_nodes( - G: nx.DiGraph, - node_factory: Callable[..., Node] | None = None, - params: dict | None = None, - seed: int | None = None, -) -> tuple[list[Node], list[Message], list[Node]]: - """ - Convert a networkx DAG to pipeline nodes with messages. - - :param G: The DAG to convert. - :type G: nx.DiGraph - :param node_factory: Factory function to create nodes (default: MLPNode). - :type node_factory: Callable[..., Node] | None - :param params: Parameters to pass to node factory. - :type params: dict | None - :param seed: Random seed for reproducibility. - :type seed: int | None - :returns: Tuple of (nodes, all_messages, trigger_nodes). - :rtype: tuple[list[Node], list[Message], list[Node]] - """ - rng = np.random.default_rng(seed) - params = params or {} - - # Create a message for each edge - edge_messages: dict[tuple[int, int], Message] = {} - for src, dst in G.edges(): - edge_messages[(src, dst)] = Message(name=f"msg_{src}_{dst}") - - # Create nodes in topological order - nodes: list[Node] = [] - trigger_nodes: list[Node] = [] - - for node_id in nx.topological_sort(G): - # Inputs: messages from incoming edges - requires = [edge_messages[(src, node_id)] for src in G.predecessors(node_id)] - - # Outputs: messages for outgoing edges - provides = [edge_messages[(node_id, dst)] for dst in G.successors(node_id)] - - # Skip nodes with no outputs (sink nodes produce no messages) - # But we still need to create them to consume inputs - if node_factory: - node = node_factory( - requires=requires, - provides=provides, - params=params, - name=f"{node_id}", - ) - else: - node = MLPNode( - requires=requires, - provides=provides, - params=params, - name=f"{node_id}", - ) - - nodes.append(node) - - # Trigger nodes are sources (no inputs) - if len(requires) == 0: - trigger_nodes.append(node) - - all_messages = list(edge_messages.values()) - return nodes, all_messages, trigger_nodes - - -def generate_multi_pipeline_dag( - num_pipelines: int, - nodes_per_pipeline: int, - cross_pipeline_edges: int = 2, - edge_probability: float = 0.3, - seed: int | None = None, -) -> tuple[list[nx.DiGraph], nx.DiGraph]: - """ - Generate multiple DAGs that can be composed into a parent pipeline. - - Creates separate DAGs for each pipeline and adds cross-pipeline edges - to create dependencies between them. - - :param num_pipelines: Number of child pipelines. - :type num_pipelines: int - :param nodes_per_pipeline: Nodes in each pipeline. - :type nodes_per_pipeline: int - :param cross_pipeline_edges: Number of edges connecting pipelines. - :type cross_pipeline_edges: int - :param edge_probability: Edge probability within each pipeline. - :type edge_probability: float - :param seed: Random seed for reproducibility. - :type seed: int | None - :returns: Tuple of (list of pipeline DAGs, combined DAG). - :rtype: tuple[list[nx.DiGraph], nx.DiGraph] - """ - rng = np.random.default_rng(seed) - - pipeline_graphs: list[nx.DiGraph] = [] - combined = nx.DiGraph() - - # Generate each pipeline's DAG - for p in range(num_pipelines): - offset = p * nodes_per_pipeline - G = generate_dag( - nodes_per_pipeline, edge_probability, seed=rng.integers(0, 10000) - ) - - # Relabel nodes with offset - mapping = {n: n + offset for n in G.nodes()} - G = nx.relabel_nodes(G, mapping) - - # Add pipeline attribute - for n in G.nodes(): - G.nodes[n]["pipeline"] = p - - pipeline_graphs.append(G) - combined = nx.compose(combined, G) - - # Add cross-pipeline edges (from later nodes to earlier nodes of next pipeline) - for _ in range(cross_pipeline_edges): - p1, p2 = rng.choice(num_pipelines, size=2, replace=False) - if p1 > p2: - p1, p2 = p2, p1 - - # Get sink nodes from p1 (nodes with no outgoing edges within pipeline) - p1_nodes = [n for n in pipeline_graphs[p1].nodes()] - p1_sinks = [n for n in p1_nodes if pipeline_graphs[p1].out_degree(n) == 0] - - # Get source-ish nodes from p2 (nodes with few incoming edges) - p2_nodes = [n for n in pipeline_graphs[p2].nodes()] - p2_sources = [n for n in p2_nodes if combined.in_degree(n) <= 1] - - if p1_sinks and p2_sources: - src = rng.choice(p1_sinks) - dst = rng.choice(p2_sources) - combined.add_edge(src, dst) - - return pipeline_graphs, combined diff --git a/backend/app/flow/events.py b/backend/app/flow/events.py new file mode 100644 index 0000000..a7487ee --- /dev/null +++ b/backend/app/flow/events.py @@ -0,0 +1,64 @@ +"""Event bus bridging the engine's worker threads to async subscribers. + +Nodes execute in a thread pool; websocket clients live on the event loop. +Publishers are therefore thread-safe and never block: a subscriber that cannot +keep up loses its oldest queued events rather than stalling the engine. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +logger = logging.getLogger(__name__) + +QUEUE_SIZE = 256 + + +class EventBus: + """Fan-out of engine events to any number of async subscribers.""" + + def __init__(self) -> None: + self._loop: asyncio.AbstractEventLoop | None = None + self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set() + + def bind(self, loop: asyncio.AbstractEventLoop) -> None: + """Attach the bus to the running event loop (called once at startup).""" + self._loop = loop + + def publish(self, event: dict[str, Any]) -> None: + """Publish an event from any thread.""" + loop = self._loop + if loop is None or not self._subscribers: + return + try: + loop.call_soon_threadsafe(self._dispatch, event) + except RuntimeError: + # Loop already closed — shutting down. + pass + + def _dispatch(self, event: dict[str, Any]) -> None: + for queue in self._subscribers: + if queue.full(): + # Drop the oldest so a slow client never blocks the engine. + try: + queue.get_nowait() + except asyncio.QueueEmpty: + pass + queue.put_nowait(event) + + @asynccontextmanager + async def subscribe(self) -> AsyncIterator[asyncio.Queue[dict[str, Any]]]: + """Yield a queue receiving every event published while subscribed.""" + queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=QUEUE_SIZE) + self._subscribers.add(queue) + try: + yield queue + finally: + self._subscribers.discard(queue) + + +event_bus = EventBus() diff --git a/backend/app/flow/messages.py b/backend/app/flow/messages.py new file mode 100644 index 0000000..1947ebd --- /dev/null +++ b/backend/app/flow/messages.py @@ -0,0 +1,112 @@ +"""Message specifications: the typed contract between nodes. + +A node declares ports; each port binds to a message name. The message name is +the wiring: a node consuming ``heating.temperature`` receives whatever any node +provides under that name. Names are namespaced per flow — a bare name is +qualified with the owning flow (``temperature`` in flow ``heating`` becomes +``heating.temperature``), a dotted name is used as written, which is how flows +consume each other's messages. +""" + +from __future__ import annotations + +import json +from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, model_validator + + +class DType(str, Enum): + """Serializable payload types. + + Binary payloads (tensors, images) will arrive later as explicitly declared + codec fields; until then everything on the wire is JSON. + """ + + FLOAT = "float" + INT = "int" + STR = "str" + BOOL = "bool" + JSON = "json" + + +_JSON_TYPES = (dict, list, str, int, float, bool, type(None)) + + +class MessageSpec(BaseModel): + """A single port of a node, and the message it is bound to. + + :param name: The message this port binds to. Bare names are qualified with + the flow name at load time; empty means the port is unbound. + :param port: The identifier the node function sees. Defaults to the last + segment of ``name``, so unqualified flows read naturally. + :param dtype: Payload type, validated on every message that passes through. + """ + + model_config = ConfigDict(frozen=True) + + name: str = "" + port: str = "" + dtype: DType = DType.FLOAT + + @model_validator(mode="after") + def _default_port(self) -> MessageSpec: + if not self.port and self.name: + object.__setattr__(self, "port", self.name.rsplit(".", 1)[-1]) + return self + + def check(self, value: Any) -> None: + """Raise if ``value`` does not match this port's declared type.""" + if self.dtype is DType.BOOL: + ok = isinstance(value, bool) + elif self.dtype is DType.INT: + # bool is an int subclass; a flag is not a number here. + ok = isinstance(value, int) and not isinstance(value, bool) + elif self.dtype is DType.FLOAT: + ok = isinstance(value, (int, float)) and not isinstance(value, bool) + elif self.dtype is DType.STR: + ok = isinstance(value, str) + else: + ok = isinstance(value, _JSON_TYPES) + if not ok: + raise TypeError( + f"{self.name or self.port}: expected {self.dtype.value}, " + f"got {type(value).__name__}" + ) + + def coerce(self, value: Any) -> Any: + """Best-effort conversion of an external value into this port's type. + + Used where payloads arrive as text (HTTP query strings, MQTT), never on + the path between nodes — there a wrong type is an error, not a hint. + """ + if self.dtype is DType.FLOAT: + return float(value) + if self.dtype is DType.INT: + return int(value) + if self.dtype is DType.BOOL: + if isinstance(value, bool): + return value + return str(value).lower() in ("true", "1", "yes", "on") + if self.dtype is DType.STR: + return value if isinstance(value, str) else json.dumps(value) + return value + + def __repr__(self) -> str: + return f"MessageSpec({self.name or self.port})" + + def __hash__(self) -> int: + return hash((self.name, self.port)) + + +def qualify(flow: str, name: str) -> str: + """Resolve a message name against its flow namespace.""" + if not name: + return "" + return name if "." in name else f"{flow}.{name}" + + +def flow_of(qualified: str) -> str: + """The flow a qualified message name belongs to.""" + return qualified.split(".", 1)[0] diff --git a/backend/app/flow/nodes.py b/backend/app/flow/nodes.py index e585641..6371d98 100644 --- a/backend/app/flow/nodes.py +++ b/backend/app/flow/nodes.py @@ -2,23 +2,23 @@ from __future__ import annotations import asyncio import logging -import numpy as np -from util import Message, NodeParams -from typing import TYPE_CHECKING, Callable, Any, Literal import threading import time -import httpx +from collections.abc import Callable, Iterable from enum import Enum +from typing import TYPE_CHECKING, Any, Literal + +import httpx +import numpy as np +from pydantic import BaseModel, ConfigDict + +from app.flow.messages import MessageSpec, qualify if TYPE_CHECKING: - from pipeline import Pipeline from fastapi import FastAPI -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) + from app.flow.pipeline import Pipeline + logger = logging.getLogger(__name__) @@ -35,12 +35,12 @@ class Node: :type f: Callable :param requires: Input messages this node consumes. Can be a single Message or list of Messages. Empty for source nodes. - :type requires: Message | list[Message] + :type requires: MessageSpec | list[MessageSpec] :param provides: Output messages this node produces. Can be a single Message or list of Messages. - :type provides: Message | list[Message] + :type provides: MessageSpec | list[MessageSpec] :param params: Additional parameters passed to the function during execution. - :type params: NodeParams + :type params: dict :param name: Optional name for the node. Defaults to function name. :type name: str | None @@ -54,8 +54,8 @@ class Node: >>> >>> temp_node = Node( ... f=process_temp, - ... requires=Message(name="temperature", dtype=float), - ... provides=Message(name="celsius", dtype=float), + ... requires=MessageSpec(name="temperature", dtype=DType.FLOAT), + ... provides=MessageSpec(name="celsius", dtype=DType.FLOAT), ... params={}, ... ) """ @@ -63,7 +63,10 @@ class Node: __slots__ = ( "f", "id", + "flow", "name", + "input_ports", + "output_ports", "requires", "provides", "params", @@ -74,38 +77,60 @@ class Node: def __init__( self, f: Callable, - requires: Message | list[Message], - provides: Message | list[Message], - params: NodeParams, - name: str = None, + requires: MessageSpec | Iterable[MessageSpec] = (), + provides: MessageSpec | Iterable[MessageSpec] = (), + params: dict[str, Any] | None = None, + name: str | None = None, ): self.f = f - self.id = id(f) self._pipeline: Pipeline | None = None - self.name = name or f.__name__ - self.synchronous = params.get("synchronous", False) + self.params = dict(params) if params else {} + self.synchronous = bool(self.params.get("synchronous", False)) - # Normalize to dict[name, Message] - self.requires = self._normalize_messages(requires) - self.provides = self._normalize_messages(provides) - self.params = params + self.input_ports = self._normalize_ports(requires) + self.output_ports = self._normalize_ports(provides) + + self.flow = "" + self.name = name or getattr(f, "__name__", "node") + self.id = self.name + self._index_ports() @staticmethod - def _normalize_messages(msgs: Message | list[Message]) -> dict[str, Message]: - """ - Convert Message or list of Messages to dict keyed by name. + def _normalize_ports( + msgs: MessageSpec | Iterable[MessageSpec], + ) -> list[MessageSpec]: + """Accept a single spec or any iterable of them.""" + if isinstance(msgs, MessageSpec): + return [msgs] + return list(msgs or ()) - Provides a consistent internal representation for both single messages - and lists of messages. + def _index_ports(self) -> None: + """Index bound ports by message name; unbound ports have no wiring.""" + self.requires = {s.name: s for s in self.input_ports if s.name} + self.provides = {s.name: s for s in self.output_ports if s.name} - :param msgs: Single Message or list of Messages to normalize. - :type msgs: Message | list[Message] - :returns: Dictionary mapping message names to Message objects. - :rtype: dict[str, Message] + def assign_flow(self, flow: str, node_id: str) -> None: + """Place this node in a flow, qualifying its identity and messages. + + Called once by the loader, before the pipeline is built. """ - if isinstance(msgs, Message): - return {msgs.name: msgs} - return {m.name: m for m in msgs} + self.flow = flow + self.name = node_id + self.id = f"{flow}.{node_id}" + self.input_ports = [ + s.model_copy(update={"name": qualify(flow, s.name)}) + for s in self.input_ports + ] + self.output_ports = [ + s.model_copy(update={"name": qualify(flow, s.name)}) + for s in self.output_ports + ] + self._index_ports() + + @property + def local_id(self) -> str: + """The node's id within its flow.""" + return self.name def bind(self, pipeline: Pipeline) -> None: """ @@ -120,10 +145,48 @@ class Node: self._pipeline = pipeline def __repr__(self) -> str: - return self.name + return self.id def __hash__(self) -> int: - return self.id + return hash(self.id) + + # ------------------------------------------------------------------------- + # Port translation + # + # The graph speaks qualified message names; node functions speak ports. + # ------------------------------------------------------------------------- + + def _to_kwargs(self, inputs: dict) -> dict: + kwargs = {} + for msg_name, value in inputs.items(): + spec = self.requires.get(msg_name) + if spec is None: + continue + spec.check(value) + kwargs[spec.port] = value + return kwargs + + def _to_messages(self, retval: dict | None) -> dict | None: + """Map a function's port-keyed return value onto message names.""" + if not retval: + return None + by_port = {s.port: s for s in self.output_ports if s.name} + outputs = {} + for key, value in retval.items(): + spec = by_port.get(key) or self.provides.get(key) + if spec is None: + continue + spec.check(value) + outputs[spec.name] = value + return outputs or None + + def execute(self, inputs: dict | None = None) -> dict | None: + """Run the node function and return its outputs by message name. + + Downstream nodes are not triggered — the pipeline schedules those. + """ + kwargs = self._to_kwargs(inputs or {}) + return self._to_messages(self.f(**kwargs, params=self.params)) def trigger(self, inputs: dict | None = None) -> dict | None: """ @@ -166,23 +229,11 @@ class Node: outputs = outputs or {} - # For source nodes with no provided outputs, execute the function - # A source node has no requires (empty dict after normalization) - is_source_node = len(self.requires) == 0 - has_outputs = len(outputs) > 0 - - if not has_outputs and is_source_node: + # A source node asked to inject nothing produces its own data. + if not outputs and not self.requires: outputs = self.f(params=self.params) or {} - # Validate outputs against provides (strict mode) - validated_outputs = {} - for msg_name, msg in self.provides.items(): - if msg_name in outputs: - msg.check(outputs[msg_name]) # Raises TypeError if invalid - validated_outputs[msg_name] = outputs[msg_name] - - # Trigger downstream nodes with the validated outputs - return self._pipeline.trigger(self, validated_outputs) + return self._pipeline.trigger(self, self._to_messages(outputs)) def __call__(self, inputs: dict | None = None) -> dict | None: """ @@ -198,26 +249,8 @@ class Node: :returns: Node outputs if successful, or pipeline execution results if bound. :rtype: dict | None """ - inputs = inputs or {} - - # Validate inputs and build kwargs - input_args = { - k: (self.requires[k].check(v), v)[1] - for k, v in inputs.items() - if k in self.requires - } - - # Execute function - retval = self.f(**input_args, params=self.params) - - # Validate outputs - if retval and self.provides: - for k, v in retval.items(): - if k in self.provides: - self.provides[k].check(v) - - # Trigger downstream if bound to pipeline - return self._pipeline.trigger(self, retval) if self._pipeline else retval + outputs = self.execute(inputs) + return self._pipeline.trigger(self, outputs) if self._pipeline else outputs class MLPNode(Node): @@ -232,13 +265,13 @@ class MLPNode(Node): output = weights @ inputs + biases :param requires: Input messages consumed by this node. - :type requires: Message | list[Message] + :type requires: MessageSpec | list[MessageSpec] :param provides: Output messages produced by this node. - :type provides: Message | list[Message] + :type provides: MessageSpec | list[MessageSpec] :param params: Parameters dict containing: - ``rng`` (numpy.random.Generator): Random number generator for weight initialization - Additional node parameters - :type params: NodeParams + :type params: dict :param name: Name for this node. :type name: str @@ -246,80 +279,60 @@ class MLPNode(Node): >>> import numpy as np >>> rng = np.random.default_rng(seed=42) >>> mlp = MLPNode( - ... requires=[Message(name="input1", dtype=float), Message(name="input2", dtype=float)], - ... provides=[Message(name="output", dtype=float)], + ... requires=[MessageSpec(name="input1", dtype=DType.FLOAT), MessageSpec(name="input2", dtype=DType.FLOAT)], + ... provides=[MessageSpec(name="output", dtype=DType.FLOAT)], ... params={"rng": rng}, ... name="mlp_layer1", ... ) """ + class Params(BaseModel): + """Weights are drawn from ``seed``, so a node reloads identically.""" + + model_config = ConfigDict(extra="allow") + + seed: int = 0 + def __init__( self, - requires: Message | list[Message], - provides: Message | list[Message], - params: NodeParams, - name: str, + requires: MessageSpec | Iterable[MessageSpec] = (), + provides: MessageSpec | Iterable[MessageSpec] = (), + params: dict[str, Any] | None = None, + name: str | None = None, ): - # Create a copy of params to avoid mutating the original - node_params = dict(params) - - # Initialize weights only if we have inputs and outputs - num_inputs = max(1, len(requires)) # At least 1 for bias-only nodes - num_outputs = max(1, len(provides)) - - node_params["weights"] = params["rng"].normal( - loc=1, size=(num_outputs, num_inputs) - ) - node_params["biases"] = params["rng"].normal(loc=0, size=(num_outputs,)) - node_params["requires"] = requires - node_params["provides"] = provides - super().__init__( - MLPNode.f, + self._forward, requires=requires, provides=provides, - params=node_params, - name=name, + params=params, + name=name or "mlp", ) + cfg = self.Params.model_validate(self.params) - @staticmethod - def f(params, **kwargs): - """ - Execute the MLP node with one hidden layer. + rng = np.random.default_rng(seed=cfg.seed) + num_inputs = max(1, len(self.input_ports)) + num_outputs = max(1, len(self.output_ports)) + self.weights = rng.normal(loc=1, size=(num_outputs, num_inputs)) + self.biases = rng.normal(loc=0, size=(num_outputs,)) - Applies weights and biases to input values and returns computed outputs. - - :param params: Node parameters including weights, biases, requires, and provides. - :type params: dict - :param kwargs: Input values from required messages. - :type kwargs: Any - :returns: Dictionary of output values for provided messages. - :rtype: dict | None - """ - req_keys = params["requires"] - prov_keys = params["provides"] - - logger.info( - "Executing MLP node in thread %s: %s -> %s", - threading.current_thread().name, - req_keys, - prov_keys, - ) - - # Handle case with no outputs (sink node) - if not prov_keys: + def _forward(self, params: dict, **kwargs) -> dict | None: + """Apply ``weights @ inputs + biases`` to the incoming values.""" + if not self.output_ports: return None - # Convert args to numpy array for matrix multiplication + logger.info( + "Executing MLP node in thread %s: %s", + threading.current_thread().name, + self.id, + ) + if kwargs: - input_array = np.array([float(v) for k, v in kwargs.items()]) + input_array = np.array([float(v) for v in kwargs.values()]) else: - input_array = np.array([1.0]) # Bias-only for source nodes + input_array = np.array([1.0]) # Bias only, for source nodes. - # Compute outputs - outputs = np.dot(params["weights"], input_array) + params["biases"] - - return {p.name: float(outputs[i]) for i, p in enumerate(prov_keys)} + outputs = np.dot(self.weights, input_array) + self.biases + return {p.port: float(outputs[i]) for i, p in enumerate(self.output_ports)} class HttpNode(Node): @@ -340,9 +353,9 @@ class HttpNode(Node): :param method: HTTP method - "GET" or "POST". :type method: Literal["GET", "POST"] :param requires: Messages required by this node (makes it a sender node). - :type requires: Message | list[Message] | None + :type requires: MessageSpec | list[MessageSpec] | None :param provides: Messages provided by this node (makes it a trigger node). - :type provides: Message | list[Message] | None + :type provides: MessageSpec | list[MessageSpec] | None :param params: Additional parameters for the node. :type params: dict :param name: Optional name for the node. @@ -361,7 +374,7 @@ class HttpNode(Node): >>> trigger = HttpNode( ... url="/api/sensors/temperature", ... method="POST", - ... provides=[Message(name="temperature", dtype=float)], + ... provides=[MessageSpec(name="temperature", dtype=DType.FLOAT)], ... params={}, ... ) @@ -370,7 +383,7 @@ class HttpNode(Node): >>> sender = HttpNode( ... url="https://api.example.com/data", ... method="POST", - ... requires=[Message(name="temperature", dtype=float)], + ... requires=[MessageSpec(name="temperature", dtype=DType.FLOAT)], ... params={}, ... ) """ @@ -391,43 +404,50 @@ class HttpNode(Node): "_http_client", ) + class Params(BaseModel): + model_config = ConfigDict(extra="allow") + + url: str + method: Literal["GET", "POST"] = "POST" + timeout: float = 30.0 + headers: dict[str, str] = {} + def __init__( self, - url: str, - method: Literal["GET", "POST"] = "POST", - requires: Message | list[Message] = [], - provides: Message | list[Message] = [], - params: NodeParams = {}, + url: str | None = None, + method: Literal["GET", "POST"] | None = None, + requires: MessageSpec | Iterable[MessageSpec] = (), + provides: MessageSpec | Iterable[MessageSpec] = (), + params: dict[str, Any] | None = None, name: str | None = None, ): - # Make a copy to avoid mutating the original and sharing state between nodes params = dict(params) if params else {} + if url is not None: + params["url"] = url + if method is not None: + params["method"] = method + cfg = self.Params.model_validate(params) - # Determine mode based on requires/provides - has_requires = isinstance(requires, Message) or len(requires) > 0 - has_provides = isinstance(provides, Message) or len(provides) > 0 - - if not has_requires and not has_provides: + requires = Node._normalize_ports(requires) + provides = Node._normalize_ports(provides) + if not requires and not provides: raise ValueError( - "HttpNode must have either 'requires' (sender) or 'provides' (trigger)" + "An HTTP node needs either inputs (to send) or outputs (to receive)" ) - # Determine mode: trigger if only provides, sender if has requires - if has_requires: - self.mode = HttpNode.Mode.SENDER - else: - self.mode = HttpNode.Mode.TRIGGER + # Inputs mean this node sends data out; outputs mean it receives. + self.mode = HttpNode.Mode.SENDER if requires else HttpNode.Mode.TRIGGER - self.url = url - self.method = method.upper() - self.timeout = params.get("timeout", 30.0) - self.headers = params.get("headers", {}) + self.url = cfg.url + self.method = cfg.method.upper() + self.timeout = cfg.timeout + self.headers = cfg.headers self._route_registered = False self._http_client: httpx.AsyncClient | None = None # Set default name based on mode and URL if name is None: - safe_url = url.replace("/", "_").replace(":", "").strip("_") + safe_url = self.url.replace("/", "_").replace(":", "").strip("_") name = f"http_{self.mode.value}_{safe_url}" # Initialize parent with appropriate function @@ -473,10 +493,7 @@ class HttpNode(Node): try: loop = asyncio.get_event_loop() if loop.is_running(): - # We're in an async context, create a task - future = asyncio.ensure_future(self._send_request(kwargs)) - # This is tricky - we need to handle this properly - # For now, use a thread to run the async code + # Already on an event loop, so run the request on its own. import concurrent.futures with concurrent.futures.ThreadPoolExecutor() as executor: @@ -530,7 +547,7 @@ class HttpNode(Node): logger.error("Request error in node '%s': %s", self.name, e) raise - def register_route(self, app: "FastAPI") -> None: + def register_route(self, app: FastAPI) -> None: """ Register this node's HTTP endpoint with a FastAPI application. @@ -547,7 +564,7 @@ class HttpNode(Node): >>> trigger_node = HttpNode( ... url="/sensors/data", ... method="POST", - ... provides=[Message(name="value", dtype=float)], + ... provides=[MessageSpec(name="value", dtype=DType.FLOAT)], ... params={}, ... ) >>> trigger_node.register_route(app) @@ -585,24 +602,11 @@ class HttpNode(Node): else: data = await request.json() # Default to JSON - # Convert string values to appropriate types based on message definitions + # Payload keys are port names; values arrive as text. typed_data = {} - for msg_name, msg in self.provides.items(): - if msg_name in data: - value = data[msg_name] - # Type conversion - if msg.dtype == float: - typed_data[msg_name] = float(value) - elif msg.dtype == int: - typed_data[msg_name] = int(value) - elif msg.dtype == bool: - typed_data[msg_name] = ( - value - if isinstance(value, bool) - else str(value).lower() in ("true", "1", "yes") - ) - else: - typed_data[msg_name] = value + for spec in self.output_ports: + if spec.port in data: + typed_data[spec.port] = spec.coerce(data[spec.port]) # Inject data into the pipeline (trigger mode nodes inject via provides) result = self.inject(typed_data) @@ -632,7 +636,7 @@ class HttpNode(Node): self.url, handle_request, methods=[self.method], - name=self.name, + name=self.id, ) app.routes.append(route) @@ -644,7 +648,7 @@ class HttpNode(Node): self.url, ) - def unregister_route(self, app: "FastAPI") -> None: + def unregister_route(self, app: FastAPI) -> None: """ Unregister this node's HTTP endpoint from a FastAPI application. @@ -665,7 +669,7 @@ class HttpNode(Node): app.routes[:] = [ route for route in app.routes - if not (hasattr(route, "name") and route.name == self.name) + if not (hasattr(route, "name") and route.name == self.id) ] self._route_registered = False @@ -693,9 +697,9 @@ class MqttNode(Node): one topic (subscriber receives from it, publisher sends to it). :param requires: Messages required by this node (makes it a publisher node). - :type requires: Message | list[Message] | None + :type requires: MessageSpec | list[MessageSpec] | None :param provides: Messages provided by this node (makes it a subscriber node). - :type provides: Message | list[Message] | None + :type provides: MessageSpec | list[MessageSpec] | None :param params: Parameters dict containing: - ``topic`` (str | dict): MQTT topic(s). A dict maps message names to individual topics. A plain string uses that topic for all messages. @@ -718,8 +722,8 @@ class MqttNode(Node): >>> subscriber = MqttNode( ... provides=[ - ... Message(name="inverter_input", dtype=float), - ... Message(name="inverter_output", dtype=float), + ... MessageSpec(name="inverter_input", dtype=DType.FLOAT), + ... MessageSpec(name="inverter_output", dtype=DType.FLOAT), ... ], ... params={ ... "topic": { @@ -734,8 +738,8 @@ class MqttNode(Node): >>> publisher = MqttNode( ... requires=[ - ... Message(name="target_temp", dtype=float), - ... Message(name="fan_speed", dtype=int), + ... MessageSpec(name="target_temp", dtype=DType.FLOAT), + ... MessageSpec(name="fan_speed", dtype=DType.INT), ... ], ... params={ ... "topic": { @@ -750,7 +754,7 @@ class MqttNode(Node): Legacy single-topic subscriber: >>> subscriber = MqttNode( - ... provides=[Message(name="temperature", dtype=float)], + ... provides=[MessageSpec(name="temperature", dtype=DType.FLOAT)], ... params={"topic": "sensors/temperature", "broker_host": "localhost"}, ... ) """ @@ -778,71 +782,59 @@ class MqttNode(Node): "_stop_event", ) + class Params(BaseModel): + model_config = ConfigDict(extra="allow") + + # One topic for every port, or a per-port mapping. + topic: str | dict[str, str] = "*" + broker_host: str = "localhost" + broker_port: int = 1883 + username: str | None = None + password: str | None = None + client_id: str | None = None + qos: int = 0 + retain: bool = False + keepalive: int = 60 + def __init__( self, - requires: Message | list[Message] = [], - provides: Message | list[Message] = [], - params: dict | None = None, + requires: MessageSpec | Iterable[MessageSpec] = (), + provides: MessageSpec | Iterable[MessageSpec] = (), + params: dict[str, Any] | None = None, name: str | None = None, ): - # Make a copy to avoid mutating the original and sharing state between nodes - params = dict(params) if params else {} + cfg = self.Params.model_validate(params or {}) - # Determine mode based on requires/provides - has_requires = isinstance(requires, Message) or len(requires) > 0 - has_provides = isinstance(provides, Message) or len(provides) > 0 - - if not has_requires and not has_provides: + requires = Node._normalize_ports(requires) + provides = Node._normalize_ports(provides) + if not requires and not provides: raise ValueError( - "MqttNode must have either 'requires' (publisher) or 'provides' (subscriber)" + "An MQTT node needs either inputs (to publish) or outputs " + "(to subscribe)" ) - # Determine mode: subscriber if only provides, publisher if has requires - if has_requires: - self.mode = MqttNode.Mode.PUBLISHER + # Inputs mean this node publishes; outputs mean it subscribes. + self.mode = MqttNode.Mode.PUBLISHER if requires else MqttNode.Mode.SUBSCRIBER + + ports = provides if self.mode == MqttNode.Mode.SUBSCRIBER else requires + if isinstance(cfg.topic, dict): + self.topics: dict[str, str] = dict(cfg.topic) else: - self.mode = MqttNode.Mode.SUBSCRIBER + self.topics = {spec.port: cfg.topic for spec in ports} - # Build the message→topic mapping - raw_topic = params.get("topic", "*") - msg_names: list[str] - if self.mode == MqttNode.Mode.SUBSCRIBER: - # Normalise provides into a list of names - if isinstance(provides, Message): - msg_names = [provides.name] - elif isinstance(provides, list): - msg_names = [m.name if isinstance(m, Message) else m for m in provides] - else: - msg_names = list(provides) if provides else [] - else: - if isinstance(requires, Message): - msg_names = [requires.name] - elif isinstance(requires, list): - msg_names = [m.name if isinstance(m, Message) else m for m in requires] - else: - msg_names = list(requires) if requires else [] + # Reverse lookup for routing incoming payloads back to ports. + self._topic_to_ports: dict[str, list[str]] = {} + for port, topic in self.topics.items(): + self._topic_to_ports.setdefault(topic, []).append(port) - if isinstance(raw_topic, dict): - # Explicit mapping: message_name → topic - self.topics: dict[str, str] = dict(raw_topic) - else: - # Legacy single-topic string: every message uses the same topic - self.topics = {msg: raw_topic for msg in msg_names} - - # Build reverse lookup: topic → list of message names - self._topic_to_messages: dict[str, list[str]] = {} - for msg, topic in self.topics.items(): - self._topic_to_messages.setdefault(topic, []).append(msg) - - # MQTT connection parameters from params - self.broker_host = params.get("broker_host", "localhost") - self.broker_port = params.get("broker_port", 1883) - self.username = params.get("username") - self.password = params.get("password") - self.client_id = params.get("client_id") - self.qos = params.get("qos", 0) - self.retain = params.get("retain", False) - self.keepalive = params.get("keepalive", 60) + self.broker_host = cfg.broker_host + self.broker_port = cfg.broker_port + self.username = cfg.username + self.password = cfg.password + self.client_id = cfg.client_id + self.qos = cfg.qos + self.retain = cfg.retain + self.keepalive = cfg.keepalive # Runtime state self._subscription_task: asyncio.Task | None = None @@ -911,7 +903,6 @@ class MqttNode(Node): :returns: None (publishing is fire-and-forget). :rtype: dict | None """ - import json # Run async publish in sync context try: @@ -938,10 +929,11 @@ class MqttNode(Node): from the ``topics`` mapping. Messages are sent as individual JSON payloads per topic. - :param data: Data to publish, keyed by message name. + :param data: Data to publish, keyed by port name. :type data: dict """ import json + import aiomqtt try: @@ -953,12 +945,12 @@ class MqttNode(Node): identifier=self.client_id, keepalive=self.keepalive, ) as client: - for msg_name, value in data.items(): - topic = self.topics.get(msg_name) + for port, value in data.items(): + topic = self.topics.get(port) if topic is None: logger.warning( - "No topic mapping for message '%s' in node '%s', skipping", - msg_name, + "No topic mapping for port '%s' in node '%s', skipping", + port, self.name, ) continue @@ -993,7 +985,7 @@ class MqttNode(Node): :example: >>> subscriber = MqttNode( ... topic="sensors/#", - ... provides=[Message(name="value", dtype=float)], + ... provides=[MessageSpec(name="value", dtype=DType.FLOAT)], ... params={"broker_host": "localhost"}, ... ) >>> await subscriber.start_subscription() @@ -1046,6 +1038,7 @@ class MqttNode(Node): payloads to the correct pipeline message names. """ import json + import aiomqtt while not (self._stop_event and self._stop_event.is_set()): @@ -1078,9 +1071,9 @@ class MqttNode(Node): payload, ) - # Find which message name(s) this topic maps to - msg_names = self._topic_to_messages.get(incoming_topic, []) - if not msg_names: + # Find which port(s) this topic feeds + ports = self._topic_to_ports.get(incoming_topic, []) + if not ports: logger.debug( "[%s] No mapping for topic '%s', ignoring", self.name, @@ -1094,35 +1087,24 @@ class MqttNode(Node): except json.JSONDecodeError: parsed = payload - # Type-convert and build typed_data for each mapped message + by_port = {s.port: s for s in self.output_ports} typed_data = {} - for msg_name in msg_names: - msg = self.provides.get(msg_name) - if msg is None: + for port in ports: + spec = by_port.get(port) + if spec is None: continue - # If parsed is a dict and contains the msg_name key, use that - if isinstance(parsed, dict) and msg_name in parsed: - value = parsed[msg_name] + # A JSON object may carry the port as a key; + # anything else is the value itself. + if isinstance(parsed, dict) and port in parsed: + value = parsed[port] else: value = parsed - # Type conversion - if msg.dtype == float: - typed_data[msg_name] = float(value) - elif msg.dtype == int: - typed_data[msg_name] = int(value) - elif msg.dtype == bool: - typed_data[msg_name] = ( - value - if isinstance(value, bool) - else str(value).lower() in ("true", "1", "yes") - ) - else: - typed_data[msg_name] = value + typed_data[port] = spec.coerce(value) if typed_data: - self.inject(typed_data) + await asyncio.to_thread(self.inject, typed_data) except Exception as e: logger.error( @@ -1183,10 +1165,10 @@ class InfluxDbNode(Node): :param requires: Messages to write to InfluxDB. The actual value from each message is written according to the corresponding config in ``writes``. - :type requires: Message | list[Message] | None + :type requires: MessageSpec | list[MessageSpec] | None :param provides: Messages to read from InfluxDB. Each message gets its value from a query defined in ``queries``. - :type provides: Message | list[Message] | None + :type provides: MessageSpec | list[MessageSpec] | None :param params: Parameters dict containing: - ``url`` (str): InfluxDB server URL (required) - ``token`` (str): Authentication token (required) @@ -1214,7 +1196,7 @@ class InfluxDbNode(Node): Write-only node (writes temperature values): >>> writer = InfluxDbNode( - ... requires=[Message(name="temperature", dtype=float)], + ... requires=[MessageSpec(name="temperature", dtype=DType.FLOAT)], ... params={ ... "url": "http://localhost:8086", ... "token": "my-token", @@ -1233,7 +1215,7 @@ class InfluxDbNode(Node): Read-only node (queries average temperature): >>> reader = InfluxDbNode( - ... provides=[Message(name="avg_temperature", dtype=float)], + ... provides=[MessageSpec(name="avg_temperature", dtype=DType.FLOAT)], ... params={ ... "url": "http://localhost:8086", ... "token": "my-token", @@ -1254,8 +1236,8 @@ class InfluxDbNode(Node): Combined read/write node: >>> node = InfluxDbNode( - ... requires=[Message(name="raw_temp", dtype=float)], - ... provides=[Message(name="avg_temp", dtype=float)], + ... requires=[MessageSpec(name="raw_temp", dtype=DType.FLOAT)], + ... provides=[MessageSpec(name="avg_temp", dtype=DType.FLOAT)], ... params={ ... "url": "http://localhost:8086", ... "token": "my-token", @@ -1293,40 +1275,43 @@ class InfluxDbNode(Node): "_query_client", ) + class Params(BaseModel): + model_config = ConfigDict(extra="allow") + + url: str + token: str + org: str + bucket: str + write_precision: str = "ms" + query_range: str = "-1h" + # Per-port write and query configuration. + writes: dict[str, dict[str, Any]] = {} + queries: dict[str, dict[str, Any]] = {} + def __init__( self, - requires: Message | list[Message] = [], - provides: Message | list[Message] = [], - params: dict | None = None, + requires: MessageSpec | Iterable[MessageSpec] = (), + provides: MessageSpec | Iterable[MessageSpec] = (), + params: dict[str, Any] | None = None, name: str | None = None, ): - # Make a copy to avoid mutating the original and sharing state between nodes - params = dict(params) if params else {} + cfg = self.Params.model_validate(params or {}) - # Validate required params - required_params = ["url", "token", "org", "bucket"] - missing = [p for p in required_params if p not in params] - if missing: - raise ValueError(f"InfluxDbNode missing required params: {missing}") - - # Determine if we have any operations - has_requires = isinstance(requires, Message) or len(requires) > 0 - has_provides = isinstance(provides, Message) or len(provides) > 0 - - if not has_requires and not has_provides: + requires = Node._normalize_ports(requires) + provides = Node._normalize_ports(provides) + if not requires and not provides: raise ValueError( - "InfluxDbNode must have either 'requires' (write) or 'provides' (read)" + "An InfluxDB node needs either inputs (to write) or outputs (to read)" ) - # Store InfluxDB connection params - self.url = params["url"] - self.token = params["token"] - self.org = params["org"] - self.bucket = params["bucket"] - self.write_precision = params.get("write_precision", "ms") - self.query_range = params.get("query_range", "-1h") - self.writes = params.get("writes", {}) - self.queries = params.get("queries", {}) + self.url = cfg.url + self.token = cfg.token + self.org = cfg.org + self.bucket = cfg.bucket + self.write_precision = cfg.write_precision + self.query_range = cfg.query_range + self.writes = cfg.writes + self.queries = cfg.queries # Lazy-initialized clients self._write_client = None @@ -1378,7 +1363,7 @@ class InfluxDbNode(Node): Each config specifies the measurement, field, and tags. The actual value comes from the incoming data. - :param data: Data to write, keyed by message name. Values can be: + :param data: Data to write, keyed by port name. Values can be: - Simple values (float, int, str, bool): Written using config from ``writes`` - List of values: Each value written as a separate point - Dict with "value" key: Value extracted and written using config @@ -1477,7 +1462,7 @@ class InfluxDbNode(Node): """ Query data from InfluxDB based on provides configuration. - :returns: Dict of message name to queried value. + :returns: Dict of port name to queried value. :rtype: dict """ from influxdb_client import InfluxDBClient @@ -1488,8 +1473,8 @@ class InfluxDbNode(Node): with InfluxDBClient(url=self.url, token=self.token, org=self.org) as client: query_api = client.query_api() - for msg_name, msg in self.provides.items(): - # Get query configuration for this message + for spec in self.output_ports: + msg_name = spec.port query_config = self.queries.get(msg_name, {}) measurement = query_config.get("measurement", msg_name) @@ -1518,7 +1503,7 @@ class InfluxDbNode(Node): tables = query_api.query(flux_query, org=self.org) # Extract result - value = self._extract_query_result(tables, msg.dtype) + value = self._extract_query_result(tables, spec) if value is not None: results[msg_name] = value @@ -1598,33 +1583,22 @@ class InfluxDbNode(Node): return "\n".join(query_parts) - def _extract_query_result(self, tables, dtype: type) -> Any: + def _extract_query_result(self, tables, spec: MessageSpec) -> Any: """ Extract a single value from query result tables. :param tables: InfluxDB query result tables. - :param dtype: Expected data type. - :type dtype: type + :param spec: The port the value is destined for. + :type spec: MessageSpec :returns: Extracted and typed value, or None if no data. :rtype: Any """ for table in tables: for record in table.records: value = record.get_value() - - # Type conversion if value is not None: try: - if dtype == float: - return float(value) - elif dtype == int: - return int(value) - elif dtype == bool: - return bool(value) - elif dtype == str: - return str(value) - else: - return value + return spec.coerce(value) except (ValueError, TypeError): return value @@ -1646,29 +1620,13 @@ class InfluxDbNode(Node): if self._pipeline is None: raise RuntimeError("Node must be bound to a pipeline to inject") - # If outputs are provided, validate and use them - if outputs: - validated_outputs = {} - for msg_name, msg in self.provides.items(): - if msg_name in outputs: - msg.check(outputs[msg_name]) - validated_outputs[msg_name] = outputs[msg_name] - return self._pipeline.trigger(self, validated_outputs) + # Without given values, injecting means running the configured queries. + if not outputs: + if not self.provides: + return None + outputs = self._query_data() - # Otherwise, perform queries to get outputs - if self.provides: - query_results = self._query_data() - - # Validate results - validated_outputs = {} - for msg_name, msg in self.provides.items(): - if msg_name in query_results: - msg.check(query_results[msg_name]) - validated_outputs[msg_name] = query_results[msg_name] - - return self._pipeline.trigger(self, validated_outputs) - - return None + return self._pipeline.trigger(self, self._to_messages(outputs)) class DelayNode(Node): @@ -1703,39 +1661,39 @@ class DelayNode(Node): Simple passthrough with delay: >>> delay_node = DelayNode( - ... requires=[Message(name="temperature", dtype=float)], - ... provides=[Message(name="temperature", dtype=float)], + ... requires=[MessageSpec(name="temperature", dtype=DType.FLOAT)], + ... provides=[MessageSpec(name="temperature", dtype=DType.FLOAT)], ... params={"delay": 1}, ... ) Rate-limited forwarding: >>> throttle_node = DelayNode( - ... requires=[Message(name="sensor_data", dtype=float)], - ... provides=[Message(name="sensor_data", dtype=float)], + ... requires=[MessageSpec(name="sensor_data", dtype=DType.FLOAT)], + ... provides=[MessageSpec(name="sensor_data", dtype=DType.FLOAT)], ... params={"interval": 5}, ... ) Cron: emit timestamp every day at 08:30: >>> alarm_node = DelayNode( - ... provides=[Message(name="timestamp", dtype=float)], + ... provides=[MessageSpec(name="timestamp", dtype=DType.FLOAT)], ... params={"cron": "30 8 * * *"}, ... ) Cron: emit stored input every 5 minutes: >>> periodic_node = DelayNode( - ... requires=[Message(name="value", dtype=float)], - ... provides=[Message(name="value", dtype=float)], + ... requires=[MessageSpec(name="value", dtype=DType.FLOAT)], + ... provides=[MessageSpec(name="value", dtype=DType.FLOAT)], ... params={"cron": "*/5 * * * *"}, ... ) Cron: emit on weekdays at 23:59: >>> weekday_node = DelayNode( - ... requires=[Message(name="daily_summary", dtype=float)], - ... provides=[Message(name="daily_summary", dtype=float)], + ... requires=[MessageSpec(name="daily_summary", dtype=DType.FLOAT)], + ... provides=[MessageSpec(name="daily_summary", dtype=DType.FLOAT)], ... params={"cron": "59 23 * * 1-5"}, ... ) """ @@ -1751,30 +1709,41 @@ class DelayNode(Node): "_stop_cron", ) - def __init__(self, params: dict, **kwargs): - self.delay = int(params.get("delay", 0)) - self.interval = int(params.get("interval", 0)) - self.mapping = params.get("mapping", {}) - self.cron_expr = params.get("cron") - self.ts = 0 - self.last_input = {} + class Params(BaseModel): + model_config = ConfigDict(extra="allow") + + delay: int = 0 + interval: int = 0 + # Input port → output port; ports are paired in order when empty. + mapping: dict[str, str] = {} + cron: str | None = None + + def __init__(self, params: dict[str, Any] | None = None, **kwargs): + cfg = self.Params.model_validate(params or {}) + self.delay = cfg.delay + self.interval = cfg.interval + self.mapping = cfg.mapping + self.cron_expr = cfg.cron + self.ts = 0.0 + self.last_input: dict[str, Any] = {} self._cron_task = None self._stop_cron = None - if self.mapping: - assert ( - self.mapping.keys() == self.requires - ), "Input mapping does not match node requirements." - assert ( - self.mapping.values() == self.provides - ), "Output mapping does not match node requirements." - super().__init__(f=self._f, params=params, **kwargs) + def _port_pairs(self) -> list[tuple[str, str]]: + """Input port → output port pairs this node forwards along.""" + if self.mapping: + return list(self.mapping.items()) + return [ + (i.port, o.port) + for i, o in zip(self.input_ports, self.output_ports, strict=False) + ] + def _f(self, params, **kwargs): """Forward messages with optional delay, rate-limiting, and alarm.""" - logger.info(f"[{self.name}] Received {kwargs}") + logger.info("[%s] Received %s", self.name, kwargs) # Store last input for cron use if kwargs: @@ -1784,7 +1753,7 @@ class DelayNode(Node): if self.interval > 0: ts = time.time() if ts <= self.ts + self.interval: - logger.info(f"[{self.name}] Stashing {kwargs}") + logger.info("[%s] Stashing %s", self.name, kwargs) return None self.ts = ts @@ -1792,28 +1761,23 @@ class DelayNode(Node): if self.delay > 0: time.sleep(self.delay) - # Build output dict if not kwargs: return None - output = {} - if self.mapping: - for input_name, output_name in self.mapping.items(): - output[output_name] = kwargs[input_name] - else: - for input_name, output_name in zip( - self.requires, self.provides, strict=True - ): - output[output_name] = kwargs[input_name] + output = { + out_port: kwargs[in_port] + for in_port, out_port in self._port_pairs() + if in_port in kwargs + } - logger.info(f"[{self.name}] Sending {output}") + logger.info("[%s] Sending %s", self.name, output) return output # ----------------------------------------------------------------- # Cron scheduler # ----------------------------------------------------------------- - async def start_cron(self): + async def start_cron(self) -> None: """ Start the cron scheduler. @@ -1834,7 +1798,7 @@ class DelayNode(Node): self._cron_task = asyncio.create_task(self._cron_loop()) logger.info("Started cron for node '%s': %s", self.name, self.cron_expr) - async def stop_cron(self): + async def stop_cron(self) -> None: """Stop the cron scheduler.""" if self._cron_task is None: return @@ -1929,23 +1893,16 @@ class DelayNode(Node): try: output = {} - if len(self.requires) == 0: - # No requires → emit current timestamp for each provides - for msg_name in self.provides: - output[msg_name] = time.time() + if not self.input_ports: + # Nothing to forward — emit the current time on every output. + output = {spec.port: time.time() for spec in self.output_ports} logger.info("Cron emission for node '%s': timestamp", self.name) elif self.last_input: - # Has requires and stored input → re-emit last input - if self.mapping: - for input_name, output_name in self.mapping.items(): - if input_name in self.last_input: - output[output_name] = self.last_input[input_name] - else: - for input_name, output_name in zip( - self.requires, self.provides, strict=True - ): - if input_name in self.last_input: - output[output_name] = self.last_input[input_name] + output = { + out_port: self.last_input[in_port] + for in_port, out_port in self._port_pairs() + if in_port in self.last_input + } logger.info( "Cron emission for node '%s': stored input %s", self.name, @@ -1959,7 +1916,9 @@ class DelayNode(Node): return if output: - self._pipeline.trigger(self, output) + await asyncio.to_thread( + self._pipeline.trigger, self, self._to_messages(output) + ) except Exception as e: logger.error( "Error triggering cron for node '%s': %s", diff --git a/backend/app/flow/nodes/alarm_test.py b/backend/app/flow/nodes/alarm_test.py deleted file mode 100644 index 43365d5..0000000 --- a/backend/app/flow/nodes/alarm_test.py +++ /dev/null @@ -1,20 +0,0 @@ -from nodes import DelayNode -from util import Message -import numpy as np -import logging - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) - - -def create_node(params: dict) -> DelayNode: - - return DelayNode( - requires=[], - provides=[Message(name="timestamp", dtype=float)], - params={"cron": "* * * * * *"}, - name="alarm", - ) diff --git a/backend/app/flow/nodes/delay_test.py b/backend/app/flow/nodes/delay_test.py deleted file mode 100644 index f7d194c..0000000 --- a/backend/app/flow/nodes/delay_test.py +++ /dev/null @@ -1,25 +0,0 @@ -from nodes import DelayNode -from util import Message - -import logging - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) - - -def create_node(params: dict) -> DelayNode: - return DelayNode( - params={ - "delay": 1, - }, - requires=[ - Message(name="random_value_rec_rate", dtype=int), - ], - provides=[ - Message(name="random_value_rec_rate_delayed", dtype=int), - ], - name="delay", - ) diff --git a/backend/app/flow/nodes/influx_test.py b/backend/app/flow/nodes/influx_test.py deleted file mode 100644 index 627c103..0000000 --- a/backend/app/flow/nodes/influx_test.py +++ /dev/null @@ -1,35 +0,0 @@ -from nodes import InfluxDbNode -from util import Message - -import logging - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) - - -def create_node(params: dict) -> InfluxDbNode: - return InfluxDbNode( - requires=[ - Message(name="random_value_rec_rate", dtype=int), - ], - params={ - "url": params.get("influxdb_url", "http://10.200.200.115:8086"), - "token": params.get( - "influxdb_token", - "***REMOVED-INFLUXDB-TOKEN***==", - ), - "org": params.get("influxdb_org", "strobl"), - "bucket": params.get("influxdb_bucket", "test"), - "writes": { - "random_value_rec_rate": { - "measurement": "test", - "field": "random", - }, - }, - "synchronous": True, - }, - name="influxdb", - ) diff --git a/backend/app/flow/nodes/mqtt_test_a.py b/backend/app/flow/nodes/mqtt_test_a.py deleted file mode 100644 index ca60425..0000000 --- a/backend/app/flow/nodes/mqtt_test_a.py +++ /dev/null @@ -1,25 +0,0 @@ -from nodes import MqttNode -from util import Message - -import logging - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) - - -def create_node(params: dict) -> MqttNode: - return MqttNode( - requires=[ - Message(name="random_value_send", dtype=int), - ], - params={ - "broker_host": "127.0.0.1", - "topic": { - "random_value_send": "random_value", - }, - }, - name="mqtt_a", - ) diff --git a/backend/app/flow/nodes/mqtt_test_b.py b/backend/app/flow/nodes/mqtt_test_b.py deleted file mode 100644 index bd42ec5..0000000 --- a/backend/app/flow/nodes/mqtt_test_b.py +++ /dev/null @@ -1,25 +0,0 @@ -from nodes import MqttNode -from util import Message - -import logging - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) - - -def create_node(params: dict) -> MqttNode: - return MqttNode( - provides=[ - Message(name="random_value_rec", dtype=int), - ], - params={ - "broker_host": "127.0.0.1", - "topic": { - "random_value_rec": "random_value", - }, - }, - name="mqtt_b", - ) diff --git a/backend/app/flow/nodes/notify_test.py b/backend/app/flow/nodes/notify_test.py deleted file mode 100644 index e7f8a46..0000000 --- a/backend/app/flow/nodes/notify_test.py +++ /dev/null @@ -1,30 +0,0 @@ -from nodes import Node -from util import Message - -import logging - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) - - -def create_node(params: dict) -> Node: - def check_power(params, random_value_rec_rate_delayed: int, **kwargs): - threshold = params.get("alert_threshold", 1000.0) - - if random_value_rec_rate_delayed > 50: - logger.info(f"[alert_node] ⚠️ Input >50") - else: - logger.info(f"[alert_node] ✓ Input < 50") - - return Node( - f=check_power, - requires=[ - Message(name="random_value_rec_rate_delayed", dtype=int), - ], - provides=[], # Using object for optional string - params=params, - name="notify", - ) diff --git a/backend/app/flow/nodes/random_test.py b/backend/app/flow/nodes/random_test.py deleted file mode 100644 index 8b30c0f..0000000 --- a/backend/app/flow/nodes/random_test.py +++ /dev/null @@ -1,32 +0,0 @@ -from nodes import Node -from util import Message -import numpy as np -import logging - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) - - -def create_node(params: dict) -> Node: - def f(params, timestamp): - seed = int(params.get("seed", 1000)) - rng = np.random.default_rng(seed=seed) - - rand_value = int(100 * rng.uniform()) - - return {"random_value_send": rand_value} - - return Node( - provides=[ - Message(name="random_value_send", dtype=int), - ], - requires=[Message(name="timestamp", dtype=float)], - f=f, - params={ - "seed": "1000", - }, - name="random", - ) diff --git a/backend/app/flow/nodes/rate_test.py b/backend/app/flow/nodes/rate_test.py deleted file mode 100644 index a2c0254..0000000 --- a/backend/app/flow/nodes/rate_test.py +++ /dev/null @@ -1,26 +0,0 @@ -from nodes import DelayNode -from util import Message - -import logging - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -logger = logging.getLogger(__name__) - - -def create_node(params: dict) -> DelayNode: - return DelayNode( - params={ - "delay": 0, - "interval": 3, - }, - requires=[ - Message(name="random_value_rec", dtype=int), - ], - provides=[ - Message(name="random_value_rec_rate", dtype=int), - ], - name="rate", - ) diff --git a/backend/app/flow/nodes_example/alert_node.py b/backend/app/flow/nodes_example/alert_node.py deleted file mode 100644 index 4a5f033..0000000 --- a/backend/app/flow/nodes_example/alert_node.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -Example sink node: Alert Generator - -This node consumes the comfort index and generates alerts -when comfort drops below a threshold. It demonstrates a -sink node (produces no outputs for other nodes). -""" - -from nodes import Node -from util import Message - - -def create_node(params: dict) -> Node: - """Create the alert generator node.""" - - def check_comfort_alert(params, comfort_index: float = 50.0, **kwargs): - """ - Check if comfort level requires an alert. - - Generates an alert message if comfort is too low. - """ - threshold = params.get("alert_threshold", 60.0) - - if comfort_index < threshold: - alert_msg = f"LOW COMFORT ALERT: Index {comfort_index:.1f} below threshold {threshold}" - print(f"[alert_node] ⚠️ {alert_msg}") - # In a real implementation, this could: - # - Send email/SMS - # - Push to a message queue - # - Trigger home automation - return {"alert": alert_msg} - else: - print(f"[alert_node] ✓ Comfort level OK ({comfort_index:.1f})") - return {"alert": None} - - return Node( - f=check_comfort_alert, - requires=[Message(name="comfort_index", dtype=float)], - provides=[ - Message(name="alert", dtype=object) - ], # Using object for optional string - params=params, - name="alert_node", - ) diff --git a/backend/app/flow/nodes_example/comfort_calculator.py b/backend/app/flow/nodes_example/comfort_calculator.py deleted file mode 100644 index 6a7776e..0000000 --- a/backend/app/flow/nodes_example/comfort_calculator.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -Example processing node: Comfort Calculator - -This node takes temperature and humidity as inputs and calculates -a comfort index. It demonstrates how nodes can have dependencies -that are automatically resolved by the pipeline. -""" - -from nodes import Node -from util import Message - - -def create_node(params: dict) -> Node: - """Create the comfort calculator node.""" - - def calculate_comfort( - params, temperature: float = 20.0, humidity: float = 50.0, **kwargs - ): - """ - Calculate comfort index based on temperature and humidity. - - Uses a simplified heat index formula. - """ - # Simplified comfort calculation - # Ideal: 22°C, 45% humidity - temp_diff = abs(temperature - 22.0) - humidity_diff = abs(humidity - 45.0) - - comfort = 100.0 - (temp_diff * 3) - (humidity_diff * 0.5) - comfort = max(0, min(100, comfort)) - - print( - f"[comfort_calculator] T={temperature:.1f}°C, H={humidity:.1f}% -> Comfort={comfort:.1f}" - ) - return {"comfort_index": comfort} - - return Node( - f=calculate_comfort, - requires=[ - Message(name="temperature", dtype=float), - Message(name="humidity", dtype=float), - ], - provides=[Message(name="comfort_index", dtype=float)], - params=params, - name="comfort_calculator", - ) diff --git a/backend/app/flow/nodes_example/http_sender.py b/backend/app/flow/nodes_example/http_sender.py deleted file mode 100644 index 64e85d1..0000000 --- a/backend/app/flow/nodes_example/http_sender.py +++ /dev/null @@ -1,35 +0,0 @@ -""" -Example HTTP sender node: Data Publisher - -This node sends processed data to an external API endpoint -via HTTP POST requests. -""" - -from nodes import HttpNode -from util import Message - - -def create_node(params: dict) -> HttpNode: - """ - Create an HTTP sender node that publishes comfort data. - - :param params: Parameters passed from the controller. - :type params: dict - :returns: Configured HttpNode instance in sender mode. - :rtype: HttpNode - """ - # Get the target URL from params or use a default - target_url = params.get("webhook_url", "https://httpbin.org/post") - - return HttpNode( - url=target_url, - method="POST", - requires=[ - Message(name="comfort_index", dtype=float), - Message(name="alert", dtype=object), - ], - params=params, - name="data_publisher", - timeout=10.0, - headers={"X-Source": "fluksio-pipeline"}, - ) diff --git a/backend/app/flow/nodes_example/http_trigger.py b/backend/app/flow/nodes_example/http_trigger.py deleted file mode 100644 index 3763d2c..0000000 --- a/backend/app/flow/nodes_example/http_trigger.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Example HTTP trigger node: Webhook Receiver - -This node acts as a webhook endpoint that receives temperature data -via HTTP POST requests and injects it into the pipeline. -""" - -from nodes import HttpNode -from util import Message - - -def create_node(params: dict) -> HttpNode: - """ - Create an HTTP trigger node that receives temperature data. - - :param params: Parameters passed from the controller. - :type params: dict - :returns: Configured HttpNode instance in trigger mode. - :rtype: HttpNode - """ - return HttpNode( - url="/api/sensors/temperature", - method="POST", - provides=[ - Message(name="temperature", dtype=float), - Message(name="sensor_id", dtype=str), - ], - params=params, - name="temperature_webhook", - ) diff --git a/backend/app/flow/nodes_example/humidity_sensor.py b/backend/app/flow/nodes_example/humidity_sensor.py deleted file mode 100644 index 98364a7..0000000 --- a/backend/app/flow/nodes_example/humidity_sensor.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -Example trigger node: Humidity Sensor - -Another trigger node that simulates a humidity sensor. -""" - -from nodes import Node -from util import Message - - -def create_node(params: dict) -> Node: - """Create the humidity sensor node.""" - - def read_humidity(params, **kwargs): - """Simulate reading humidity from a sensor.""" - import random - - humidity = 50.0 + random.gauss(0, 10) # 50% ± 10% - humidity = max(0, min(100, humidity)) # Clamp to [0, 100] - print(f"[humidity_sensor] Read humidity: {humidity:.1f}%") - return {"humidity": humidity} - - return Node( - f=read_humidity, - requires=[], - provides=[Message(name="humidity", dtype=float)], - params=params, - name="humidity_sensor", - ) diff --git a/backend/app/flow/nodes_example/influxdb_reader.py b/backend/app/flow/nodes_example/influxdb_reader.py deleted file mode 100644 index 08eac17..0000000 --- a/backend/app/flow/nodes_example/influxdb_reader.py +++ /dev/null @@ -1,52 +0,0 @@ -""" -Example InfluxDB reader node: Average Temperature Provider - -This node queries InfluxDB for average temperature and provides it -to downstream nodes. -""" - -from nodes import InfluxDbNode -from util import Message - - -def create_node(params: dict) -> InfluxDbNode: - """ - Create an InfluxDB reader node that queries average temperature. - - :param params: Parameters passed from the controller, should include: - - ``influxdb_url``: InfluxDB server URL - - ``influxdb_token``: Authentication token - - ``influxdb_org``: Organization name - - ``influxdb_bucket``: Bucket name - :type params: dict - :returns: Configured InfluxDbNode instance. - :rtype: InfluxDbNode - """ - return InfluxDbNode( - provides=[ - Message(name="avg_temperature", dtype=float), - Message(name="max_temperature", dtype=float), - ], - params={ - "url": params.get("influxdb_url", "http://localhost:8086"), - "token": params.get("influxdb_token", "my-token"), - "org": params.get("influxdb_org", "my-org"), - "bucket": params.get("influxdb_bucket", "sensors"), - "query_range": "-1h", - "queries": { - "avg_temperature": { - "measurement": "temperature", - "field": "value", - "tags": {"location": "room1"}, - "aggregation": "mean", - }, - "max_temperature": { - "measurement": "temperature", - "field": "value", - "tags": {"location": "room1"}, - "aggregation": "max", - }, - }, - }, - name="influxdb_temperature_reader", - ) diff --git a/backend/app/flow/nodes_example/influxdb_writer.py b/backend/app/flow/nodes_example/influxdb_writer.py deleted file mode 100644 index 43e43b8..0000000 --- a/backend/app/flow/nodes_example/influxdb_writer.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -Example InfluxDB writer node: Temperature Logger - -This node writes temperature readings to InfluxDB. -The input is just a float value - the measurement, field, and tags -are configured via params. -""" - -from nodes import InfluxDbNode -from util import Message - - -def create_node(params: dict) -> InfluxDbNode: - """ - Create an InfluxDB writer node that logs temperature data. - - The node receives a simple float value and writes it to InfluxDB - with the configured measurement, field, and tags. - - :param params: Parameters passed from the controller, should include: - - ``influxdb_url``: InfluxDB server URL - - ``influxdb_token``: Authentication token - - ``influxdb_org``: Organization name - - ``influxdb_bucket``: Bucket name - :type params: dict - :returns: Configured InfluxDbNode instance. - :rtype: InfluxDbNode - """ - return InfluxDbNode( - requires=[ - Message(name="temperature", dtype=float), - Message(name="humidity", dtype=float), - ], - params={ - "url": params.get("influxdb_url", "http://localhost:8086"), - "token": params.get("influxdb_token", "my-token"), - "org": params.get("influxdb_org", "my-org"), - "bucket": params.get("influxdb_bucket", "sensors"), - "write_precision": "ms", - "writes": { - "temperature": { - "measurement": "environment", - "field": "temp_celsius", - "tags": {"location": "room1", "sensor": "dht22"}, - }, - "humidity": { - "measurement": "environment", - "field": "humidity_percent", - "tags": {"location": "room1", "sensor": "dht22"}, - }, - }, - }, - name="influxdb_temperature_writer", - ) diff --git a/backend/app/flow/nodes_example/mqtt_publisher.py b/backend/app/flow/nodes_example/mqtt_publisher.py deleted file mode 100644 index 0a2571b..0000000 --- a/backend/app/flow/nodes_example/mqtt_publisher.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -Example MQTT publisher node: Alert Publisher - -This node publishes alerts to an MQTT topic when comfort levels -are outside acceptable ranges. -""" - -from nodes import MqttNode -from util import Message - - -def create_node(params: dict) -> MqttNode: - """ - Create an MQTT publisher node that sends alert messages. - - :param params: Parameters passed from the controller, should include: - - ``broker_host``: MQTT broker hostname - - ``broker_port``: MQTT broker port (optional, default 1883) - - ``qos``: Quality of Service level (optional, default 0) - :type params: dict - :returns: Configured MqttNode instance in publisher mode. - :rtype: MqttNode - """ - return MqttNode( - topic="alerts/comfort", - requires=[ - Message(name="comfort_index", dtype=float), - Message(name="alert", dtype=object), - ], - params={ - **params, - "qos": params.get("qos", 1), # Use QoS 1 for alerts - "retain": True, # Retain last alert - }, - name="mqtt_alert_publisher", - ) diff --git a/backend/app/flow/nodes_example/mqtt_subscriber.py b/backend/app/flow/nodes_example/mqtt_subscriber.py deleted file mode 100644 index f058c19..0000000 --- a/backend/app/flow/nodes_example/mqtt_subscriber.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -Example MQTT subscriber node: Temperature Sensor Listener - -This node subscribes to an MQTT topic and triggers the pipeline -when temperature readings are received. -""" - -from nodes import MqttNode -from util import Message - - -def create_node(params: dict) -> MqttNode: - """ - Create an MQTT subscriber node that listens for temperature data. - - :param params: Parameters passed from the controller, should include: - - ``broker_host``: MQTT broker hostname - - ``broker_port``: MQTT broker port (optional, default 1883) - :type params: dict - :returns: Configured MqttNode instance in subscriber mode. - :rtype: MqttNode - """ - return MqttNode( - topic="sensors/temperature", - provides=[ - Message(name="temperature", dtype=float), - Message(name="sensor_id", dtype=str), - ], - params=params, - name="mqtt_temperature_listener", - ) diff --git a/backend/app/flow/nodes_example/temperature_sensor.py b/backend/app/flow/nodes_example/temperature_sensor.py deleted file mode 100644 index 5e5a5fb..0000000 --- a/backend/app/flow/nodes_example/temperature_sensor.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -Example trigger node: Temperature Sensor - -This is a trigger node (no inputs) that simulates a temperature sensor. -Trigger nodes act as entry points to the pipeline - they can be triggered -externally via HTTP, MQTT, or other mechanisms. -""" - -from nodes import Node -from util import Message - - -def create_node(params: dict) -> Node: - """ - Create the temperature sensor node. - - :param params: Parameters passed from the controller. - :type params: dict - :returns: Configured Node instance. - :rtype: Node - """ - - def read_temperature(params, **kwargs): - """Simulate reading temperature from a sensor.""" - import random - - temperature = 20.0 + random.gauss(0, 2) # 20°C ± 2°C - print(f"[temperature_sensor] Read temperature: {temperature:.2f}°C") - return {"temperature": temperature} - - return Node( - f=read_temperature, - requires=[], # No inputs - this is a trigger node - provides=[Message(name="temperature", dtype=float)], - params=params, - name="temperature_sensor", - ) diff --git a/backend/app/flow/pipeline.py b/backend/app/flow/pipeline.py index 3305c68..e739258 100644 --- a/backend/app/flow/pipeline.py +++ b/backend/app/flow/pipeline.py @@ -1,210 +1,134 @@ -""" -Pipeline module for directed acyclic graph execution. +"""Pipeline: the executable graph. -This module provides a Pipeline class that manages nodes with automatic -dependency resolution and supports both sequential and parallel execution. +One pipeline holds the nodes of every loaded flow. Edges are not declared — +they follow from message names, so a node consuming ``heating.setpoint`` is +downstream of every node providing it. Several producers of one message are +allowed: each publication triggers the consumers, and the latest value wins. """ from __future__ import annotations +import logging +import time from collections import deque -from concurrent.futures import ThreadPoolExecutor, Future, wait +from concurrent.futures import Future, ThreadPoolExecutor, wait +from typing import Any, Literal -import matplotlib.pyplot as plt -import networkx as nx +from pydantic import BaseModel -from nodes import Node -from state import StateBackend, MemoryState -from util import Message +from app.flow.events import EventBus +from app.flow.messages import flow_of +from app.flow.nodes import Node +from app.flow.state import MemoryState, StateBackend + +logger = logging.getLogger(__name__) + + +class ValidationIssue(BaseModel): + """A problem that keeps a flow from running correctly.""" + + code: Literal[ + "cycle", + "unconnected_input", + "missing_initial_value", + "node_error", + ] + message: str + flow: str = "" + nodes: list[str] = [] + node: str | None = None + port: str | None = None + message_name: str | None = None class Pipeline: - """ - Directed acyclic graph of nodes with automatic dependency resolution. - - A Pipeline manages a collection of nodes, automatically resolving their - dependencies and executing them in the correct order. Supports parallel - execution, external triggering, and composition of multiple pipelines. - - :param nodes: List of nodes owned by this pipeline. - :type nodes: list[Node] | None - :param pipelines: Child pipelines to compose into this pipeline. - :type pipelines: list[Pipeline] | None - :param inputs: External input messages (not produced by any node). - :type inputs: list[Message] | None - :param outputs: Output messages (for documentation purposes). - :type outputs: list[Message] | None - :param max_workers: Maximum thread pool workers for parallel execution. - :type max_workers: int | None - :param state: State backend for storing pipeline values. - :type state: StateBackend | None - - :example: - >>> pipeline = Pipeline( - ... nodes=[node_a, node_b], - ... pipelines=[child_pipeline], - ... max_workers=4 - ... ) - >>> result = pipeline.run() - """ + """Directed graph of nodes with automatic dependency resolution.""" __slots__ = ( - "_own_nodes", - "_child_pipelines", - "_parent", + "_nodes", "_state", + "_events", + "_max_workers", "produces", "dependencies", "_edges", "_execution_order", "_downstream_cache", - "_max_workers", ) def __init__( self, nodes: list[Node] | None = None, - pipelines: list[Pipeline] | None = None, - inputs: list[Message] | None = None, - outputs: list[Message] | None = None, - max_workers: int | None = None, state: StateBackend | None = None, + events: EventBus | None = None, + max_workers: int | None = None, + initial_values: dict[str, Any] | None = None, ) -> None: - self._own_nodes = nodes or [] - self._child_pipelines = pipelines or [] - self._parent: Pipeline | None = None + self._nodes = nodes or [] + self._state: StateBackend = state or MemoryState() + self._events = events self._max_workers = max_workers - # Set parent reference for child pipelines - for child in self._child_pipelines: - child._parent = self + # A message may have several producers; every one of them is upstream + # of the nodes consuming it. + self.produces: dict[str, list[Node]] = {} + for node in self._nodes: + for msg in node.provides: + self.produces.setdefault(msg, []).append(node) - # Collect all nodes and build graph - all_nodes = self._collect_all_nodes() - - # Use provided state backend or create default MemoryState - self._state: StateBackend = state or MemoryState() - - # Build producer map and dependency graph - self.produces: dict[str, Node | None] = { - **({m.name: None for m in inputs} if inputs else {}), - **{msg: node for node in all_nodes for msg in node.provides}, - } self.dependencies: dict[Node, frozenset[Node]] = { node: frozenset( - self.produces[msg] for msg in node.requires if self.produces.get(msg) + producer + for msg in node.requires + for producer in self.produces.get(msg, ()) + if producer is not node ) - for node in all_nodes + for node in self._nodes } - # Lazy-initialized caches self._edges: dict[Node, set[Node]] | None = None self._execution_order: list[Node] | None = None self._downstream_cache: dict[Node, list[Node]] = {} - # Bind all nodes to root pipeline - for node in all_nodes: - node.bind(self._root) + if initial_values: + with self._state.lock(): + for name, value in initial_values.items(): + if name not in self._state: + self._state[name] = value + + for node in self._nodes: + node.bind(self) # ------------------------------------------------------------------------- - # Properties + # Graph # ------------------------------------------------------------------------- - @property - def _root(self) -> Pipeline: - """ - Get the root pipeline in the hierarchy. - - :returns: The topmost parent pipeline, or self if no parent. - :rtype: Pipeline - """ - return self._parent._root if self._parent else self - @property def nodes(self) -> list[Node]: - """ - All nodes in this pipeline and child pipelines. + return self._nodes - :returns: Flattened list of all nodes. - :rtype: list[Node] - """ - return self._collect_all_nodes() + @property + def state(self) -> StateBackend: + return self._state @property def edges(self) -> dict[Node, set[Node]]: - """ - Reverse dependency graph mapping producers to consumers. - - Lazily built on first access. - - :returns: Dict mapping each node to its downstream consumers. - :rtype: dict[Node, set[Node]] - """ + """Producers mapped to their consumers, built on first access.""" if self._edges is None: - self._edges = {node: set() for node in self.nodes} + self._edges = {node: set() for node in self._nodes} for consumer, producers in self.dependencies.items(): for producer in producers: self._edges[producer].add(consumer) return self._edges - @property - def state(self) -> StateBackend: - """ - State backend for storing pipeline values. + def get_node_by_id(self, nid: str) -> Node | None: + return next((n for n in self._nodes if n.id == nid), None) - :returns: State backend. - :rtype: StateBackend - """ - return self._state._data - - # ------------------------------------------------------------------------- - # Node Access - # ------------------------------------------------------------------------- - - def _collect_all_nodes(self) -> list[Node]: - """ - Recursively collect nodes from this pipeline and all children. - - :returns: List of all nodes in the hierarchy. - :rtype: list[Node] - """ - nodes = list(self._own_nodes) - for child in self._child_pipelines: - nodes.extend(child._collect_all_nodes()) - return nodes - - def get_node_by_id(self, nid) -> Node | None: - """ - Find a node by its ID. - - Searches this pipeline's own nodes first, then child pipelines. - - :param nid: The ID of the node to find. - :type nid: Any - :returns: The node with the given ID, or None if not found. - :rtype: Node | None - """ - if node := next((n for n in self._own_nodes if n.id is nid), None): - return node - for child in self._child_pipelines: - if node := child.get_node_by_id(nid): - return node - return None - - # ------------------------------------------------------------------------- - # Graph Algorithms - # ------------------------------------------------------------------------- + def flow_nodes(self, flow: str) -> set[Node]: + return {n for n in self._nodes if n.flow == flow} def _topological_sort(self) -> list[Node]: - """ - Compute topological ordering of nodes using Kahn's algorithm. - - Results are cached for subsequent calls. - - :returns: Nodes in topologically sorted order. - :rtype: list[Node] - :raises ValueError: If a cycle is detected in the graph. - """ + """Kahn's algorithm; nodes left over are part of a cycle.""" if self._execution_order is not None: return self._execution_order @@ -220,23 +144,10 @@ class Pipeline: if in_degree[consumer] == 0: queue.append(consumer) - if len(result) != len(self.nodes): - raise ValueError("Cycle detected in pipeline graph") - self._execution_order = result return result def _get_downstream(self, start: Node) -> list[Node]: - """ - Get all downstream nodes from a starting node in topological order. - - Results are cached per start node. - - :param start: The node to find downstream nodes from. - :type start: Node - :returns: Topologically sorted downstream nodes. - :rtype: list[Node] - """ if start not in self._downstream_cache: reachable: set[Node] = set() queue = deque([start]) @@ -245,186 +156,186 @@ class Pipeline: if consumer not in reachable: reachable.add(consumer) queue.append(consumer) - self._downstream_cache[start] = [ - n for n in self._topological_sort() if n in reachable - ] + order = self._topological_sort() + ordered = [n for n in order if n in reachable] + # Nodes inside a cycle never make it into the topological order. + ordered += [n for n in reachable if n not in order] + self._downstream_cache[start] = ordered return self._downstream_cache[start] # ------------------------------------------------------------------------- - # Synchronous Node Support + # Validation + # ------------------------------------------------------------------------- + + def validate(self, known_inputs: set[str] | None = None) -> list[ValidationIssue]: + """Report everything that would keep this graph from running. + + :param known_inputs: Message names supplied from outside the graph + (flow inputs with an initial value). + """ + known = known_inputs or set() + issues: list[ValidationIssue] = [] + + ordered = set(self._topological_sort()) + if len(ordered) != len(self._nodes): + cyclic = sorted(n.id for n in self._nodes if n not in ordered) + issues.append( + ValidationIssue( + code="cycle", + message=( + "These nodes depend on each other in a loop, so none of " + "them can run: " + ", ".join(cyclic) + ), + flow=flow_of(cyclic[0]) if cyclic else "", + nodes=cyclic, + ) + ) + + for node in self._nodes: + for msg_name, spec in node.requires.items(): + if msg_name in self.produces or msg_name in known: + continue + issues.append( + ValidationIssue( + code="unconnected_input", + message=( + f"'{node.local_id}' waits for '{msg_name}', " + "which nothing provides." + ), + flow=node.flow, + node=node.id, + port=spec.port, + message_name=msg_name, + ) + ) + return issues + + # ------------------------------------------------------------------------- + # Readiness (synchronous nodes) # ------------------------------------------------------------------------- def _version_key(self, msg_name: str) -> str: - """Get the state key for a message's version number.""" return f"__version__:{msg_name}" def _last_seen_key(self, node_name: str, msg_name: str) -> str: - """Get the state key for the version a node last processed.""" return f"__last_seen__:{node_name}:{msg_name}" - def _increment_message_versions(self, outputs: dict) -> None: - """ - Increment version numbers for all output messages. + def _timestamp_key(self, msg_name: str) -> str: + return f"__ts__:{msg_name}" - :param outputs: Dict of message names to values. - :type outputs: dict - """ + def _increment_message_versions(self, outputs: dict) -> None: for msg_name in outputs: self._state.increment(self._version_key(msg_name)) def _check_synchronous_ready(self, node: Node) -> tuple[bool, dict[str, int]]: - """ - Check if a synchronous node is ready to execute. - - A synchronous node is ready when ALL its required inputs have a newer - version than what the node last processed. - - :param node: The synchronous node to check. - :type node: Node - :returns: Tuple of (is_ready, current_versions dict). - :rtype: tuple[bool, dict[str, int]] - """ + """A synchronous node runs once every input is newer than last time.""" if not node.requires: return True, {} - # Build all keys we need to fetch version_keys = [self._version_key(msg) for msg in node.requires] - last_seen_keys = [self._last_seen_key(node.name, msg) for msg in node.requires] + last_seen_keys = [self._last_seen_key(node.id, msg) for msg in node.requires] + values = self._state.get_multi(version_keys + last_seen_keys) - # Fetch all values atomically - all_keys = version_keys + last_seen_keys - values = self._state.get_multi(all_keys) - - # Check each input current_versions = {} all_newer = True for msg_name in node.requires: - version_key = self._version_key(msg_name) - last_seen_key = self._last_seen_key(node.name, msg_name) - - current_version = values.get(version_key) or 0 - last_seen_version = values.get(last_seen_key) or 0 - - current_versions[msg_name] = current_version - - # For synchronous nodes, version must be: - # 1. Greater than 0 (message has been received at least once) - # 2. Strictly greater than last seen (message has been updated since last execution) - if current_version == 0 or current_version <= last_seen_version: + current = values.get(self._version_key(msg_name)) or 0 + last_seen = values.get(self._last_seen_key(node.id, msg_name)) or 0 + current_versions[msg_name] = current + if current == 0 or current <= last_seen: all_newer = False return all_newer, current_versions def _try_acquire_synchronous_execution( - self, - node: Node, - current_versions: dict[str, int], + self, node: Node, current_versions: dict[str, int] ) -> bool: - """ - Attempt to acquire exclusive execution rights for a synchronous node. - - Uses compare-and-swap to atomically verify versions haven't changed - and update last_seen versions. This prevents race conditions when - multiple threads try to execute the same synchronous node. - - :param node: The node attempting to execute. - :type node: Node - :param current_versions: The versions that were checked. - :type current_versions: dict[str, int] - :returns: True if execution rights acquired, False otherwise. - :rtype: bool - """ + """Claim the right to execute, so concurrent triggers run a node once.""" if not current_versions: return True - # Build expected values and updates expected = {} updates = {} - for msg_name, version in current_versions.items(): - version_key = self._version_key(msg_name) - last_seen_key = self._last_seen_key(node.name, msg_name) - - # Expect the version hasn't changed since we checked - expected[version_key] = version - # Update last_seen to this version - updates[last_seen_key] = version + expected[self._version_key(msg_name)] = version + updates[self._last_seen_key(node.id, msg_name)] = version return self._state.compare_and_swap_multi(expected, updates) def _is_node_ready(self, node: Node, state: StateBackend) -> bool: - """ - Check if a node is ready to execute. - - For non-synchronous nodes: ready when all required inputs exist in state. - For synchronous nodes: ready when all inputs are newer than last processed - AND we can acquire exclusive execution rights. - - :param node: The node to check. - :type node: Node - :param state: State backend containing input values. - :type state: StateBackend - :returns: True if the node should execute. - :rtype: bool - """ - # First check: all required inputs must exist in state with state.lock(): for msg_name in node.requires: if msg_name not in state: return False - # For non-synchronous nodes, that's all we need if not node.synchronous: return True - # For synchronous nodes, check if all inputs have been updated is_ready, current_versions = self._check_synchronous_ready(node) - if not is_ready: return False - - # Try to acquire execution rights atomically - # This prevents race conditions when multiple triggers happen concurrently return self._try_acquire_synchronous_execution(node, current_versions) # ------------------------------------------------------------------------- - # Execution Core + # Execution # ------------------------------------------------------------------------- - def _execute_node( - self, - node: Node, - state: StateBackend, - ) -> dict | None: - """ - Execute a single node without triggering downstream propagation. + def _publish(self, event: dict[str, Any]) -> None: + if self._events is not None: + self._events.publish(event) - :param node: The node to execute. - :type node: Node - :param state: State backend containing input values. - :type state: StateBackend - :returns: Node outputs, or None if no outputs. - :rtype: dict | None - """ - node._pipeline = None + def _execute_node(self, node: Node, state: StateBackend) -> dict | None: + """Run one node and record its outputs. Never raises.""" + started = time.perf_counter() try: - # Gather inputs with locking with state.lock(): inputs = {k: state[k] for k in node.requires if k in state} - result = node(inputs) + result = node.execute(inputs) - # Update state with outputs and increment versions if result: + ts = time.time() with state.lock(): state.update(result) - # Increment version numbers for synchronous node tracking + state.update( + {self._timestamp_key(name): ts for name in result}, + ) self._increment_message_versions(result) + for name, value in result.items(): + self._publish( + { + "type": "message_value", + "flow": flow_of(name), + "name": name, + "value": value, + "ts": ts, + } + ) + self._publish( + { + "type": "node_executed", + "flow": node.flow, + "node": node.id, + "duration_ms": round((time.perf_counter() - started) * 1000, 2), + "ts": time.time(), + } + ) return result - finally: - node._pipeline = self._root + except Exception as exc: + # One failing node must not take the rest of the graph down. + logger.exception("Node '%s' failed", node.id) + self._publish( + { + "type": "node_error", + "flow": node.flow, + "node": node.id, + "error": f"{type(exc).__name__}: {exc}", + "ts": time.time(), + } + ) + return None def _execute_parallel( self, @@ -432,51 +343,34 @@ class Pipeline: state: StateBackend, check_ready: bool = False, ) -> StateBackend: - """ - Execute nodes in parallel using dynamic scheduling. - - :param nodes_subset: Subset of nodes to execute, or None for all nodes. - :type nodes_subset: set[Node] | None - :param state: State backend for inputs/outputs. - :type state: StateBackend - :param check_ready: If True, check state for required inputs before scheduling. - :type check_ready: bool - :returns: State backend after execution. - :rtype: StateBackend - """ - target_nodes = nodes_subset or set(self.nodes) + """Execute nodes concurrently, scheduling each as its inputs arrive.""" + target_nodes = nodes_subset if nodes_subset is not None else set(self._nodes) if not target_nodes: return state - # Build in-degree map (only counting deps within target set) in_degree = { n: sum(1 for dep in self.dependencies[n] if dep in target_nodes) for n in target_nodes } - # Track submitted/completed/skipped nodes submitted: set[Node] = set() - skipped: set[Node] = set() # Synchronous nodes that weren't ready + skipped: set[Node] = set() node_futures: dict[Node, Future] = {} def is_ready(n: Node) -> bool: - """Check if node can be scheduled.""" if in_degree[n] != 0: return False if check_ready: - # Use the enhanced readiness check that handles synchronous nodes return self._is_node_ready(n, state) return True def submit_ready(executor: ThreadPoolExecutor) -> None: - """Submit all currently ready nodes.""" for n in target_nodes: if n not in submitted and n not in skipped and is_ready(n): submitted.add(n) node_futures[n] = executor.submit(self._execute_node, n, state) elif n not in submitted and n.synchronous and in_degree[n] == 0: - # Mark synchronous nodes that weren't ready as skipped - # They may become ready on a future trigger + # Not ready now; a later trigger may make it ready. skipped.add(n) with ThreadPoolExecutor(max_workers=self._max_workers) as executor: @@ -487,15 +381,10 @@ class Pipeline: completed = [n for n, f in node_futures.items() if f in done] for n in completed: - future = node_futures.pop(n) - if exc := future.exception(): - raise exc - - # Only propagate to downstream nodes if this node produced output - # If result is None, the node chose not to forward data (e.g., rate limiting) - result = future.result() + result = node_futures.pop(n).result() + # A node returning nothing (rate limiting, an error) stops + # propagation along its branch. if result is not None: - # Update in-degrees and submit newly ready nodes for consumer in self.edges[n]: if consumer in target_nodes: in_degree[consumer] -= 1 @@ -503,124 +392,57 @@ class Pipeline: return state - # ------------------------------------------------------------------------- - # Public Execution API - # ------------------------------------------------------------------------- - - def run(self, inputs: dict | None = None) -> StateBackend: - """ - Execute the entire pipeline. - - :param inputs: Initial input values to seed the state. - :type inputs: dict | None - :returns: State backend containing all computed values. - :rtype: StateBackend - - :example: - >>> result = pipeline.run({"input": 42}) - """ - # Create a fresh state for this run - state = MemoryState() + def run( + self, inputs: dict | None = None, nodes: set[Node] | None = None + ) -> StateBackend: + """Execute the graph (or one flow's nodes) against the shared state.""" if inputs: - state.update(inputs) + with self._state.lock(): + self._state.update(inputs) + self._increment_message_versions(inputs) - return self._execute_parallel(None, state, check_ready=False) + return self._execute_parallel(nodes, self._state, check_ready=False) def trigger(self, node: Node, outputs: dict | None) -> StateBackend: - """ - Trigger execution after a node runs externally. - - Updates the shared state with node outputs, increments message versions - for synchronous node tracking, and executes all downstream nodes whose - dependencies are satisfied. - - :param node: The node that was triggered. - :type node: Node - :param outputs: Outputs produced by the triggered node. - :type outputs: dict | None - :returns: Updated shared state. - :rtype: StateBackend - """ - root = self._root - state = root._state + """Publish a node's outputs and run everything downstream of it.""" + state = self._state if outputs: + ts = time.time() with state.lock(): state.update(outputs) - # Increment version numbers for synchronous node tracking - root._increment_message_versions(outputs) + state.update({self._timestamp_key(name): ts for name in outputs}) + self._increment_message_versions(outputs) + for name, value in outputs.items(): + self._publish( + { + "type": "message_value", + "flow": flow_of(name), + "name": name, + "value": value, + "ts": ts, + } + ) - downstream = set(root._get_downstream(node)) + downstream = set(self._get_downstream(node)) if not downstream: return state - return root._execute_parallel(downstream, state, check_ready=True) + return self._execute_parallel(downstream, state, check_ready=True) + + def values(self, flow: str | None = None) -> dict[str, dict[str, Any]]: + """Last value and timestamp of every message, optionally one flow's.""" + out: dict[str, dict[str, Any]] = {} + with self._state.lock(): + keys = [k for k in self._state.keys() if not k.startswith("__")] + for key in keys: + if flow and flow_of(key) != flow: + continue + out[key] = { + "value": self._state.get(key), + "ts": self._state.get(self._timestamp_key(key)), + } + return out def reset(self) -> None: - """ - Reset pipeline state for a new execution cycle. - - Clears all computed values from the shared state. - """ - self._root._state.clear() - - # ------------------------------------------------------------------------- - # Visualization - # ------------------------------------------------------------------------- - - def visualize(self, filename: str = "pipeline.png") -> None: - """ - Save a visualization of the pipeline graph. - - :param filename: Output filename for the image. - :type filename: str - """ - G = nx.DiGraph() - G.add_nodes_from(self.nodes) - G.add_edges_from( - (producer, consumer) - for consumer, producers in self.dependencies.items() - for producer in producers - ) - - plt.figure(figsize=(10, 6)) - pos = nx.spring_layout(G, k=0.8, seed=42) - - node_size = 1800 - node_radius = (node_size**0.5) / 2 - - nx.draw_networkx_nodes( - G, - pos, - node_size=node_size, - node_color="#59849B", - edgecolors="#59849B", - linewidths=1.2, - alpha=0.95, - ) - nx.draw_networkx_edges( - G, - pos, - arrows=True, - arrowstyle="-|>", - arrowsize=18, - width=2.8, - edge_color="#DE8F6E", - connectionstyle="arc3,rad=0.05", - min_source_margin=node_radius, - min_target_margin=node_radius, - ) - nx.draw_networkx_labels( - G, - pos, - font_size=15, - font_color="#F5F9E9", - font_weight="bold", - ) - - plt.axis("off") - plt.gca().set_facecolor("#333232") - plt.gcf().set_facecolor("#333232") - plt.tight_layout() - plt.savefig(filename, dpi=200) - plt.close() + self._state.clear() diff --git a/backend/app/flow/schemas.py b/backend/app/flow/schemas.py new file mode 100644 index 0000000..2b140d9 --- /dev/null +++ b/backend/app/flow/schemas.py @@ -0,0 +1,118 @@ +"""The persisted shape of a flow, shared by the store, the API and the editor. + +A flow is structure plus code: this module is the structure. Node logic for +``python`` nodes lives beside it as a plain ``.py`` file. +""" + +from __future__ import annotations + +import re +from typing import Any + +from pydantic import BaseModel, Field, field_validator + +from app.flow.messages import MessageSpec + +NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$") + + +def _validate_name(value: str) -> str: + if not NAME_PATTERN.match(value): + raise ValueError( + "Use lowercase letters, digits and underscores, starting with a letter" + ) + return value + + +class Position(BaseModel): + """Where a node sits on the canvas.""" + + x: float = 0 + y: float = 0 + + +class NodeDef(BaseModel): + """A node as stored: identity, placement, configuration and ports.""" + + id: str + type: str = "python" + title: str = "" + position: Position = Position() + params: dict[str, Any] = Field(default_factory=dict) + requires: list[MessageSpec] = Field(default_factory=list) + provides: list[MessageSpec] = Field(default_factory=list) + + @field_validator("id") + @classmethod + def _check_id(cls, value: str) -> str: + return _validate_name(value) + + +class FlowInput(BaseModel): + """A message the flow starts with rather than computes.""" + + spec: MessageSpec + initial: Any | None = None + + +class FlowDef(BaseModel): + """One atomic flow.""" + + name: str + title: str = "" + nodes: list[NodeDef] = Field(default_factory=list) + inputs: list[FlowInput] = Field(default_factory=list) + version: int = 1 + + @field_validator("name") + @classmethod + def _check_name(cls, value: str) -> str: + return _validate_name(value) + + +class NodeSource(BaseModel): + """The Python source of a node.""" + + code: str + + +class NodeStatusPublic(BaseModel): + """Whether a node loaded, and why not.""" + + id: str + status: str = "active" + error: str | None = None + + +class MessageValue(BaseModel): + """The last payload seen on a message.""" + + value: Any = None + ts: float | None = None + + +class FlowSummary(BaseModel): + name: str + title: str = "" + node_count: int = 0 + error_count: int = 0 + + +class FlowsPublic(BaseModel): + data: list[FlowSummary] + count: int + + +class FlowStatePublic(BaseModel): + values: dict[str, MessageValue] = Field(default_factory=dict) + nodes: list[NodeStatusPublic] = Field(default_factory=list) + + +class NodeTypeInfo(BaseModel): + """A node type the editor can offer, with its parameter schema.""" + + type: str + title: str + description: str + params_schema: dict[str, Any] = Field(default_factory=dict) + has_source: bool = False diff --git a/backend/app/flow/secrets.py b/backend/app/flow/secrets.py new file mode 100644 index 0000000..886d0b8 --- /dev/null +++ b/backend/app/flow/secrets.py @@ -0,0 +1,115 @@ +"""Encrypted credential store for node integrations. + +Nodes never hold credentials: a parameter written as ``{"$secret": "name"}`` +is replaced with the stored value when the node is built. Secrets are kept +encrypted outside the flows repository, so what gets committed and shared +never contains a password. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import logging +from pathlib import Path +from typing import Any + +from cryptography.fernet import Fernet, InvalidToken + +logger = logging.getLogger(__name__) + +SECRET_REF = "$secret" + + +class SecretNotFound(KeyError): + """A node asked for a secret that is not in the store.""" + + def __init__(self, name: str) -> None: + super().__init__(name) + self.name = name + + def __str__(self) -> str: + return f"No secret named '{self.name}' — add it under Settings › Secrets." + + +class SecretsStore: + """Named credentials, encrypted at rest with a key derived from the app key.""" + + def __init__(self, path: Path, key: str) -> None: + self._path = path + self._fernet = Fernet( + base64.urlsafe_b64encode(hashlib.sha256(key.encode()).digest()) + ) + + def _read(self) -> dict[str, str]: + if not self._path.exists(): + return {} + try: + data: dict[str, str] = json.loads( + self._fernet.decrypt(self._path.read_bytes()) + ) + return data + except (InvalidToken, ValueError): + # A changed app key makes existing secrets unreadable. + logger.error( + "Cannot decrypt %s — it was written with a different SECRET_KEY", + self._path, + ) + return {} + + def _write(self, data: dict[str, str]) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_bytes(self._fernet.encrypt(json.dumps(data).encode())) + + def list(self) -> list[str]: + return sorted(self._read()) + + def get(self, name: str) -> str: + data = self._read() + if name not in data: + raise SecretNotFound(name) + return data[name] + + def set(self, name: str, value: str) -> None: + data = self._read() + data[name] = value + self._write(data) + + def delete(self, name: str) -> None: + data = self._read() + if data.pop(name, None) is None: + raise SecretNotFound(name) + self._write(data) + + +_store: SecretsStore | None = None + + +def init_secrets(path: Path, key: str) -> SecretsStore: + """Create the process-wide store (called once at startup).""" + global _store + _store = SecretsStore(path, key) + return _store + + +def get_secrets() -> SecretsStore: + if _store is None: + raise RuntimeError("Secrets store not initialised") + return _store + + +def get(name: str) -> str: + """Look up a secret by name — the entry point for custom node code.""" + return get_secrets().get(name) + + +def resolve_params(params: Any) -> Any: + """Replace every ``{"$secret": "name"}`` reference with its value.""" + if isinstance(params, dict): + if set(params) == {SECRET_REF} and isinstance(params[SECRET_REF], str): + return get(params[SECRET_REF]) + return {k: resolve_params(v) for k, v in params.items()} + if isinstance(params, list): + return [resolve_params(v) for v in params] + return params diff --git a/backend/app/flow/state.py b/backend/app/flow/state.py index 09a53c8..42d1ca1 100644 --- a/backend/app/flow/state.py +++ b/backend/app/flow/state.py @@ -7,11 +7,12 @@ supporting both in-memory storage and Redis for distributed execution. from __future__ import annotations -import pickle +import json from abc import ABC, abstractmethod +from collections.abc import Iterator from contextlib import contextmanager from threading import RLock -from typing import Any, Iterator +from typing import Any import redis @@ -255,8 +256,8 @@ class RedisState(StateBackend): """ Redis-based state backend for distributed execution. - Supports automatic serialization using pickle and distributed locking. - Uses a namespace prefix to isolate different pipeline executions. + Values are stored as JSON — anything a node passes through must be + serializable. Uses a namespace prefix to isolate pipeline executions. :param host: Redis host address. :type host: str @@ -306,11 +307,11 @@ class RedisState(StateBackend): def _serialize(self, value: Any) -> bytes: """Serialize value for storage.""" - return pickle.dumps(value) + return json.dumps(value).encode() def _deserialize(self, data: bytes | None) -> Any: """Deserialize value from storage.""" - return pickle.loads(data) if data else None + return json.loads(data) if data else None def get(self, key: str, default: Any = None) -> Any: data = self._client.get(self._key(key)) @@ -418,7 +419,7 @@ class RedisState(StateBackend): values = self._client.mget(full_keys) result = {} - for key, value in zip(keys, values): + for key, value in zip(keys, values, strict=True): result[key] = self._deserialize(value) if value is not None else None return result diff --git a/backend/app/flow/store.py b/backend/app/flow/store.py new file mode 100644 index 0000000..486c9d7 --- /dev/null +++ b/backend/app/flow/store.py @@ -0,0 +1,154 @@ +"""Flows on disk, versioned with git. + +Every flow is a directory: ``flow.json`` for the structure, ``nodes/*.py`` for +node logic. The whole tree is a git repository and each saved change is a +commit, so a flow's history is readable with ordinary git tooling and two +flows never collide in one file. +""" + +from __future__ import annotations + +import logging +import shutil +import subprocess +from pathlib import Path + +from app.flow.schemas import FlowDef + +logger = logging.getLogger(__name__) + +DEFAULT_SOURCE = '''"""A new node. Return a dict keyed by your output ports.""" + + +def process(params): + return {} +''' + + +class FlowNotFound(KeyError): + def __init__(self, name: str) -> None: + super().__init__(name) + self.name = name + + def __str__(self) -> str: + return f"No flow named '{self.name}'" + + +class FlowStore: + """Reads and writes flows, committing every change.""" + + def __init__(self, root: Path) -> None: + self.root = root + self.root.mkdir(parents=True, exist_ok=True) + if not (self.root / ".git").exists(): + self._git("init", "-q") + self._commit("Initialise flow store", allow_empty=True) + + # ------------------------------------------------------------------------- + # git + # ------------------------------------------------------------------------- + + def _git(self, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", "-C", str(self.root), *args], + capture_output=True, + text=True, + check=False, + ) + + def _commit(self, message: str, allow_empty: bool = False) -> None: + self._git("add", "-A") + result = self._git( + "-c", + "user.name=fluksio", + "-c", + "user.email=fluksio@localhost", + "commit", + "-q", + *(["--allow-empty"] if allow_empty else []), + "-m", + message, + ) + if result.returncode != 0 and "nothing to commit" not in result.stdout: + logger.warning("Could not commit flow change: %s", result.stdout.strip()) + + # ------------------------------------------------------------------------- + # Paths + # ------------------------------------------------------------------------- + + def _flow_dir(self, name: str) -> Path: + return self.root / name + + def _flow_file(self, name: str) -> Path: + return self._flow_dir(name) / "flow.json" + + def _node_file(self, flow: str, node_id: str) -> Path: + return self._flow_dir(flow) / "nodes" / f"{node_id}.py" + + # ------------------------------------------------------------------------- + # Flows + # ------------------------------------------------------------------------- + + def list_flows(self) -> list[str]: + return sorted(path.parent.name for path in self.root.glob("*/flow.json")) + + def exists(self, name: str) -> bool: + return self._flow_file(name).exists() + + def read_flow(self, name: str) -> FlowDef: + path = self._flow_file(name) + if not path.exists(): + raise FlowNotFound(name) + return FlowDef.model_validate_json(path.read_text()) + + def read_all(self) -> list[FlowDef]: + flows = [] + for name in self.list_flows(): + try: + flows.append(self.read_flow(name)) + except Exception: + logger.exception("Skipping unreadable flow '%s'", name) + return flows + + def write_flow(self, flow: FlowDef) -> bool: + """Save a flow. Returns False when nothing actually changed.""" + path = self._flow_file(flow.name) + content = flow.model_dump_json(indent=2) + "\n" + if path.exists() and path.read_text() == content: + return False + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + self._commit(f"Update flow '{flow.name}'") + return True + + def delete_flow(self, name: str) -> None: + directory = self._flow_dir(name) + if not directory.exists(): + raise FlowNotFound(name) + shutil.rmtree(directory) + self._commit(f"Delete flow '{name}'") + + # ------------------------------------------------------------------------- + # Node source + # ------------------------------------------------------------------------- + + def read_node_source(self, flow: str, node_id: str) -> str: + path = self._node_file(flow, node_id) + return path.read_text() if path.exists() else DEFAULT_SOURCE + + def write_node_source(self, flow: str, node_id: str, code: str) -> bool: + path = self._node_file(flow, node_id) + if path.exists() and path.read_text() == code: + return False + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(code) + self._commit(f"Update node '{flow}.{node_id}'") + return True + + def delete_node_source(self, flow: str, node_id: str) -> None: + path = self._node_file(flow, node_id) + if path.exists(): + path.unlink() + self._commit(f"Delete node '{flow}.{node_id}'") diff --git a/backend/app/flow/test.py b/backend/app/flow/test.py deleted file mode 100644 index 69a4a04..0000000 --- a/backend/app/flow/test.py +++ /dev/null @@ -1,73 +0,0 @@ -from pipeline import Pipeline -from state import MemoryState, RedisState -from dag_generator import ( - generate_multi_pipeline_dag, - dag_to_pipeline_nodes, -) -import time -import numpy as np -import threading - - -# Thread-safe RNG -rng_lock = threading.Lock() -rng = np.random.default_rng(1000) - -# Parameters for nodes -params = {"rng": rng, "rng_lock": rng_lock} - -state = RedisState( - host="localhost", - port=6379, - namespace="fluksio_pipeline", - ttl=3600, # Keys expire after 1 hour -) -print("Using Redis state backend") -assert state.ping(), "Can't connect to redis" - -n_pipelines = 30 -n_nodes_per_pipeline = 100 - -print("\n=== Generating multi-pipeline DAG ===") -start = time.time() -pipeline_graphs, combined_graph = generate_multi_pipeline_dag( - num_pipelines=n_pipelines, - nodes_per_pipeline=n_nodes_per_pipeline, - cross_pipeline_edges=2, - edge_probability=0.4, - seed=5, -) - -# Create pipelines from each sub-graph -child_pipelines = [] -all_trigger_nodes = [] - -for i, G in enumerate(pipeline_graphs): - nodes_i, _, triggers_i = dag_to_pipeline_nodes(G, params=params, seed=42 + i) - child_pipelines.append(Pipeline(nodes=nodes_i)) - all_trigger_nodes.extend(triggers_i) - print(f"Pipeline {i}: {len(nodes_i)} nodes, {len(triggers_i)} triggers") - -# Compose into parent pipeline -pipeline = Pipeline( - pipelines=child_pipelines, - max_workers=4, - state=state, -) - -print(f"Generated DAG in {time.time() - start:.2f} seconds") - -# Visualize the complete pipeline -if n_pipelines * n_nodes_per_pipeline < 1000: - pipeline.visualize("composed_pipeline.png") - -# ============================================================================= -# Run the pipeline by triggering source nodes -# ============================================================================= -print(f"\n=== Triggering {len(all_trigger_nodes)} source nodes ===") -for i, trigger_node in enumerate(all_trigger_nodes): - print(f"Triggering node {i + 1}/{len(all_trigger_nodes)}: {trigger_node.name}") - pipeline.get_node_by_id(trigger_node.id).trigger() - print(f"State contains {len(list(pipeline._state.keys()))} values") - -print("\n=== Pipeline execution complete ===") diff --git a/backend/app/flow/test_controller.py b/backend/app/flow/test_controller.py deleted file mode 100644 index 0470e2f..0000000 --- a/backend/app/flow/test_controller.py +++ /dev/null @@ -1,194 +0,0 @@ -""" -Test script for the PipelineController. - -This script demonstrates the controller's ability to: -1. Scan a nodes directory and build a pipeline -2. Watch for file changes and update the pipeline dynamically -3. Handle errors gracefully without breaking the pipeline -4. Accept node code from external sources (simulating frontend input) -""" - -import asyncio -import sys -from pathlib import Path - -# Add flow directory to path for imports -sys.path.insert(0, str(Path(__file__).parent)) - -from controller import PipelineController, NodeStatus -from state import RedisState, MemoryState - - -async def main(): - """Run the controller test.""" - - print("=" * 60) - print("Pipeline Controller Test") - print("=" * 60) - - # Use Redis if available, otherwise fall back to memory - try: - state = RedisState( - host="localhost", - port=6379, - namespace="fluksio_controller_test", - ttl=3600, - ) - if state.ping(): - print("✓ Using Redis state backend") - else: - raise ConnectionError("Redis ping failed") - except Exception as e: - print(f"⚠ Redis not available ({e}), using MemoryState") - state = MemoryState() - - # Create controller pointing to example nodes - nodes_dir = Path(__file__).parent / "nodes_example" - - controller = PipelineController( - nodes_dir=nodes_dir, - state_backend=state, - max_workers=4, - node_params={"alert_threshold": 70.0}, # Custom param for alert node - ) - - # Register callbacks to see what's happening - controller.on_node_loaded( - lambda nid, node: print( - f" ✓ Loaded: {nid} ({len(node.requires)} inputs, {len(node.provides)} outputs)" - ) - ) - controller.on_node_error( - lambda nid, err: print(f" ✗ Error in {nid}: {err.split(chr(10))[0]}") - ) - controller.on_node_removed(lambda nid: print(f" ⊘ Removed: {nid}")) - controller.on_pipeline_rebuilt( - lambda p: print( - f" ⟳ Pipeline rebuilt: {len(p.nodes)} nodes, {len(p.dependencies)} dependencies" - ) - ) - - # Start the controller - print("\n--- Starting Controller ---") - await controller.start() - - # Show current state - print("\n--- Current State ---") - print(f"Active nodes: {[n.name for n in controller.active_nodes]}") - print(f"Error nodes: {list(controller.error_nodes.keys())}") - - if controller.pipeline: - print(f"\nPipeline dependency graph:") - for node, deps in controller.pipeline.dependencies.items(): - dep_names = [d.name for d in deps] if deps else ["(trigger)"] - print(f" {node.name} <- {dep_names}") - - # Test triggering nodes - print("\n--- Testing Node Triggers ---") - - if controller.pipeline: - # Find trigger nodes (nodes with no dependencies) - trigger_nodes = [ - n - for n in controller.active_nodes - if not controller.pipeline.dependencies.get(n) - ] - - print(f"Trigger nodes: {[n.name for n in trigger_nodes]}") - - # Trigger each trigger node - for node in trigger_nodes: - print(f"\nTriggering: {node.name}") - try: - node.trigger() - except Exception as e: - print(f" Error: {e}") - - # Show state after triggers - print(f"\nState after triggers:") - for key in controller.state_backend.keys(): - value = controller.state_backend.get(key) - print(f" {key}: {value}") - - # Test dynamic node addition - print("\n--- Testing Dynamic Node Addition ---") - - new_node_code = ''' -"""Dynamically added node that logs all sensor data.""" - -from nodes import Node -from util import Message - - -def create_node(params): - def log_data(params, temperature=None, humidity=None, comfort_index=None, **kwargs): - """Log all available sensor data.""" - print(f"[data_logger] Logging: T={temperature}, H={humidity}, Comfort={comfort_index}") - return {"log_entry": f"T={temperature}, H={humidity}, C={comfort_index}"} - - return Node( - f=log_data, - requires=[ - Message(name="temperature", dtype=float), - Message(name="humidity", dtype=float), - Message(name="comfort_index", dtype=float), - ], - provides=[Message(name="log_entry", dtype=str)], - params=params, - name="data_logger", - ) -''' - - print("Adding 'data_logger' node from code...") - controller.add_node_from_code("data_logger", new_node_code) - - # Wait for file watcher to pick up the change - await asyncio.sleep(1.0) - - print(f"\nActive nodes after addition: {[n.name for n in controller.active_nodes]}") - - # Test adding a broken node - print("\n--- Testing Error Handling ---") - - broken_node_code = ''' -"""This node has an intentional error.""" - -from nodes import Node -from util import Message - - -def create_node(params): - # This will cause a NameError - return undefined_variable_that_does_not_exist -''' - - print("Adding 'broken_node' with intentional error...") - controller.add_node_from_code("broken_node", broken_node_code) - - await asyncio.sleep(1.0) - - print( - f"\nActive nodes (should exclude broken): {[n.name for n in controller.active_nodes]}" - ) - print(f"Error nodes: {list(controller.error_nodes.keys())}") - - # The pipeline should still work with the other nodes - if controller.pipeline: - print(f"\nPipeline still has {len(controller.pipeline.nodes)} working nodes") - - # Clean up test nodes - print("\n--- Cleaning Up ---") - controller.remove_node("data_logger") - controller.remove_node("broken_node") - - await asyncio.sleep(0.5) - - print(f"Active nodes after cleanup: {[n.name for n in controller.active_nodes]}") - - # Stop the controller - await controller.stop() - print("\n✓ Controller stopped") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/backend/app/flow/test_http_node.py b/backend/app/flow/test_http_node.py deleted file mode 100644 index 750892f..0000000 --- a/backend/app/flow/test_http_node.py +++ /dev/null @@ -1,270 +0,0 @@ -""" -Test script for HttpNode functionality. - -This script demonstrates both trigger (receiver) and sender modes of HttpNode, -including integration with FastAPI and the PipelineController. -""" - -import asyncio -import sys -from pathlib import Path - -# Add flow directory to path for imports -sys.path.insert(0, str(Path(__file__).parent)) - -from fastapi import FastAPI -from starlette.testclient import TestClient - -from nodes import HttpNode, Node -from pipeline import Pipeline -from state import MemoryState -from util import Message -from controller import PipelineController - - -def test_http_node_modes(): - """Test HttpNode mode detection.""" - print("=" * 60) - print("Testing HttpNode Mode Detection") - print("=" * 60) - - # Trigger mode: only provides - trigger = HttpNode( - url="/api/data", - method="POST", - provides=[Message(name="value", dtype=float)], - ) - print(f"✓ Trigger node mode: {trigger.mode}") - assert trigger.mode == HttpNode.Mode.TRIGGER - - # Sender mode: has requires - sender = HttpNode( - url="https://httpbin.org/post", - method="POST", - requires=[Message(name="value", dtype=float)], - ) - print(f"✓ Sender node mode: {sender.mode}") - assert sender.mode == HttpNode.Mode.SENDER - - # Both requires and provides = sender mode - hybrid = HttpNode( - url="https://httpbin.org/post", - method="POST", - requires=[Message(name="input", dtype=float)], - provides=[Message(name="output", dtype=float)], - ) - print(f"✓ Hybrid node mode: {hybrid.mode}") - assert hybrid.mode == HttpNode.Mode.SENDER - - # Error: neither requires nor provides - try: - invalid = HttpNode(url="/api/nothing", method="GET") - assert False, "Should have raised ValueError" - except ValueError as e: - print(f"✓ Correctly rejected invalid config: {e}") - - print("\n✓ All mode detection tests passed!\n") - - -def test_http_trigger_with_fastapi(): - """Test HTTP trigger node with FastAPI.""" - print("=" * 60) - print("Testing HTTP Trigger with FastAPI") - print("=" * 60) - - # Create FastAPI app - app = FastAPI() - - # Create a simple pipeline with HTTP trigger - trigger = HttpNode( - url="/api/sensors/temperature", - method="POST", - provides=[ - Message(name="temperature", dtype=float), - Message(name="sensor_id", dtype=str), - ], - name="temp_trigger", - ) - - # Create a processing node - def process_temp(params, temperature=0.0, sensor_id="unknown", **kwargs): - celsius = temperature - fahrenheit = celsius * 9 / 5 + 32 - print(f"[processor] Sensor {sensor_id}: {celsius}°C = {fahrenheit}°F") - return {"fahrenheit": fahrenheit} - - processor = Node( - f=process_temp, - requires=[ - Message(name="temperature", dtype=float), - Message(name="sensor_id", dtype=str), - ], - provides=[Message(name="fahrenheit", dtype=float)], - params={}, - name="temp_processor", - ) - - # Build pipeline - state = MemoryState() - pipeline = Pipeline( - nodes=[trigger, processor], - state=state, - max_workers=2, - ) - - # Register the HTTP route - trigger.register_route(app) - - # Test with TestClient - client = TestClient(app) - - print("\nSending POST request to /api/sensors/temperature...") - response = client.post( - "/api/sensors/temperature", - json={"temperature": 25.5, "sensor_id": "sensor_001"}, - ) - - print(f"Response status: {response.status_code}") - print(f"Response body: {response.json()}") - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "triggered" - assert data["node"] == "temp_trigger" - - # Check pipeline state - print(f"\nPipeline state after trigger:") - for key in state.keys(): - print(f" {key}: {state.get(key)}") - - print("\n✓ HTTP trigger test passed!\n") - - -def test_http_sender(): - """Test HTTP sender node.""" - print("=" * 60) - print("Testing HTTP Sender") - print("=" * 60) - - # Create a sender node that posts to httpbin - sender = HttpNode( - url="https://httpbin.org/post", - method="POST", - requires=[ - Message(name="temperature", dtype=float), - Message(name="humidity", dtype=float), - ], - name="data_sender", - params=dict( - timeout=10.0, - headers={"X-Custom-Header": "fluksio-test"}, - ), - ) - - print(f"Sender node created: {sender.name}") - print(f" URL: {sender.url}") - print(f" Method: {sender.method}") - print(f" Mode: {sender.mode}") - - # Note: Actual HTTP call would need async context or be mocked - # For this test, we just verify the node is configured correctly - print("\n✓ HTTP sender configuration test passed!\n") - - -async def test_controller_with_http_nodes(): - """Test PipelineController with HTTP nodes.""" - print("=" * 60) - print("Testing Controller with HTTP Nodes") - print("=" * 60) - - import tempfile - - # Create FastAPI app - app = FastAPI() - - with tempfile.TemporaryDirectory() as tmpdir: - nodes_dir = Path(tmpdir) / "nodes" - nodes_dir.mkdir() - - # Create an HTTP trigger node file - trigger_code = """ -from nodes import HttpNode -from util import Message - -def create_node(params): - return HttpNode( - url="/api/webhook", - method="POST", - provides=[ - Message(name="event_type", dtype=str), - Message(name="payload", dtype=object), - ], - name="webhook_receiver", - ) -""" - (nodes_dir / "webhook.py").write_text(trigger_code) - - # Create controller with FastAPI app - controller = PipelineController( - nodes_dir=nodes_dir, - max_workers=2, - fastapi_app=app, - ) - - # Register callbacks - controller.on_node_loaded( - lambda nid, node: print( - f" ✓ Loaded: {nid} (mode: {getattr(node, 'mode', 'N/A')})" - ) - ) - - # Start controller - print("\nStarting controller...") - await controller.start() - - print(f"\nActive nodes: {[n.name for n in controller.active_nodes]}") - print(f"HTTP trigger nodes: {[n.name for n in controller.http_trigger_nodes]}") - print(f"HTTP sender nodes: {[n.name for n in controller.http_sender_nodes]}") - - # Check that route was registered - route_paths = [r.path for r in app.routes if hasattr(r, "path")] - print(f"\nRegistered routes: {route_paths}") - - if "/api/webhook" in route_paths: - print("✓ Webhook route registered!") - - # Test the endpoint - client = TestClient(app) - response = client.post( - "/api/webhook", - json={"event_type": "test", "payload": {"key": "value"}}, - ) - print(f"Webhook response: {response.json()}") - - # Stop controller - await controller.stop() - - print("\n✓ Controller with HTTP nodes test passed!\n") - - -def main(): - """Run all tests.""" - print("\n" + "=" * 60) - print("HttpNode Test Suite") - print("=" * 60 + "\n") - - # Synchronous tests - test_http_node_modes() - test_http_trigger_with_fastapi() - test_http_sender() - - # Async tests - asyncio.run(test_controller_with_http_nodes()) - - print("=" * 60) - print("All tests completed successfully!") - print("=" * 60) - - -if __name__ == "__main__": - main() diff --git a/backend/app/flow/test_influxdb_node.py b/backend/app/flow/test_influxdb_node.py deleted file mode 100644 index cb4430e..0000000 --- a/backend/app/flow/test_influxdb_node.py +++ /dev/null @@ -1,447 +0,0 @@ -""" -Test script for InfluxDbNode functionality. - -This script demonstrates write and read operations with InfluxDB. -Requires an InfluxDB instance running. - -To run a local InfluxDB instance: - docker run -d -p 8086:8086 \ - -e DOCKER_INFLUXDB_INIT_MODE=setup \ - -e DOCKER_INFLUXDB_INIT_USERNAME=admin \ - -e DOCKER_INFLUXDB_INIT_PASSWORD=adminpass \ - -e DOCKER_INFLUXDB_INIT_ORG=my-org \ - -e DOCKER_INFLUXDB_INIT_BUCKET=sensors \ - -e DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=my-super-secret-token \ - influxdb:2.7 -""" - -import asyncio -import sys -import time -from pathlib import Path - -# Add flow directory to path for imports -sys.path.insert(0, str(Path(__file__).parent)) - -from nodes import InfluxDbNode, Node -from pipeline import Pipeline -from state import MemoryState -from util import Message - - -# Default test configuration -TEST_CONFIG = { - "url": "http://10.200.200.115:8086", - "token": "***REMOVED-INFLUXDB-TOKEN***==", - "org": "strobl", - "bucket": "test", -} - - -def test_influxdb_node_validation(): - """Test InfluxDbNode parameter validation.""" - print("=" * 60) - print("Testing InfluxDbNode Validation") - print("=" * 60) - - # Test missing required params - try: - node = InfluxDbNode( - requires=[Message(name="data", dtype=float)], - params={"url": "http://localhost:8086"}, # Missing token, org, bucket - ) - assert False, "Should have raised ValueError" - except ValueError as e: - print(f"✓ Correctly rejected missing params: {e}") - - # Test neither requires nor provides - try: - node = InfluxDbNode(params=TEST_CONFIG) - assert False, "Should have raised ValueError" - except ValueError as e: - print(f"✓ Correctly rejected empty requires/provides: {e}") - - # Test valid write-only node - writer = InfluxDbNode( - requires=[Message(name="temperature", dtype=float)], - params={ - **TEST_CONFIG, - "writes": { - "temperature": { - "measurement": "sensor_data", - "field": "temp_celsius", - "tags": {"location": "room1"}, - } - }, - }, - ) - print(f"✓ Created write-only node: {writer.name}") - assert len(writer.requires) == 1 - assert len(writer.provides) == 0 - - # Test valid read-only node - reader = InfluxDbNode( - provides=[Message(name="avg_temperature", dtype=float)], - params={ - **TEST_CONFIG, - "queries": { - "avg_temperature": { - "measurement": "sensor_data", - "field": "temp_celsius", - "aggregation": "mean", - } - }, - }, - ) - print(f"✓ Created read-only node: {reader.name}") - assert len(reader.requires) == 0 - assert len(reader.provides) == 1 - - # Test combined read/write node - combined = InfluxDbNode( - requires=[Message(name="raw_temp", dtype=float)], - provides=[Message(name="avg_temp", dtype=float)], - params={ - **TEST_CONFIG, - "writes": { - "raw_temp": { - "measurement": "temperature", - "field": "value", - "tags": {"source": "sensor"}, - } - }, - "queries": { - "avg_temp": { - "measurement": "temperature", - "field": "value", - "aggregation": "mean", - } - }, - }, - ) - print(f"✓ Created combined node: {combined.name}") - assert len(combined.requires) == 1 - assert len(combined.provides) == 1 - - print("\n✓ All validation tests passed!\n") - - -def test_flux_query_building(): - """Test Flux query string generation.""" - print("=" * 60) - print("Testing Flux Query Building") - print("=" * 60) - - node = InfluxDbNode( - provides=[Message(name="value", dtype=float)], - params={ - **TEST_CONFIG, - "queries": { - "value": { - "measurement": "temperature", - "field": "celsius", - "tags": {"location": "room1", "sensor": "dht22"}, - "range": "-24h", - "aggregation": "mean", - } - }, - }, - ) - - query = node._build_flux_query( - measurement="temperature", - field="celsius", - tags={"location": "room1", "sensor": "dht22"}, - time_range="-24h", - aggregation="mean", - ) - - print(f"Generated Flux query:\n{query}\n") - - # Verify query components - assert 'from(bucket: "test")' in query - assert "range(start: -24h)" in query - assert 'r["_measurement"] == "temperature"' in query - assert 'r["_field"] == "celsius"' in query - assert 'r["location"] == "room1"' in query - assert 'r["sensor"] == "dht22"' in query - assert "mean()" in query - - print("✓ Query contains all expected components") - - # Test different aggregations - for agg in ["last", "first", "max", "min", "sum", "count"]: - query = node._build_flux_query( - measurement="test", - field="value", - tags={}, - time_range="-1h", - aggregation=agg, - ) - assert f"{agg}()" in query - print(f"✓ Aggregation '{agg}' works") - - print("\n✓ All query building tests passed!\n") - - -def test_influxdb_write(skip_if_no_server: bool = True): - """Test writing data to InfluxDB.""" - print("=" * 60) - print("Testing InfluxDB Write") - print("=" * 60) - - # Create writer node with write configuration - writer = InfluxDbNode( - requires=[Message(name="temperature", dtype=float)], - params={ - **TEST_CONFIG, - "writes": { - "temperature": { - "measurement": "test_temperature", - "field": "value", - "tags": {"location": "test_room", "sensor": "test_sensor"}, - } - }, - }, - name="test_writer", - ) - - # Build a simple pipeline - state = MemoryState() - pipeline = Pipeline(nodes=[writer], state=state, max_workers=1) - - # Test data to write - now just a simple value! - test_data = {"temperature": 25.5} - - print(f"Attempting to write: {test_data}") - - try: - writer._write_points(test_data) - print("✓ Write successful!") - except Exception as e: - if skip_if_no_server: - print(f"⚠ Write failed (server may not be running): {e}") - else: - raise - - # Test with runtime tags override - test_data_with_tags = { - "temperature": {"value": 26.0, "tags": {"sensor": "override_sensor"}} - } - print(f"Attempting to write with runtime tags: {test_data_with_tags}") - - try: - writer._write_points(test_data_with_tags) - print("✓ Write with runtime tags successful!") - except Exception as e: - if skip_if_no_server: - print(f"⚠ Write failed (server may not be running): {e}") - else: - raise - - print("\n✓ Write test completed!\n") - - -def test_influxdb_read(skip_if_no_server: bool = True): - """Test reading data from InfluxDB.""" - print("=" * 60) - print("Testing InfluxDB Read") - print("=" * 60) - - # Create reader node - reader = InfluxDbNode( - provides=[ - Message(name="last_temp", dtype=float), - Message(name="avg_temp", dtype=float), - ], - params={ - **TEST_CONFIG, - "query_range": "-1h", - "queries": { - "last_temp": { - "measurement": "test_temperature", - "field": "value", - "tags": {"location": "test_room"}, - "aggregation": "last", - }, - "avg_temp": { - "measurement": "test_temperature", - "field": "value", - "tags": {"location": "test_room"}, - "aggregation": "mean", - }, - }, - }, - name="test_reader", - ) - - print(f"Attempting to query data...") - - try: - results = reader._query_data() - print(f"✓ Query successful! Results: {results}") - - if results: - for key, value in results.items(): - print(f" {key}: {value}") - else: - print(" No data found (this is OK if no data was written)") - except Exception as e: - if skip_if_no_server: - print(f"⚠ Query failed (server may not be running): {e}") - else: - raise - - print("\n✓ Read test completed!\n") - - -def test_influxdb_pipeline_integration(skip_if_no_server: bool = True): - """Test InfluxDbNode in a complete pipeline.""" - print("=" * 60) - print("Testing InfluxDB Pipeline Integration") - print("=" * 60) - - # Create a source node that generates sensor data (now just a float!) - def generate_sensor_data(params, **kwargs): - """Generate test sensor data.""" - import random - - return {"temperature": 20.0 + random.random() * 10} - - source = Node( - f=generate_sensor_data, - requires=[], - provides=[Message(name="temperature", dtype=float)], - params={}, - name="sensor_source", - ) - - # Create InfluxDB writer that receives the sensor data - writer = InfluxDbNode( - requires=[Message(name="temperature", dtype=float)], - params={ - **TEST_CONFIG, - "writes": { - "temperature": { - "measurement": "pipeline_test", - "field": "temp_value", - "tags": { - "source": "test_pipeline", - "run_id": str(int(time.time())), - }, - } - }, - }, - name="influx_writer", - ) - - # Build pipeline - state = MemoryState() - pipeline = Pipeline( - nodes=[source, writer], - state=state, - max_workers=2, - ) - - print(f"Pipeline created with nodes: {[n.name for n in pipeline.nodes]}") - print(f"Dependencies: {pipeline.dependencies}") - - try: - # Trigger the source node to start the pipeline - print("\nTriggering source node...") - result = source.inject({}) # Source has no inputs, just generates output - - print(f"Pipeline execution result: {result}") - print("✓ Pipeline integration successful!") - except Exception as e: - if skip_if_no_server: - print(f"⚠ Pipeline test failed (server may not be running): {e}") - else: - raise - - print("\n✓ Pipeline integration test completed!\n") - - -async def test_influxdb_async_operations(skip_if_no_server: bool = True): - """Test async write and query operations.""" - print("=" * 60) - print("Testing InfluxDB Async Operations") - print("=" * 60) - - # Create a combined node with write and query configs - node = InfluxDbNode( - requires=[Message(name="temperature", dtype=float)], - provides=[Message(name="last_value", dtype=float)], - params={ - **TEST_CONFIG, - "writes": { - "temperature": { - "measurement": "async_test", - "field": "value", - "tags": {"test": "async"}, - } - }, - "queries": { - "last_value": { - "measurement": "async_test", - "field": "value", - "aggregation": "last", - } - }, - }, - name="async_test_node", - ) - - try: - # Test async write - now just a simple value! - print("Testing async write...") - await node.write_async({"temperature": 42.0}) - print("✓ Async write successful!") - - # Wait a moment for the write to be visible - await asyncio.sleep(0.5) - - # Test async query - print("Testing async query...") - results = await node.query_async() - print(f"✓ Async query successful! Results: {results}") - - except Exception as e: - if skip_if_no_server: - print(f"⚠ Async test failed (server may not be running): {e}") - else: - raise - - print("\n✓ Async operations test completed!\n") - - -def main(): - """Run all tests.""" - print("\n" + "=" * 60) - print("InfluxDbNode Test Suite") - print("=" * 60 + "\n") - - # Synchronous tests (always run) - test_influxdb_node_validation() - test_flux_query_building() - - # Tests that require InfluxDB server - print("\n" + "-" * 60) - print("The following tests require InfluxDB on localhost:8086") - print("-" * 60 + "\n") - - try: - test_influxdb_write(skip_if_no_server=True) - test_influxdb_read(skip_if_no_server=True) - test_influxdb_pipeline_integration(skip_if_no_server=True) - asyncio.run(test_influxdb_async_operations(skip_if_no_server=True)) - except KeyboardInterrupt: - print("\nTests interrupted by user") - - print("=" * 60) - print("All tests completed!") - print("=" * 60) - - -if __name__ == "__main__": - main() diff --git a/backend/app/flow/test_mqtt_node.py b/backend/app/flow/test_mqtt_node.py deleted file mode 100644 index 70ae975..0000000 --- a/backend/app/flow/test_mqtt_node.py +++ /dev/null @@ -1,334 +0,0 @@ -""" -Test script for MqttNode functionality. - -This script demonstrates both subscriber (trigger) and publisher (sender) modes -of MqttNode. Requires an MQTT broker running (e.g., mosquitto). - -To run a local mosquitto broker: - docker run -it -p 1883:1883 eclipse-mosquitto mosquitto -c /mosquitto-no-auth.conf - -Or install locally: - sudo apt install mosquitto mosquitto-clients - sudo systemctl start mosquitto -""" - -import asyncio -import sys -import json -from pathlib import Path - -# Add flow directory to path for imports -sys.path.insert(0, str(Path(__file__).parent)) - -from nodes import MqttNode, Node -from pipeline import Pipeline -from state import MemoryState -from util import Message - -host = "127.0.0.1" - - -def test_mqtt_node_modes(): - """Test MqttNode mode detection.""" - print("=" * 60) - print("Testing MqttNode Mode Detection") - print("=" * 60) - - # Subscriber mode: only provides - subscriber = MqttNode( - topic="sensors/temperature", - provides=[Message(name="temperature", dtype=float)], - params={"broker_host": host}, - ) - print(f"✓ Subscriber node mode: {subscriber.mode}") - assert subscriber.mode == MqttNode.Mode.SUBSCRIBER - - # Publisher mode: has requires - publisher = MqttNode( - topic="actuators/hvac", - requires=[Message(name="target_temp", dtype=float)], - params={"broker_host": host}, - ) - print(f"✓ Publisher node mode: {publisher.mode}") - assert publisher.mode == MqttNode.Mode.PUBLISHER - - # Both requires and provides = publisher mode - hybrid = MqttNode( - topic="devices/thermostat", - requires=[Message(name="input", dtype=float)], - provides=[Message(name="output", dtype=float)], - params={"broker_host": host}, - ) - print(f"✓ Hybrid node mode: {hybrid.mode}") - assert hybrid.mode == MqttNode.Mode.PUBLISHER - - # Error: neither requires nor provides - try: - invalid = MqttNode(topic="invalid/topic", params={}) - assert False, "Should have raised ValueError" - except ValueError as e: - print(f"✓ Correctly rejected invalid config: {e}") - - print("\n✓ All mode detection tests passed!\n") - - -def test_mqtt_node_config(): - """Test MqttNode configuration from params.""" - print("=" * 60) - print("Testing MqttNode Configuration") - print("=" * 60) - - node = MqttNode( - topic="test/topic", - provides=[Message(name="value", dtype=float)], - params={ - "broker_host": "mqtt.example.com", - "broker_port": 8883, - "username": "user", - "password": "secret", - "client_id": "test-client", - "qos": 2, - "retain": True, - "keepalive": 120, - }, - ) - - assert node.broker_host == "mqtt.example.com" - assert node.broker_port == 8883 - assert node.username == "user" - assert node.password == "secret" - assert node.client_id == "test-client" - assert node.qos == 2 - assert node.retain is True - assert node.keepalive == 120 - - print(f"✓ broker_host: {node.broker_host}") - print(f"✓ broker_port: {node.broker_port}") - print(f"✓ username: {node.username}") - print(f"✓ qos: {node.qos}") - print(f"✓ retain: {node.retain}") - print(f"✓ keepalive: {node.keepalive}") - - print("\n✓ Configuration test passed!\n") - - -async def test_mqtt_publisher(): - """Test MQTT publisher node (requires broker).""" - print("=" * 60) - print("Testing MQTT Publisher") - print("=" * 60) - - publisher = MqttNode( - topic="test/fluksio/output", - requires=[ - Message(name="temperature", dtype=float), - Message(name="humidity", dtype=float), - ], - params={ - "broker_host": host, - "broker_port": 1883, - "qos": 1, - }, - name="test_publisher", - ) - - print(f"Publisher node created: {publisher.name}") - print(f" Topic: {publisher.topic}") - print(f" Mode: {publisher.mode}") - print(f" Broker: {publisher.broker_host}:{publisher.broker_port}") - - # Try to publish (will fail if no broker is running) - try: - # Create a simple pipeline to bind the node - state = MemoryState() - pipeline = Pipeline(nodes=[publisher], state=state, max_workers=1) - - # Directly call the publish method - await publisher._publish_message({"temperature": 25.5, "humidity": 60.0}) - print("✓ Published message successfully!") - except Exception as e: - print(f"⚠ Could not publish (broker may not be running): {e}") - - print("\n✓ Publisher test completed!\n") - - -async def test_mqtt_subscriber(): - """Test MQTT subscriber node (requires broker).""" - print("=" * 60) - print("Testing MQTT Subscriber") - print("=" * 60) - - # Create a subscriber node - subscriber = MqttNode( - topic="test/fluksio/input", - provides=[ - Message(name="value", dtype=float), - Message(name="unit", dtype=str), - ], - params={ - "broker_host": host, - "broker_port": 1883, - }, - name="test_subscriber", - ) - - # Create a processing node that will be triggered - received_data = [] - - def process_data(params, value=0.0, unit="unknown", **kwargs): - print(f"[processor] Received: value={value}, unit={unit}") - received_data.append({"value": value, "unit": unit}) - return {"processed": True} - - processor = Node( - f=process_data, - requires=[ - Message(name="value", dtype=float), - Message(name="unit", dtype=str), - ], - provides=[Message(name="processed", dtype=bool)], - params={}, - name="data_processor", - ) - - # Build pipeline - state = MemoryState() - pipeline = Pipeline( - nodes=[subscriber, processor], - state=state, - max_workers=2, - ) - - print(f"Subscriber node created: {subscriber.name}") - print(f" Topic: {subscriber.topic}") - print(f" Mode: {subscriber.mode}") - - # Try to start subscription - try: - await subscriber.start_subscription() - print("✓ Subscription started!") - - # Wait a bit for potential messages - print(" Listening for 3 seconds...") - await asyncio.sleep(3) - - # Stop subscription - await subscriber.stop_subscription() - print("✓ Subscription stopped!") - - if received_data: - print(f"✓ Received {len(received_data)} messages") - else: - print(" No messages received (publish to test/fluksio/input to test)") - - except Exception as e: - print(f"⚠ Could not subscribe (broker may not be running): {e}") - - print("\n✓ Subscriber test completed!\n") - - -async def test_mqtt_integration(): - """Test full MQTT pub/sub integration (requires broker).""" - print("=" * 60) - print("Testing MQTT Integration (Pub/Sub)") - print("=" * 60) - - import aiomqtt - - topic = "test/fluksio/integration" - - # Create subscriber - subscriber = MqttNode( - topic=topic, - provides=[Message(name="sensor_value", dtype=float)], - params={"broker_host": host}, - name="integration_subscriber", - ) - - # Track received messages - received_values = [] - - def track_value(params, sensor_value=0.0, **kwargs): - print(f"[tracker] Received sensor_value={sensor_value}") - received_values.append(sensor_value) - return {"tracked": sensor_value} - - tracker = Node( - f=track_value, - requires=[Message(name="sensor_value", dtype=float)], - provides=[Message(name="tracked", dtype=float)], - params={}, - name="value_tracker", - ) - - # Build pipeline - state = MemoryState() - pipeline = Pipeline( - nodes=[subscriber, tracker], - state=state, - max_workers=2, - ) - - try: - # Start subscriber - await subscriber.start_subscription() - print("✓ Subscriber started") - - # Publish some test messages - async with aiomqtt.Client(hostname=host) as client: - for i in range(3): - value = 20.0 + i * 5 - payload = json.dumps({"sensor_value": value}) - await client.publish(topic, payload) - print(f" Published: {payload}") - await asyncio.sleep(0.5) - - # Wait for messages to be processed - await asyncio.sleep(1) - - # Stop subscriber - await subscriber.stop_subscription() - print("✓ Subscriber stopped") - - # Check results - print(f"\nReceived values: {received_values}") - if len(received_values) == 3: - print("✓ All messages received and processed!") - else: - print(f"⚠ Expected 3 messages, got {len(received_values)}") - - except Exception as e: - print(f"⚠ Integration test failed (broker may not be running): {e}") - - print("\n✓ Integration test completed!\n") - - -def main(): - """Run all tests.""" - print("\n" + "=" * 60) - print("MqttNode Test Suite") - print("=" * 60 + "\n") - - # Synchronous tests (always run) - test_mqtt_node_modes() - test_mqtt_node_config() - - # Async tests (require broker) - print("\n" + "-" * 60) - print(f"The following tests require an MQTT broker on {host}:1883") - print("-" * 60 + "\n") - - try: - asyncio.run(test_mqtt_publisher()) - asyncio.run(test_mqtt_subscriber()) - asyncio.run(test_mqtt_integration()) - except KeyboardInterrupt: - print("\nTests interrupted by user") - - print("=" * 60) - print("All tests completed!") - print("=" * 60) - - -if __name__ == "__main__": - main() diff --git a/backend/app/flow/test_synchronous_nodes.py b/backend/app/flow/test_synchronous_nodes.py deleted file mode 100644 index fd2cdb3..0000000 --- a/backend/app/flow/test_synchronous_nodes.py +++ /dev/null @@ -1,512 +0,0 @@ -""" -Test synchronous node execution in pipelines. - -This module tests the "synchronous" flag which ensures a node only executes -when ALL its inputs have changed since the last execution. - -Synchronous nodes are useful for: -- Aggregation nodes that need all inputs to be "fresh" -- Nodes that should only process complete "batches" of data -- Avoiding redundant executions when multiple inputs update at different rates -""" - -import sys -import threading -import time -from pathlib import Path - -# Add flow directory to path for imports -sys.path.insert(0, str(Path(__file__).parent)) - -from state import MemoryState, RedisState -from pipeline import Pipeline -from nodes import Node -from util import Message - - -def test_synchronous_basic(): - """Test basic synchronous node behavior.""" - print("=" * 60) - print("Testing Basic Synchronous Node Behavior") - print("=" * 60) - - execution_log = [] - - def sensor_a(params, **kwargs): - value = params.get("value", 1.0) - execution_log.append(f"sensor_a -> {value}") - return {"temp_a": value} - - def sensor_b(params, **kwargs): - value = params.get("value", 2.0) - execution_log.append(f"sensor_b -> {value}") - return {"temp_b": value} - - def sync_processor(params, temp_a=0, temp_b=0, **kwargs): - result = temp_a + temp_b - execution_log.append(f"run_sync_processor({temp_a}, {temp_b}) -> {result}") - return {"combined": result} - - def async_processor(params, temp_a=0, temp_b=0, **kwargs): - result = temp_a * temp_b - execution_log.append(f"run_async_processor({temp_a}, {temp_b}) -> {result}") - return {"product": result} - - # Create nodes - node_a = Node( - f=sensor_a, - requires=[], - provides=[Message(name="temp_a", dtype=float)], - params={"value": 10.0}, - name="sensor_a", - ) - - node_b = Node( - f=sensor_b, - requires=[], - provides=[Message(name="temp_b", dtype=float)], - params={"value": 20.0}, - name="sensor_b", - ) - - # Synchronous node - only executes when BOTH inputs change - sync_node = Node( - f=sync_processor, - requires=[ - Message(name="temp_a", dtype=float), - Message(name="temp_b", dtype=float), - ], - provides=[Message(name="combined", dtype=float)], - params={"synchronous": True}, - name="sync_processor", - ) - - # Non-synchronous node - executes whenever any input is available - async_node = Node( - f=async_processor, - requires=[ - Message(name="temp_a", dtype=float), - Message(name="temp_b", dtype=float), - ], - provides=[Message(name="product", dtype=float)], - params={"synchronous": False}, - name="async_processor", - ) - - # Build pipeline - state = MemoryState() - pipeline = Pipeline( - nodes=[node_a, node_b, sync_node, async_node], - state=state, - max_workers=1, # Single worker for predictable ordering - ) - - print("\n--- Triggering sensor_a (first time) ---") - execution_log.clear() - node_a.inject({}) - print(f"Execution log: {execution_log}") - print(f"State: temp_a={state.get('temp_a')}, temp_b={state.get('temp_b')}") - - # Neither processor should run yet (temp_b missing) - assert "run_async_processor" not in str( - execution_log - ), "async_processor ran too early" - assert "run_sync_processor" not in str( - execution_log - ), "sync_processor ran too early" - print("✓ Neither processor ran (temp_b not yet available)") - - print("\n--- Triggering sensor_b (first time) ---") - execution_log.clear() - node_b.inject({}) - print(f"Execution log: {execution_log}") - - # Both should run now (first time both inputs are available) - assert "run_async_processor" in str( - execution_log - ), "async_processor should have run" - assert "run_sync_processor" in str(execution_log), "sync_processor should have run" - print("✓ Both processors ran (first time both inputs available)") - - print("\n--- Triggering sensor_a (second time) ---") - node_a.params["value"] = 15.0 - execution_log.clear() - node_a.inject({}) - print(f"Execution log: {execution_log}") - - # async_processor SHOULD run (any input change triggers it) - # sync_processor should NOT run (only temp_a changed, not temp_b) - assert "run_async_processor" in str( - execution_log - ), "async_processor should have run" - assert "run_sync_processor" not in str( - execution_log - ), "sync_processor should NOT have run (only temp_a changed)" - print("✓ Only async_processor ran (temp_b hasn't changed)") - - print("\n--- Triggering sensor_b (second time) ---") - node_b.params["value"] = 25.0 - execution_log.clear() - node_b.inject({}) - print(f"Execution log: {execution_log}") - - # Both should run now (both inputs have changed since last sync execution) - assert "run_async_processor" in str( - execution_log - ), "async_processor should have run" - assert "run_sync_processor" in str( - execution_log - ), "sync_processor should have run (both inputs changed)" - print("✓ Both processors ran (both inputs have changed)") - - # Verify final state values - assert ( - pipeline.state.get("combined") == 40.0 - ), f"Expected combined=40.0, got {pipeline.state.get('combined')}" - assert ( - pipeline.state.get("product") == 375.0 - ), f"Expected product=375.0, got {pipeline.state.get('product')}" - print( - f"\n✓ Final state: combined={pipeline.state.get('combined')}, product={pipeline.state.get('product')}" - ) - - print("\n✓ Basic synchronous test passed!\n") - - -def test_synchronous_multiple_triggers(): - """Test that synchronous nodes handle rapid successive triggers correctly.""" - print("=" * 60) - print("Testing Synchronous Node with Multiple Rapid Triggers") - print("=" * 60) - - sync_execution_count = {"count": 0} - async_execution_count = {"count": 0} - - def source_x(params, **kwargs): - return {"x": params.get("value", 1)} - - def source_y(params, **kwargs): - return {"y": params.get("value", 2)} - - def sync_consumer(params, x=0, y=0, **kwargs): - sync_execution_count["count"] += 1 - return {"sync_out": x + y} - - def async_consumer(params, x=0, y=0, **kwargs): - async_execution_count["count"] += 1 - return {"async_out": x * y} - - node_x = Node( - f=source_x, - requires=[], - provides=[Message(name="x", dtype=int)], - params={"value": 1}, - name="source_x", - ) - - node_y = Node( - f=source_y, - requires=[], - provides=[Message(name="y", dtype=int)], - params={"value": 1}, - name="source_y", - ) - - sync_node = Node( - f=sync_consumer, - requires=[Message(name="x", dtype=int), Message(name="y", dtype=int)], - provides=[Message(name="sync_out", dtype=int)], - params={"synchronous": True}, - name="sync_consumer", - ) - - async_node = Node( - f=async_consumer, - requires=[Message(name="x", dtype=int), Message(name="y", dtype=int)], - provides=[Message(name="async_out", dtype=int)], - params={"synchronous": False}, - name="async_consumer", - ) - - state = MemoryState() - pipeline = Pipeline( - nodes=[node_x, node_y, sync_node, async_node], - state=state, - max_workers=1, - ) - - # Initial trigger to get both inputs - node_x.inject({}) - node_y.inject({}) - - initial_sync = sync_execution_count["count"] - initial_async = async_execution_count["count"] - - print(f"After initial triggers: sync={initial_sync}, async={initial_async}") - assert initial_sync == 1, "sync_consumer should have run once initially" - assert initial_async == 1, "async_consumer should have run once initially" - - # Now trigger X multiple times without triggering Y - print("\nTriggering source_x 5 times without changing source_y...") - for i in range(5): - node_x.params["value"] = 10 + i - node_x.inject({}) - - final_sync = sync_execution_count["count"] - final_async = async_execution_count["count"] - - print(f"After 5 more X triggers: sync={final_sync}, async={final_async}") - - # sync_consumer should NOT have run again (Y didn't change) - assert ( - final_sync == initial_sync - ), f"sync_consumer should still be at {initial_sync}, got {final_sync}" - # async_consumer should have run 5 more times - assert ( - final_async == initial_async + 5 - ), f"async_consumer should be at {initial_async + 5}, got {final_async}" - - print("✓ Synchronous node correctly waited for both inputs to change") - - # Now trigger Y once - sync should run - print("\nTriggering source_y once...") - node_y.params["value"] = 100 - node_y.inject({}) - - after_y_sync = sync_execution_count["count"] - after_y_async = async_execution_count["count"] - - print(f"After Y trigger: sync={after_y_sync}, async={after_y_async}") - - assert after_y_sync == initial_sync + 1, f"sync_consumer should have run once more" - assert after_y_async == final_async + 1, f"async_consumer should have run once more" - - print("\n✓ Multiple triggers test passed!\n") - - -def test_synchronous_race_condition(): - """Test that synchronous nodes handle concurrent triggers correctly.""" - print("=" * 60) - print("Testing Synchronous Node Race Condition Handling") - print("=" * 60) - - execution_count = {"sync": 0, "async": 0} - execution_lock = threading.Lock() - - def source_a(params, **kwargs): - return {"data_a": params.get("value", 1)} - - def source_b(params, **kwargs): - return {"data_b": params.get("value", 2)} - - def sync_consumer(params, data_a=0, data_b=0, **kwargs): - with execution_lock: - execution_count["sync"] += 1 - time.sleep(0.01) # Small delay to increase chance of race conditions - return {"sync_result": data_a + data_b} - - def async_consumer(params, data_a=0, data_b=0, **kwargs): - with execution_lock: - execution_count["async"] += 1 - return {"async_result": data_a * data_b} - - node_a = Node( - f=source_a, - requires=[], - provides=[Message(name="data_a", dtype=int)], - params={"value": 10}, - name="source_a", - ) - - node_b = Node( - f=source_b, - requires=[], - provides=[Message(name="data_b", dtype=int)], - params={"value": 20}, - name="source_b", - ) - - sync_node = Node( - f=sync_consumer, - requires=[Message(name="data_a", dtype=int), Message(name="data_b", dtype=int)], - provides=[Message(name="sync_result", dtype=int)], - params={"synchronous": True}, - name="sync_consumer", - ) - - async_node = Node( - f=async_consumer, - requires=[Message(name="data_a", dtype=int), Message(name="data_b", dtype=int)], - provides=[Message(name="async_result", dtype=int)], - params={"synchronous": False}, - name="async_consumer", - ) - - state = MemoryState() - pipeline = Pipeline( - nodes=[node_a, node_b, sync_node, async_node], - state=state, - max_workers=4, # Multiple workers for concurrency - ) - - # Trigger both sensors multiple times concurrently - num_rounds = 5 - print(f"\nTriggering both sensors {num_rounds} times concurrently...") - - threads = [] - for i in range(num_rounds): - node_a.params["value"] = 10 + i - node_b.params["value"] = 20 + i - - t1 = threading.Thread(target=lambda: node_a.inject({})) - t2 = threading.Thread(target=lambda: node_b.inject({})) - threads.extend([t1, t2]) - t1.start() - t2.start() - - # Wait for all threads - for t in threads: - t.join() - - # Give pipeline time to process - time.sleep(0.5) - - print(f"\nExecution counts:") - print(f" sync_consumer: {execution_count['sync']}") - print(f" async_consumer: {execution_count['async']}") - - # sync_consumer should execute at most num_rounds times - # (could be less if some triggers happened before the other input updated) - assert ( - execution_count["sync"] <= num_rounds + 1 - ), f"sync_consumer ran too many times: {execution_count['sync']} (expected <= {num_rounds + 1})" - - # async_consumer will run more frequently - assert ( - execution_count["async"] >= execution_count["sync"] - ), "async_consumer should run at least as often as sync_consumer" - - print( - f"\n✓ sync_consumer ran {execution_count['sync']} times (max expected: {num_rounds + 1})" - ) - print(f"✓ async_consumer ran {execution_count['async']} times") - print("\n✓ Race condition test passed!\n") - - -def test_state_backend_atomic_operations(): - """Test the atomic operations used for synchronous node support.""" - print("=" * 60) - print("Testing State Backend Atomic Operations") - print("=" * 60) - - state = MemoryState() - - # Test increment - print("\nTesting increment...") - assert state.increment("counter") == 1 - assert state.increment("counter") == 2 - assert state.increment("counter") == 3 - print("✓ increment works correctly") - - # Test get_multi - print("\nTesting get_multi...") - state.set("a", 1) - state.set("b", 2) - state.set("c", 3) - result = state.get_multi(["a", "b", "c", "missing"]) - assert result == {"a": 1, "b": 2, "c": 3, "missing": None} - print("✓ get_multi works correctly") - - # Test compare_and_swap_multi - success case - print("\nTesting compare_and_swap_multi (success)...") - success = state.compare_and_swap_multi( - expected={"a": 1, "b": 2}, updates={"a": 10, "b": 20, "new_key": 100} - ) - assert success, "compare_and_swap_multi should succeed" - assert state.get("a") == 10 - assert state.get("b") == 20 - assert state.get("new_key") == 100 - print("✓ compare_and_swap_multi succeeded and applied updates") - - # Test compare_and_swap_multi - failure case - print("\nTesting compare_and_swap_multi (failure)...") - success = state.compare_and_swap_multi( - expected={"a": 1, "b": 20}, updates={"a": 999, "b": 999} # a is now 10, not 1 - ) - assert not success, "compare_and_swap_multi should fail" - assert state.get("a") == 10, "a should be unchanged" - assert state.get("b") == 20, "b should be unchanged" - print("✓ compare_and_swap_multi correctly rejected mismatched expectation") - - print("\n✓ State backend atomic operations test passed!\n") - - -def test_concurrent_compare_and_swap(): - """Test that compare_and_swap_multi handles concurrent access correctly.""" - print("=" * 60) - print("Testing Concurrent Compare-and-Swap") - print("=" * 60) - - state = MemoryState() - state.set("version", 0) - - success_count = {"count": 0} - failure_count = {"count": 0} - count_lock = threading.Lock() - - def try_swap(thread_id: int): - """Try to atomically increment the version.""" - for _ in range(10): - current = state.get("version") - success = state.compare_and_swap_multi( - expected={"version": current}, updates={"version": current + 1} - ) - with count_lock: - if success: - success_count["count"] += 1 - else: - failure_count["count"] += 1 - - # Start multiple threads trying to increment - threads = [threading.Thread(target=try_swap, args=(i,)) for i in range(5)] - for t in threads: - t.start() - for t in threads: - t.join() - - final_version = state.get("version") - total_attempts = success_count["count"] + failure_count["count"] - - print(f"Total attempts: {total_attempts}") - print(f"Successful swaps: {success_count['count']}") - print(f"Failed swaps (race lost): {failure_count['count']}") - print(f"Final version: {final_version}") - - # The final version should equal the number of successful swaps - assert ( - final_version == success_count["count"] - ), f"Version mismatch: {final_version} != {success_count['count']}" - - print("\n✓ Concurrent compare-and-swap test passed!\n") - - -def main(): - """Run all synchronous node tests.""" - print("\n" + "=" * 60) - print("Synchronous Node Test Suite") - print("=" * 60 + "\n") - - # Run all tests - test_state_backend_atomic_operations() - test_concurrent_compare_and_swap() - test_synchronous_basic() - test_synchronous_multiple_triggers() - test_synchronous_race_condition() - - print("=" * 60) - print("All synchronous node tests completed!") - print("=" * 60) - - -if __name__ == "__main__": - main() diff --git a/backend/app/flow/util.py b/backend/app/flow/util.py deleted file mode 100644 index 3ae5185..0000000 --- a/backend/app/flow/util.py +++ /dev/null @@ -1,47 +0,0 @@ -from pydantic import BaseModel, Field, field_validator, ConfigDict -from typing import Any, Tuple, Type -import hashlib - - -class Message(BaseModel): - """Defines a message type with validation constraints.""" - - model_config = ConfigDict(frozen=True) # Immutable for hashability - - name: str - dtype: Type = float - shape: Tuple[int, ...] = () - vrange: Tuple[float, float] = (0.0, 1.0) - - @property - def id(self) -> str: - return hashlib.sha256(self.name.encode()).hexdigest()[:12] - - def check(self, value: Any) -> None: - if not isinstance(value, self.dtype): - raise TypeError( - f"{self.name}: expected {self.dtype.__name__}, got {type(value).__name__}" - ) - - def __repr__(self) -> str: - return f"Message({self.name})" - - def __hash__(self) -> int: - return hash(self.name) - - -class NodeParams(BaseModel): - """Parameters passed to node functions.""" - - model_config = ConfigDict(arbitrary_types_allowed=True) - - params: dict[str, Any] = Field(default_factory=dict) - - def __getitem__(self, key: str) -> Any: - return self.params[key] - - def __contains__(self, key: str) -> bool: - return key in self.params - - def get(self, key: str, default: Any = None) -> Any: - return self.params.get(key, default) diff --git a/backend/app/main.py b/backend/app/main.py index 9a95801..5b20493 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,7 @@ +import asyncio +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + import sentry_sdk from fastapi import FastAPI from fastapi.routing import APIRoute @@ -5,6 +9,11 @@ from starlette.middleware.cors import CORSMiddleware from app.api.main import api_router from app.core.config import settings +from app.flow.controller import FlowController +from app.flow.events import event_bus +from app.flow.secrets import init_secrets +from app.flow.state import MemoryState, RedisState, StateBackend +from app.flow.store import FlowStore def custom_generate_unique_id(route: APIRoute) -> str: @@ -14,10 +23,39 @@ def custom_generate_unique_id(route: APIRoute) -> str: if settings.SENTRY_DSN and settings.ENVIRONMENT != "local": sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True) + +def _state_backend() -> StateBackend: + if settings.REDIS_HOST: + return RedisState(host=settings.REDIS_HOST, port=settings.REDIS_PORT) + return MemoryState() + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + """Start the flow engine alongside the API.""" + event_bus.bind(asyncio.get_running_loop()) + init_secrets(settings.SECRETS_FILE, settings.SECRET_KEY) + + controller = FlowController( + store=FlowStore(settings.FLOWS_DIR), + state=_state_backend(), + events=event_bus, + max_workers=settings.FLOW_MAX_WORKERS, + fastapi_app=app, + ) + app.state.flow_controller = controller + await controller.start() + try: + yield + finally: + await controller.stop() + + app = FastAPI( title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json", generate_unique_id_function=custom_generate_unique_id, + lifespan=lifespan, ) # Set all CORS enabled origins diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 1a78be7..4f25839 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -19,12 +19,9 @@ dependencies = [ "sentry-sdk[fastapi]<2.0.0,>=1.40.6", "pyjwt<3.0.0,>=2.8.0", "pwdlib[argon2,bcrypt]>=0.3.0", - "networkx>=3.4.2", "numpy>=2.2.6", - "matplotlib>=3.10.8", "redis>=7.1.0", - "scipy>=1.15.3", - "watchfiles>=1.0.0", + "cryptography>=44.0.0", "aiomqtt>=2.0.0", "influxdb-client[async]>=1.40.0", "croniter>=1.3.0", @@ -45,16 +42,18 @@ build-backend = "hatchling.build" [tool.mypy] strict = true -# app/flow is the standalone flow-engine prototype: it is not importable as a -# package yet (no __init__.py, top-level sibling imports) and nothing in the API -# reaches it. Type-checking it would only report on code that is scheduled to be -# restructured — see NOTEPAD.md. Drop the exclusion when it is wired up. -exclude = ["venv", ".venv", "alembic", "app/flow"] +exclude = ["venv", ".venv", "alembic"] + +# app/flow/nodes.py still carries the prototype's node classes, and state.py's +# redis calls type as sync/async unions under the current stubs. Both are being +# revisited; every module around them is checked strictly. +[[tool.mypy.overrides]] +module = ["app.flow.nodes", "app.flow.pipeline", "app.flow.state"] +ignore_errors = true [tool.ruff] target-version = "py310" -# See the note on the same exclusion under [tool.mypy]. -exclude = ["alembic", "app/flow"] +exclude = ["alembic"] [tool.ruff.lint] select = [ @@ -75,6 +74,12 @@ ignore = [ "B904", # Allow raising exceptions without from e, for HTTPException ] +[tool.ruff.lint.per-file-ignores] +# Node functions take `params` whether or not they use it — that is the +# contract the engine calls them with. +"app/flow/nodes.py" = ["ARG001", "ARG002"] +"tests/flow/*" = ["ARG001"] + [tool.ruff.lint.pyupgrade] # Preserve types, even if a file imports `from __future__ import annotations`. keep-runtime-typing = true diff --git a/backend/tests/api/routes/test_flows.py b/backend/tests/api/routes/test_flows.py new file mode 100644 index 0000000..b5abb0e --- /dev/null +++ b/backend/tests/api/routes/test_flows.py @@ -0,0 +1,146 @@ +from fastapi.testclient import TestClient + +from app.core.config import settings + +PREFIX = f"{settings.API_V1_STR}/flows" + +WORKING_NODE = """ +def process(params): + return {"reading": 21.5} +""" + +BROKEN_NODE = """ +def process(params): + raise RuntimeError("boom") +""" + + +def a_flow(name: str = "demo") -> dict: + return { + "name": name, + "title": "Demo", + "nodes": [ + { + "id": "sensor", + "type": "python", + "position": {"x": 0, "y": 0}, + "provides": [{"name": "reading", "dtype": "float"}], + }, + { + "id": "logger", + "type": "python", + "position": {"x": 240, "y": 0}, + "requires": [{"name": "reading", "dtype": "float"}], + }, + ], + } + + +def test_flows_require_authentication(client: TestClient) -> None: + assert client.get(f"{PREFIX}/").status_code == 401 + + +def test_save_then_read_flow( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + response = client.put( + f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow() + ) + assert response.status_code == 200 + + response = client.get(f"{PREFIX}/demo", headers=superuser_token_headers) + assert response.status_code == 200 + body = response.json() + assert body["definition"]["title"] == "Demo" + assert {node["id"] for node in body["definition"]["nodes"]} == {"sensor", "logger"} + + +def test_name_mismatch_is_rejected( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + response = client.put( + f"{PREFIX}/other", headers=superuser_token_headers, json=a_flow("demo") + ) + assert response.status_code == 400 + + +def test_broken_node_is_reported_and_siblings_stay_active( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow()) + client.put( + f"{PREFIX}/demo/nodes/sensor/source", + headers=superuser_token_headers, + json={"code": WORKING_NODE}, + ) + + response = client.put( + f"{PREFIX}/demo/nodes/logger/source", + headers=superuser_token_headers, + json={"code": "def process(reading, params:\n"}, + ) + assert response.status_code == 200 + assert response.json()["status"] == "error" + + statuses = client.get(f"{PREFIX}/demo", headers=superuser_token_headers).json() + by_id = {node["id"]: node for node in statuses["nodes"]} + assert by_id["demo.sensor"]["status"] == "active" + assert by_id["demo.logger"]["status"] == "error" + + +def test_running_a_flow_produces_values( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow()) + client.put( + f"{PREFIX}/demo/nodes/sensor/source", + headers=superuser_token_headers, + json={"code": WORKING_NODE}, + ) + client.put( + f"{PREFIX}/demo/nodes/logger/source", + headers=superuser_token_headers, + json={"code": "def process(reading, params):\n return {}\n"}, + ) + + response = client.post( + f"{PREFIX}/demo/run", headers=superuser_token_headers, json={"inputs": {}} + ) + assert response.status_code == 200 + assert response.json()["values"]["demo.reading"]["value"] == 21.5 + + +def test_unconnected_input_is_surfaced( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + flow = a_flow() + flow["nodes"][0]["provides"] = [] # nothing produces "reading" any more + client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=flow) + + issues = client.post( + f"{PREFIX}/demo/validate", headers=superuser_token_headers + ).json()["issues"] + assert any(issue["code"] == "unconnected_input" for issue in issues) + + +def test_delete_flow( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + client.put(f"{PREFIX}/demo", headers=superuser_token_headers, json=a_flow()) + + assert ( + client.delete(f"{PREFIX}/demo", headers=superuser_token_headers).status_code + == 200 + ) + assert ( + client.get(f"{PREFIX}/demo", headers=superuser_token_headers).status_code == 404 + ) + + +def test_node_types_are_listed( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + types = client.get(f"{PREFIX}/node-types", headers=superuser_token_headers).json() + by_type = {entry["type"]: entry for entry in types} + assert by_type["python"]["has_source"] is True + assert "properties" in by_type["mqtt"]["params_schema"] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8ddab7b..52e5f66 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -12,6 +12,15 @@ from tests.utils.user import authentication_token_from_email from tests.utils.utils import get_superuser_token_headers +@pytest.fixture(scope="session", autouse=True) +def flow_data(tmp_path_factory: pytest.TempPathFactory) -> Generator[None, None, None]: + """Keep flows and secrets written by tests out of the real store.""" + root = tmp_path_factory.mktemp("flow-data") + settings.FLOWS_DIR = root / "flows" + settings.SECRETS_FILE = root / "secrets.enc" + yield + + @pytest.fixture(scope="session", autouse=True) def db() -> Generator[Session, None, None]: with Session(engine) as session: diff --git a/backend/tests/flow/__init__.py b/backend/tests/flow/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/flow/conftest.py b/backend/tests/flow/conftest.py new file mode 100644 index 0000000..da2907c --- /dev/null +++ b/backend/tests/flow/conftest.py @@ -0,0 +1,9 @@ +from collections.abc import Generator + +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def db() -> Generator[None, None, None]: + """The engine holds no database state, so these tests need no database.""" + yield diff --git a/backend/tests/flow/test_messages.py b/backend/tests/flow/test_messages.py new file mode 100644 index 0000000..e202dcb --- /dev/null +++ b/backend/tests/flow/test_messages.py @@ -0,0 +1,55 @@ +import json + +import pytest + +from app.flow.messages import DType, MessageSpec, flow_of, qualify + + +def test_port_defaults_to_last_name_segment(): + assert MessageSpec(name="temperature").port == "temperature" + assert MessageSpec(name="heating.temperature").port == "temperature" + assert MessageSpec(name="heating.temperature", port="t").port == "t" + + +def test_int_rejects_bool(): + # bool is a subclass of int, but a flag is not a number here. + spec = MessageSpec(name="count", dtype=DType.INT) + spec.check(3) + with pytest.raises(TypeError): + spec.check(True) + + +def test_float_accepts_int_but_not_bool(): + spec = MessageSpec(name="temp", dtype=DType.FLOAT) + spec.check(21) + spec.check(21.5) + with pytest.raises(TypeError): + spec.check(True) + with pytest.raises(TypeError): + spec.check("21") + + +def test_json_dtype_round_trips(): + spec = MessageSpec(name="payload", dtype=DType.JSON) + value = {"a": [1, 2], "b": None} + spec.check(value) + assert json.loads(json.dumps(value)) == value + + +def test_coerce_from_text(): + assert MessageSpec(name="a", dtype=DType.FLOAT).coerce("2.5") == 2.5 + assert MessageSpec(name="a", dtype=DType.INT).coerce("7") == 7 + assert MessageSpec(name="a", dtype=DType.BOOL).coerce("yes") is True + assert MessageSpec(name="a", dtype=DType.BOOL).coerce("0") is False + + +def test_spec_serializes(): + spec = MessageSpec(name="heating.temp", dtype=DType.FLOAT) + assert MessageSpec.model_validate_json(spec.model_dump_json()) == spec + + +def test_qualify_scopes_bare_names_only(): + assert qualify("heating", "temp") == "heating.temp" + assert qualify("heating", "solar.power") == "solar.power" + assert qualify("heating", "") == "" + assert flow_of("heating.temp") == "heating" diff --git a/backend/tests/flow/test_pipeline.py b/backend/tests/flow/test_pipeline.py new file mode 100644 index 0000000..079cfa2 --- /dev/null +++ b/backend/tests/flow/test_pipeline.py @@ -0,0 +1,152 @@ +"""The wiring fundamentals: name binding, fan-in, namespaces, validation.""" + +from app.flow.messages import DType, MessageSpec +from app.flow.nodes import Node +from app.flow.pipeline import Pipeline + + +def spec(name: str, dtype: DType = DType.FLOAT, port: str = "") -> MessageSpec: + return MessageSpec(name=name, dtype=dtype, port=port) + + +def make_node(node_id: str, flow: str, f, requires=(), provides=()) -> Node: + node = Node(f=f, requires=list(requires), provides=list(provides), name=node_id) + node.assign_flow(flow, node_id) + return node + + +def test_bare_names_are_scoped_to_their_flow(): + source = make_node( + "source", "heating", lambda params: {"temp": 20.0}, provides=[spec("temp")] + ) + assert "heating.temp" in source.provides + + +def test_consumer_receives_from_any_producer(): + # Two producers of one message: each publication reaches the consumer. + seen = [] + + def emit_a(params): + return {"temp": 1.0} + + def emit_b(params): + return {"temp": 2.0} + + def consume(temp, params): + seen.append(temp) + return None + + a = make_node("a", "heating", emit_a, provides=[spec("temp")]) + b = make_node("b", "heating", emit_b, provides=[spec("temp")]) + c = make_node("c", "heating", consume, requires=[spec("temp")]) + + pipeline = Pipeline(nodes=[a, b, c]) + + assert pipeline.produces["heating.temp"] == [a, b] + assert pipeline.dependencies[c] == frozenset({a, b}) + + a.inject() + b.inject() + + assert seen == [1.0, 2.0] + # Latest value wins. + assert pipeline.state["heating.temp"] == 2.0 + + +def test_flows_connect_through_qualified_names(): + def emit(params): + return {"power": 500.0} + + def consume(power, params): + return {"used": power} + + producer = make_node("meter", "solar", emit, provides=[spec("power")]) + # A bare name would be heating.power; the dotted one crosses the flow. + consumer = make_node( + "load", + "heating", + consume, + requires=[spec("solar.power")], + provides=[spec("used")], + ) + + pipeline = Pipeline(nodes=[producer, consumer]) + assert pipeline.dependencies[consumer] == frozenset({producer}) + + producer.inject() + assert pipeline.state["heating.used"] == 500.0 + + +def test_ports_keep_function_arguments_local(): + def convert(celsius, params): + return {"fahrenheit": celsius * 9 / 5 + 32} + + node = make_node( + "convert", + "heating", + convert, + requires=[spec("solar.celsius", port="celsius")], + provides=[spec("fahrenheit")], + ) + Pipeline(nodes=[node]) + + assert node.execute({"solar.celsius": 100.0}) == {"heating.fahrenheit": 212.0} + + +def test_validate_reports_cycles_and_dangling_inputs(): + a = make_node( + "a", "f", lambda b, params: {"a": b}, requires=[spec("b")], provides=[spec("a")] + ) + b = make_node( + "b", "f", lambda a, params: {"b": a}, requires=[spec("a")], provides=[spec("b")] + ) + lonely = make_node( + "lonely", "f", lambda missing, params: None, requires=[spec("missing")] + ) + + issues = Pipeline(nodes=[a, b, lonely]).validate() + codes = {issue.code for issue in issues} + + assert "cycle" in codes + assert "unconnected_input" in codes + dangling = next(i for i in issues if i.code == "unconnected_input") + assert dangling.message_name == "f.missing" + assert dangling.node == "f.lonely" + + +def test_declared_flow_inputs_are_not_dangling(): + node = make_node( + "n", "f", lambda setpoint, params: None, requires=[spec("setpoint")] + ) + issues = Pipeline(nodes=[node]).validate(known_inputs={"f.setpoint"}) + assert issues == [] + + +def test_a_failing_node_does_not_stop_its_siblings(): + ran = [] + + def boom(params): + raise RuntimeError("nope") + + def fine(params): + ran.append("fine") + return {"ok": 1.0} + + bad = make_node("bad", "f", boom, provides=[spec("bad_out")]) + good = make_node("good", "f", fine, provides=[spec("ok")]) + + pipeline = Pipeline(nodes=[bad, good]) + pipeline.run() + + assert ran == ["fine"] + assert pipeline.state["f.ok"] == 1.0 + + +def test_values_carry_timestamps(): + node = make_node("n", "f", lambda params: {"out": 1.0}, provides=[spec("out")]) + pipeline = Pipeline(nodes=[node]) + node.inject() + + values = pipeline.values("f") + assert values["f.out"]["value"] == 1.0 + assert values["f.out"]["ts"] > 0 diff --git a/backend/tests/flow/test_store.py b/backend/tests/flow/test_store.py new file mode 100644 index 0000000..0beee3f --- /dev/null +++ b/backend/tests/flow/test_store.py @@ -0,0 +1,75 @@ +import subprocess +from pathlib import Path + +import pytest + +from app.flow.messages import MessageSpec +from app.flow.schemas import FlowDef, NodeDef +from app.flow.store import FlowNotFound, FlowStore + + +@pytest.fixture +def store(tmp_path: Path) -> FlowStore: + return FlowStore(tmp_path / "flows") + + +def commit_count(store: FlowStore) -> int: + result = subprocess.run( + ["git", "-C", str(store.root), "rev-list", "--count", "HEAD"], + capture_output=True, + text=True, + check=True, + ) + return int(result.stdout.strip()) + + +def a_flow() -> FlowDef: + return FlowDef( + name="heating", + nodes=[NodeDef(id="sensor", provides=[MessageSpec(name="temp")])], + ) + + +def test_flow_round_trips(store: FlowStore): + store.write_flow(a_flow()) + + assert store.list_flows() == ["heating"] + assert store.read_flow("heating").nodes[0].provides[0].name == "temp" + + +def test_every_change_is_committed(store: FlowStore): + before = commit_count(store) + + store.write_flow(a_flow()) + assert commit_count(store) == before + 1 + + store.write_node_source( + "heating", "sensor", "def process(params):\n return {}\n" + ) + assert commit_count(store) == before + 2 + + +def test_saving_unchanged_content_does_nothing(store: FlowStore): + store.write_flow(a_flow()) + commits = commit_count(store) + + # Autosave repeats the same document; history should not grow. + assert store.write_flow(a_flow()) is False + assert commit_count(store) == commits + + +def test_missing_flow_is_reported(store: FlowStore): + with pytest.raises(FlowNotFound): + store.read_flow("nope") + + +def test_deleting_removes_flow_and_its_nodes(store: FlowStore): + store.write_flow(a_flow()) + store.write_node_source( + "heating", "sensor", "def process(params):\n return {}\n" + ) + + store.delete_flow("heating") + + assert store.list_flows() == [] + assert not (store.root / "heating").exists() diff --git a/backend/tests/flow/test_synchronous_nodes.py b/backend/tests/flow/test_synchronous_nodes.py new file mode 100644 index 0000000..f0dfd55 --- /dev/null +++ b/backend/tests/flow/test_synchronous_nodes.py @@ -0,0 +1,111 @@ +"""Synchronous nodes wait until every input is fresh. + +The ordering guarantee at the stateful boundary rests on the state backend's +atomic operations, so those are covered here too. +""" + +from concurrent.futures import ThreadPoolExecutor + +from app.flow.messages import DType, MessageSpec +from app.flow.nodes import Node +from app.flow.pipeline import Pipeline +from app.flow.state import MemoryState + + +def spec(name: str) -> MessageSpec: + return MessageSpec(name=name, dtype=DType.FLOAT) + + +def node(node_id: str, f, requires=(), provides=(), params=None) -> Node: + n = Node( + f=f, + requires=list(requires), + provides=list(provides), + params=params or {}, + name=node_id, + ) + n.assign_flow("f", node_id) + return n + + +def test_synchronous_node_waits_for_all_inputs_to_be_fresh(): + runs: list[str] = [] + + def sensor_a(params): + return {"a": 1.0} + + def sensor_b(params): + return {"b": 2.0} + + def sync(a, b, params): + runs.append("sync") + return None + + def eager(a, b, params): + runs.append("eager") + return None + + a = node("a", sensor_a, provides=[spec("a")]) + b = node("b", sensor_b, provides=[spec("b")]) + sync_node = node( + "sync", sync, requires=[spec("a"), spec("b")], params={"synchronous": True} + ) + eager_node = node("eager", eager, requires=[spec("a"), spec("b")]) + + Pipeline(nodes=[a, b, sync_node, eager_node], max_workers=1) + + a.inject() + assert runs == [] # b has never arrived + + b.inject() + assert runs.count("eager") == 1 + assert runs.count("sync") == 1 + + # Only a is new: the eager node runs again, the synchronous one waits. + runs.clear() + a.inject() + assert runs == ["eager"] + + # Now b is new as well, so both have moved on. + runs.clear() + b.inject() + assert sorted(runs) == ["eager", "sync"] + + +def test_increment_and_multi_get(): + state = MemoryState() + + assert state.increment("counter") == 1 + assert state.increment("counter") == 2 + + state.update({"a": 1, "b": 2}) + assert state.get_multi(["a", "b", "missing"]) == { + "a": 1, + "b": 2, + "missing": None, + } + + +def test_compare_and_swap_only_applies_on_match(): + state = MemoryState() + state.update({"a": 1, "b": 2}) + + assert state.compare_and_swap_multi({"a": 1, "b": 2}, {"a": 10, "new": 100}) + assert state.get("a") == 10 + assert state.get("new") == 100 + + assert not state.compare_and_swap_multi({"a": 1}, {"a": 99}) + assert state.get("a") == 10 + + +def test_only_one_thread_wins_a_compare_and_swap(): + state = MemoryState() + state.set("version", 1) + + def claim() -> bool: + return state.compare_and_swap_multi({"version": 1}, {"version": 2}) + + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(lambda _: claim(), range(8))) + + assert sum(results) == 1 diff --git a/uv.lock b/uv.lock index d61d4ce..9144899 100644 --- a/uv.lock +++ b/uv.lock @@ -272,15 +272,13 @@ dependencies = [ { name = "aiomqtt" }, { name = "alembic" }, { name = "croniter" }, + { name = "cryptography" }, { name = "email-validator" }, { name = "emails" }, { name = "fastapi", extra = ["standard"] }, { name = "httpx" }, { name = "influxdb-client", extra = ["async"] }, { name = "jinja2" }, - { name = "matplotlib" }, - { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "psycopg", extra = ["binary"] }, @@ -290,12 +288,9 @@ dependencies = [ { name = "pyjwt" }, { name = "python-multipart" }, { name = "redis" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sentry-sdk", extra = ["fastapi"] }, { name = "sqlmodel" }, { name = "tenacity" }, - { name = "watchfiles" }, ] [package.dev-dependencies] @@ -312,14 +307,13 @@ requires-dist = [ { name = "aiomqtt", specifier = ">=2.0.0" }, { name = "alembic", specifier = ">=1.12.1,<2.0.0" }, { name = "croniter", specifier = ">=1.3.0" }, + { name = "cryptography", specifier = ">=44.0.0" }, { name = "email-validator", specifier = ">=2.1.0.post1,<3.0.0.0" }, { name = "emails", specifier = ">=0.6,<1.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.114.2,<1.0.0" }, { name = "httpx", specifier = ">=0.25.1,<1.0.0" }, { name = "influxdb-client", extras = ["async"], specifier = ">=1.40.0" }, { name = "jinja2", specifier = ">=3.1.4,<4.0.0" }, - { name = "matplotlib", specifier = ">=3.10.8" }, - { name = "networkx", specifier = ">=3.4.2" }, { name = "numpy", specifier = ">=2.2.6" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.1.13,<4.0.0" }, { name = "pwdlib", extras = ["argon2", "bcrypt"], specifier = ">=0.3.0" }, @@ -328,11 +322,9 @@ requires-dist = [ { name = "pyjwt", specifier = ">=2.8.0,<3.0.0" }, { name = "python-multipart", specifier = ">=0.0.7,<1.0.0" }, { name = "redis", specifier = ">=7.1.0" }, - { name = "scipy", specifier = ">=1.15.3" }, { name = "sentry-sdk", extras = ["fastapi"], specifier = ">=1.40.6,<2.0.0" }, { name = "sqlmodel", specifier = ">=0.0.21,<1.0.0" }, { name = "tenacity", specifier = ">=8.2.3,<9.0.0" }, - { name = "watchfiles", specifier = ">=1.0.0" }, ] [package.metadata.requires-dev] @@ -687,162 +679,6 @@ 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 = "contourpy" -version = "1.3.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, - { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, - { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, - { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, - { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, - { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, - { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, - { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, - { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" }, - { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" }, - { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" }, - { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" }, - { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" }, - { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" }, - { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" }, - { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" }, - { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" }, - { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" }, - { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" }, - { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" }, - { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" }, - { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" }, - { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, - { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, - { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" }, - { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, - { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, - { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, - { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, - { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, - { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, - { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" }, - { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" }, - { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" }, - { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, - { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, - { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, - { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, - { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, - { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, - { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, - { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, - { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, - { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, - { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, - { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, -] - -[[package]] -name = "contourpy" -version = "1.3.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version >= '3.11' and python_full_version < '3.14'", -] -dependencies = [ - { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, - { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, - { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, - { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, - { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, - { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, - { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, - { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, - { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, - { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, - { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, - { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, - { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, - { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, - { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, - { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, - { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, - { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, - { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, - { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, - { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, - { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, - { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, - { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, - { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, - { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, - { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, - { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, - { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, - { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, - { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, - { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, - { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, - { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, - { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, - { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, - { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, - { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, - { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, - { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, - { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, - { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, - { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, - { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, - { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, - { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, - { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, - { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, - { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, - { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, - { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, - { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, - { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, - { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, -] - [[package]] name = "coverage" version = "7.13.1" @@ -955,6 +791,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/4b/290b4c3efd6417a8b0c284896de19b1d5855e6dbdb97d2a35e68fa42de85/croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368", size = 25468, upload-time = "2024-12-17T17:17:45.359Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, +] + [[package]] name = "cssselect" version = "1.3.0" @@ -976,15 +869,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/ec/bb273b7208c606890dc36540fe667d06ce840a6f62f9fae7e658fcdc90fb/cssutils-2.11.1-py3-none-any.whl", hash = "sha256:a67bfdfdff4f3867fab43698ec4897c1a828eca5973f4073321b3bccaf1199b1", size = 385747, upload-time = "2024-06-04T15:51:37.499Z" }, ] -[[package]] -name = "cycler" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, -] - [[package]] name = "dnspython" version = "2.8.0" @@ -1079,63 +963,6 @@ standard = [ { name = "uvicorn", extra = ["standard"] }, ] -[[package]] -name = "fonttools" -version = "4.61.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756, upload-time = "2025-12-12T17:31:24.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/94/8a28707adb00bed1bf22dac16ccafe60faf2ade353dcb32c3617ee917307/fonttools-4.61.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c7db70d57e5e1089a274cbb2b1fd635c9a24de809a231b154965d415d6c6d24", size = 2854799, upload-time = "2025-12-12T17:29:27.5Z" }, - { url = "https://files.pythonhosted.org/packages/94/93/c2e682faaa5ee92034818d8f8a8145ae73eb83619600495dcf8503fa7771/fonttools-4.61.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5fe9fd43882620017add5eabb781ebfbc6998ee49b35bd7f8f79af1f9f99a958", size = 2403032, upload-time = "2025-12-12T17:29:30.115Z" }, - { url = "https://files.pythonhosted.org/packages/f1/62/1748f7e7e1ee41aa52279fd2e3a6d0733dc42a673b16932bad8e5d0c8b28/fonttools-4.61.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8db08051fc9e7d8bc622f2112511b8107d8f27cd89e2f64ec45e9825e8288da", size = 4897863, upload-time = "2025-12-12T17:29:32.535Z" }, - { url = "https://files.pythonhosted.org/packages/69/69/4ca02ee367d2c98edcaeb83fc278d20972502ee071214ad9d8ca85e06080/fonttools-4.61.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a76d4cb80f41ba94a6691264be76435e5f72f2cb3cab0b092a6212855f71c2f6", size = 4859076, upload-time = "2025-12-12T17:29:34.907Z" }, - { url = "https://files.pythonhosted.org/packages/8c/f5/660f9e3cefa078861a7f099107c6d203b568a6227eef163dd173bfc56bdc/fonttools-4.61.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a13fc8aeb24bad755eea8f7f9d409438eb94e82cf86b08fe77a03fbc8f6a96b1", size = 4875623, upload-time = "2025-12-12T17:29:37.33Z" }, - { url = "https://files.pythonhosted.org/packages/63/d1/9d7c5091d2276ed47795c131c1bf9316c3c1ab2789c22e2f59e0572ccd38/fonttools-4.61.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b846a1fcf8beadeb9ea4f44ec5bdde393e2f1569e17d700bfc49cd69bde75881", size = 4993327, upload-time = "2025-12-12T17:29:39.781Z" }, - { url = "https://files.pythonhosted.org/packages/6f/2d/28def73837885ae32260d07660a052b99f0aa00454867d33745dfe49dbf0/fonttools-4.61.1-cp310-cp310-win32.whl", hash = "sha256:78a7d3ab09dc47ac1a363a493e6112d8cabed7ba7caad5f54dbe2f08676d1b47", size = 1502180, upload-time = "2025-12-12T17:29:42.217Z" }, - { url = "https://files.pythonhosted.org/packages/63/fa/bfdc98abb4dd2bd491033e85e3ba69a2313c850e759a6daa014bc9433b0f/fonttools-4.61.1-cp310-cp310-win_amd64.whl", hash = "sha256:eff1ac3cc66c2ac7cda1e64b4e2f3ffef474b7335f92fc3833fc632d595fcee6", size = 1550654, upload-time = "2025-12-12T17:29:44.564Z" }, - { url = "https://files.pythonhosted.org/packages/69/12/bf9f4eaa2fad039356cc627587e30ed008c03f1cebd3034376b5ee8d1d44/fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09", size = 2852213, upload-time = "2025-12-12T17:29:46.675Z" }, - { url = "https://files.pythonhosted.org/packages/ac/49/4138d1acb6261499bedde1c07f8c2605d1d8f9d77a151e5507fd3ef084b6/fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37", size = 2401689, upload-time = "2025-12-12T17:29:48.769Z" }, - { url = "https://files.pythonhosted.org/packages/e5/fe/e6ce0fe20a40e03aef906af60aa87668696f9e4802fa283627d0b5ed777f/fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb", size = 5058809, upload-time = "2025-12-12T17:29:51.701Z" }, - { url = "https://files.pythonhosted.org/packages/79/61/1ca198af22f7dd22c17ab86e9024ed3c06299cfdb08170640e9996d501a0/fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9", size = 5036039, upload-time = "2025-12-12T17:29:53.659Z" }, - { url = "https://files.pythonhosted.org/packages/99/cc/fa1801e408586b5fce4da9f5455af8d770f4fc57391cd5da7256bb364d38/fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87", size = 5034714, upload-time = "2025-12-12T17:29:55.592Z" }, - { url = "https://files.pythonhosted.org/packages/bf/aa/b7aeafe65adb1b0a925f8f25725e09f078c635bc22754f3fecb7456955b0/fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56", size = 5158648, upload-time = "2025-12-12T17:29:57.861Z" }, - { url = "https://files.pythonhosted.org/packages/99/f9/08ea7a38663328881384c6e7777bbefc46fd7d282adfd87a7d2b84ec9d50/fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a", size = 2280681, upload-time = "2025-12-12T17:29:59.943Z" }, - { url = "https://files.pythonhosted.org/packages/07/ad/37dd1ae5fa6e01612a1fbb954f0927681f282925a86e86198ccd7b15d515/fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7", size = 2331951, upload-time = "2025-12-12T17:30:02.254Z" }, - { url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593, upload-time = "2025-12-12T17:30:04.225Z" }, - { url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231, upload-time = "2025-12-12T17:30:06.47Z" }, - { url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103, upload-time = "2025-12-12T17:30:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295, upload-time = "2025-12-12T17:30:10.56Z" }, - { url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109, upload-time = "2025-12-12T17:30:12.874Z" }, - { url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598, upload-time = "2025-12-12T17:30:15.79Z" }, - { url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060, upload-time = "2025-12-12T17:30:18.058Z" }, - { url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078, upload-time = "2025-12-12T17:30:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/4b/cf/00ba28b0990982530addb8dc3e9e6f2fa9cb5c20df2abdda7baa755e8fe1/fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c", size = 2846454, upload-time = "2025-12-12T17:30:24.938Z" }, - { url = "https://files.pythonhosted.org/packages/5a/ca/468c9a8446a2103ae645d14fee3f610567b7042aba85031c1c65e3ef7471/fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e", size = 2398191, upload-time = "2025-12-12T17:30:27.343Z" }, - { url = "https://files.pythonhosted.org/packages/a3/4b/d67eedaed19def5967fade3297fed8161b25ba94699efc124b14fb68cdbc/fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5", size = 4928410, upload-time = "2025-12-12T17:30:29.771Z" }, - { url = "https://files.pythonhosted.org/packages/b0/8d/6fb3494dfe61a46258cd93d979cf4725ded4eb46c2a4ca35e4490d84daea/fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd", size = 4984460, upload-time = "2025-12-12T17:30:32.073Z" }, - { url = "https://files.pythonhosted.org/packages/f7/f1/a47f1d30b3dc00d75e7af762652d4cbc3dff5c2697a0dbd5203c81afd9c3/fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3", size = 4925800, upload-time = "2025-12-12T17:30:34.339Z" }, - { url = "https://files.pythonhosted.org/packages/a7/01/e6ae64a0981076e8a66906fab01539799546181e32a37a0257b77e4aa88b/fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d", size = 5067859, upload-time = "2025-12-12T17:30:36.593Z" }, - { url = "https://files.pythonhosted.org/packages/73/aa/28e40b8d6809a9b5075350a86779163f074d2b617c15d22343fce81918db/fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c", size = 2267821, upload-time = "2025-12-12T17:30:38.478Z" }, - { url = "https://files.pythonhosted.org/packages/1a/59/453c06d1d83dc0951b69ef692d6b9f1846680342927df54e9a1ca91c6f90/fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b", size = 2318169, upload-time = "2025-12-12T17:30:40.951Z" }, - { url = "https://files.pythonhosted.org/packages/32/8f/4e7bf82c0cbb738d3c2206c920ca34ca74ef9dabde779030145d28665104/fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd", size = 2846094, upload-time = "2025-12-12T17:30:43.511Z" }, - { url = "https://files.pythonhosted.org/packages/71/09/d44e45d0a4f3a651f23a1e9d42de43bc643cce2971b19e784cc67d823676/fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e", size = 2396589, upload-time = "2025-12-12T17:30:45.681Z" }, - { url = "https://files.pythonhosted.org/packages/89/18/58c64cafcf8eb677a99ef593121f719e6dcbdb7d1c594ae5a10d4997ca8a/fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c", size = 4877892, upload-time = "2025-12-12T17:30:47.709Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ec/9e6b38c7ba1e09eb51db849d5450f4c05b7e78481f662c3b79dbde6f3d04/fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75", size = 4972884, upload-time = "2025-12-12T17:30:49.656Z" }, - { url = "https://files.pythonhosted.org/packages/5e/87/b5339da8e0256734ba0dbbf5b6cdebb1dd79b01dc8c270989b7bcd465541/fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063", size = 4924405, upload-time = "2025-12-12T17:30:51.735Z" }, - { url = "https://files.pythonhosted.org/packages/0b/47/e3409f1e1e69c073a3a6fd8cb886eb18c0bae0ee13db2c8d5e7f8495e8b7/fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2", size = 5035553, upload-time = "2025-12-12T17:30:54.823Z" }, - { url = "https://files.pythonhosted.org/packages/bf/b6/1f6600161b1073a984294c6c031e1a56ebf95b6164249eecf30012bb2e38/fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c", size = 2271915, upload-time = "2025-12-12T17:30:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/52/7b/91e7b01e37cc8eb0e1f770d08305b3655e4f002fc160fb82b3390eabacf5/fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c", size = 2323487, upload-time = "2025-12-12T17:30:59.804Z" }, - { url = "https://files.pythonhosted.org/packages/39/5c/908ad78e46c61c3e3ed70c3b58ff82ab48437faf84ec84f109592cabbd9f/fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa", size = 2929571, upload-time = "2025-12-12T17:31:02.574Z" }, - { url = "https://files.pythonhosted.org/packages/bd/41/975804132c6dea64cdbfbaa59f3518a21c137a10cccf962805b301ac6ab2/fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91", size = 2435317, upload-time = "2025-12-12T17:31:04.974Z" }, - { url = "https://files.pythonhosted.org/packages/b0/5a/aef2a0a8daf1ebaae4cfd83f84186d4a72ee08fd6a8451289fcd03ffa8a4/fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19", size = 4882124, upload-time = "2025-12-12T17:31:07.456Z" }, - { url = "https://files.pythonhosted.org/packages/80/33/d6db3485b645b81cea538c9d1c9219d5805f0877fda18777add4671c5240/fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba", size = 5100391, upload-time = "2025-12-12T17:31:09.732Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d6/675ba631454043c75fcf76f0ca5463eac8eb0666ea1d7badae5fea001155/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7", size = 4978800, upload-time = "2025-12-12T17:31:11.681Z" }, - { url = "https://files.pythonhosted.org/packages/7f/33/d3ec753d547a8d2bdaedd390d4a814e8d5b45a093d558f025c6b990b554c/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118", size = 5006426, upload-time = "2025-12-12T17:31:13.764Z" }, - { url = "https://files.pythonhosted.org/packages/b4/40/cc11f378b561a67bea850ab50063366a0d1dd3f6d0a30ce0f874b0ad5664/fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5", size = 2335377, upload-time = "2025-12-12T17:31:16.49Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ff/c9a2b66b39f8628531ea58b320d66d951267c98c6a38684daa8f50fb02f8/fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b", size = 2400613, upload-time = "2025-12-12T17:31:18.769Z" }, - { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" }, -] - [[package]] name = "frozenlist" version = "1.8.0" @@ -1266,7 +1093,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/6a/33d1702184d94106d3cdd7bfb788e19723206fce152e303473ca3b946c7b/greenlet-3.3.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6f8496d434d5cb2dce025773ba5597f71f5410ae499d5dd9533e0653258cdb3d", size = 273658, upload-time = "2025-12-04T14:23:37.494Z" }, { url = "https://files.pythonhosted.org/packages/d6/b7/2b5805bbf1907c26e434f4e448cd8b696a0b71725204fa21a211ff0c04a7/greenlet-3.3.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b96dc7eef78fd404e022e165ec55327f935b9b52ff355b067eb4a0267fc1cffb", size = 574810, upload-time = "2025-12-04T14:50:04.154Z" }, { url = "https://files.pythonhosted.org/packages/94/38/343242ec12eddf3d8458c73f555c084359883d4ddc674240d9e61ec51fd6/greenlet-3.3.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73631cd5cccbcfe63e3f9492aaa664d278fda0ce5c3d43aeda8e77317e38efbd", size = 586248, upload-time = "2025-12-04T14:57:39.35Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d0/0ae86792fb212e4384041e0ef8e7bc66f59a54912ce407d26a966ed2914d/greenlet-3.3.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b299a0cb979f5d7197442dccc3aee67fce53500cd88951b7e6c35575701c980b", size = 597403, upload-time = "2025-12-04T15:07:10.831Z" }, { url = "https://files.pythonhosted.org/packages/b6/a8/15d0aa26c0036a15d2659175af00954aaaa5d0d66ba538345bd88013b4d7/greenlet-3.3.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dee147740789a4632cace364816046e43310b59ff8fb79833ab043aefa72fd5", size = 586910, upload-time = "2025-12-04T14:25:59.705Z" }, { url = "https://files.pythonhosted.org/packages/e1/9b/68d5e3b7ccaba3907e5532cf8b9bf16f9ef5056a008f195a367db0ff32db/greenlet-3.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:39b28e339fc3c348427560494e28d8a6f3561c8d2bcf7d706e1c624ed8d822b9", size = 1547206, upload-time = "2025-12-04T15:04:21.027Z" }, { url = "https://files.pythonhosted.org/packages/66/bd/e3086ccedc61e49f91e2cfb5ffad9d8d62e5dc85e512a6200f096875b60c/greenlet-3.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3c374782c2935cc63b2a27ba8708471de4ad1abaa862ffdb1ef45a643ddbb7d", size = 1613359, upload-time = "2025-12-04T14:27:26.548Z" }, @@ -1274,7 +1100,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" }, { url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" }, { url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" }, - { url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" }, { url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" }, { url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" }, { url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" }, @@ -1282,7 +1107,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, - { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" }, { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, @@ -1290,7 +1114,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, - { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, @@ -1298,7 +1121,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, - { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" }, { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, @@ -1306,7 +1128,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, - { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" }, { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, @@ -1443,114 +1264,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "kiwisolver" -version = "1.4.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/5d/8ce64e36d4e3aac5ca96996457dcf33e34e6051492399a3f1fec5657f30b/kiwisolver-1.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b4b4d74bda2b8ebf4da5bd42af11d02d04428b2c32846e4c2c93219df8a7987b", size = 124159, upload-time = "2025-08-10T21:25:35.472Z" }, - { url = "https://files.pythonhosted.org/packages/96/1e/22f63ec454874378175a5f435d6ea1363dd33fb2af832c6643e4ccea0dc8/kiwisolver-1.4.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fb3b8132019ea572f4611d770991000d7f58127560c4889729248eb5852a102f", size = 66578, upload-time = "2025-08-10T21:25:36.73Z" }, - { url = "https://files.pythonhosted.org/packages/41/4c/1925dcfff47a02d465121967b95151c82d11027d5ec5242771e580e731bd/kiwisolver-1.4.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84fd60810829c27ae375114cd379da1fa65e6918e1da405f356a775d49a62bcf", size = 65312, upload-time = "2025-08-10T21:25:37.658Z" }, - { url = "https://files.pythonhosted.org/packages/d4/42/0f333164e6307a0687d1eb9ad256215aae2f4bd5d28f4653d6cd319a3ba3/kiwisolver-1.4.9-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b78efa4c6e804ecdf727e580dbb9cba85624d2e1c6b5cb059c66290063bd99a9", size = 1628458, upload-time = "2025-08-10T21:25:39.067Z" }, - { url = "https://files.pythonhosted.org/packages/86/b6/2dccb977d651943995a90bfe3495c2ab2ba5cd77093d9f2318a20c9a6f59/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4efec7bcf21671db6a3294ff301d2fc861c31faa3c8740d1a94689234d1b415", size = 1225640, upload-time = "2025-08-10T21:25:40.489Z" }, - { url = "https://files.pythonhosted.org/packages/50/2b/362ebd3eec46c850ccf2bfe3e30f2fc4c008750011f38a850f088c56a1c6/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90f47e70293fc3688b71271100a1a5453aa9944a81d27ff779c108372cf5567b", size = 1244074, upload-time = "2025-08-10T21:25:42.221Z" }, - { url = "https://files.pythonhosted.org/packages/6f/bb/f09a1e66dab8984773d13184a10a29fe67125337649d26bdef547024ed6b/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fdca1def57a2e88ef339de1737a1449d6dbf5fab184c54a1fca01d541317154", size = 1293036, upload-time = "2025-08-10T21:25:43.801Z" }, - { url = "https://files.pythonhosted.org/packages/ea/01/11ecf892f201cafda0f68fa59212edaea93e96c37884b747c181303fccd1/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cf554f21be770f5111a1690d42313e140355e687e05cf82cb23d0a721a64a48", size = 2175310, upload-time = "2025-08-10T21:25:45.045Z" }, - { url = "https://files.pythonhosted.org/packages/7f/5f/bfe11d5b934f500cc004314819ea92427e6e5462706a498c1d4fc052e08f/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1795ac5cd0510207482c3d1d3ed781143383b8cfd36f5c645f3897ce066220", size = 2270943, upload-time = "2025-08-10T21:25:46.393Z" }, - { url = "https://files.pythonhosted.org/packages/3d/de/259f786bf71f1e03e73d87e2db1a9a3bcab64d7b4fd780167123161630ad/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ccd09f20ccdbbd341b21a67ab50a119b64a403b09288c27481575105283c1586", size = 2440488, upload-time = "2025-08-10T21:25:48.074Z" }, - { url = "https://files.pythonhosted.org/packages/1b/76/c989c278faf037c4d3421ec07a5c452cd3e09545d6dae7f87c15f54e4edf/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:540c7c72324d864406a009d72f5d6856f49693db95d1fbb46cf86febef873634", size = 2246787, upload-time = "2025-08-10T21:25:49.442Z" }, - { url = "https://files.pythonhosted.org/packages/a2/55/c2898d84ca440852e560ca9f2a0d28e6e931ac0849b896d77231929900e7/kiwisolver-1.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:ede8c6d533bc6601a47ad4046080d36b8fc99f81e6f1c17b0ac3c2dc91ac7611", size = 73730, upload-time = "2025-08-10T21:25:51.102Z" }, - { url = "https://files.pythonhosted.org/packages/e8/09/486d6ac523dd33b80b368247f238125d027964cfacb45c654841e88fb2ae/kiwisolver-1.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:7b4da0d01ac866a57dd61ac258c5607b4cd677f63abaec7b148354d2b2cdd536", size = 65036, upload-time = "2025-08-10T21:25:52.063Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167, upload-time = "2025-08-10T21:25:53.403Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579, upload-time = "2025-08-10T21:25:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309, upload-time = "2025-08-10T21:25:55.76Z" }, - { url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596, upload-time = "2025-08-10T21:25:56.861Z" }, - { url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548, upload-time = "2025-08-10T21:25:58.246Z" }, - { url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618, upload-time = "2025-08-10T21:25:59.857Z" }, - { url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437, upload-time = "2025-08-10T21:26:01.105Z" }, - { url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742, upload-time = "2025-08-10T21:26:02.675Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810, upload-time = "2025-08-10T21:26:04.009Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579, upload-time = "2025-08-10T21:26:05.317Z" }, - { url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071, upload-time = "2025-08-10T21:26:06.686Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840, upload-time = "2025-08-10T21:26:07.94Z" }, - { url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159, upload-time = "2025-08-10T21:26:09.048Z" }, - { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" }, - { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" }, - { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" }, - { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" }, - { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" }, - { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" }, - { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" }, - { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" }, - { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" }, - { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" }, - { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" }, - { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" }, - { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681, upload-time = "2025-08-10T21:26:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464, upload-time = "2025-08-10T21:26:27.733Z" }, - { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961, upload-time = "2025-08-10T21:26:28.729Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607, upload-time = "2025-08-10T21:26:29.798Z" }, - { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546, upload-time = "2025-08-10T21:26:31.401Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482, upload-time = "2025-08-10T21:26:32.721Z" }, - { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720, upload-time = "2025-08-10T21:26:34.032Z" }, - { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907, upload-time = "2025-08-10T21:26:35.824Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334, upload-time = "2025-08-10T21:26:37.534Z" }, - { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313, upload-time = "2025-08-10T21:26:39.191Z" }, - { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970, upload-time = "2025-08-10T21:26:40.828Z" }, - { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894, upload-time = "2025-08-10T21:26:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995, upload-time = "2025-08-10T21:26:43.889Z" }, - { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510, upload-time = "2025-08-10T21:26:44.915Z" }, - { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903, upload-time = "2025-08-10T21:26:45.934Z" }, - { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402, upload-time = "2025-08-10T21:26:47.101Z" }, - { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135, upload-time = "2025-08-10T21:26:48.665Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409, upload-time = "2025-08-10T21:26:50.335Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763, upload-time = "2025-08-10T21:26:51.867Z" }, - { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643, upload-time = "2025-08-10T21:26:53.592Z" }, - { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818, upload-time = "2025-08-10T21:26:55.051Z" }, - { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963, upload-time = "2025-08-10T21:26:56.421Z" }, - { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639, upload-time = "2025-08-10T21:26:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741, upload-time = "2025-08-10T21:26:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646, upload-time = "2025-08-10T21:27:00.52Z" }, - { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806, upload-time = "2025-08-10T21:27:01.537Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605, upload-time = "2025-08-10T21:27:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925, upload-time = "2025-08-10T21:27:04.339Z" }, - { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414, upload-time = "2025-08-10T21:27:05.437Z" }, - { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272, upload-time = "2025-08-10T21:27:07.063Z" }, - { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578, upload-time = "2025-08-10T21:27:08.452Z" }, - { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607, upload-time = "2025-08-10T21:27:10.125Z" }, - { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150, upload-time = "2025-08-10T21:27:11.484Z" }, - { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979, upload-time = "2025-08-10T21:27:12.917Z" }, - { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456, upload-time = "2025-08-10T21:27:14.353Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621, upload-time = "2025-08-10T21:27:15.808Z" }, - { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417, upload-time = "2025-08-10T21:27:17.436Z" }, - { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582, upload-time = "2025-08-10T21:27:18.436Z" }, - { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514, upload-time = "2025-08-10T21:27:19.465Z" }, - { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905, upload-time = "2025-08-10T21:27:20.51Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399, upload-time = "2025-08-10T21:27:21.496Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197, upload-time = "2025-08-10T21:27:22.604Z" }, - { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125, upload-time = "2025-08-10T21:27:24.036Z" }, - { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612, upload-time = "2025-08-10T21:27:25.773Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990, upload-time = "2025-08-10T21:27:27.089Z" }, - { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601, upload-time = "2025-08-10T21:27:29.343Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041, upload-time = "2025-08-10T21:27:30.754Z" }, - { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897, upload-time = "2025-08-10T21:27:32.803Z" }, - { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835, upload-time = "2025-08-10T21:27:34.23Z" }, - { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988, upload-time = "2025-08-10T21:27:35.587Z" }, - { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" }, - { url = "https://files.pythonhosted.org/packages/a2/63/fde392691690f55b38d5dd7b3710f5353bf7a8e52de93a22968801ab8978/kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4d1d9e582ad4d63062d34077a9a1e9f3c34088a2ec5135b1f7190c07cf366527", size = 60183, upload-time = "2025-08-10T21:27:37.669Z" }, - { url = "https://files.pythonhosted.org/packages/27/b1/6aad34edfdb7cced27f371866f211332bba215bfd918ad3322a58f480d8b/kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:deed0c7258ceb4c44ad5ec7d9918f9f14fd05b2be86378d86cf50e63d1e7b771", size = 58675, upload-time = "2025-08-10T21:27:39.031Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1a/23d855a702bb35a76faed5ae2ba3de57d323f48b1f6b17ee2176c4849463/kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a590506f303f512dff6b7f75fd2fd18e16943efee932008fe7140e5fa91d80e", size = 80277, upload-time = "2025-08-10T21:27:40.129Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5b/5239e3c2b8fb5afa1e8508f721bb77325f740ab6994d963e61b2b7abcc1e/kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e09c2279a4d01f099f52d5c4b3d9e208e91edcbd1a175c9662a8b16e000fece9", size = 77994, upload-time = "2025-08-10T21:27:41.181Z" }, - { url = "https://files.pythonhosted.org/packages/f9/1c/5d4d468fb16f8410e596ed0eac02d2c68752aa7dc92997fe9d60a7147665/kiwisolver-1.4.9-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c9e7cdf45d594ee04d5be1b24dd9d49f3d1590959b2271fb30b5ca2b262c00fb", size = 73744, upload-time = "2025-08-10T21:27:42.254Z" }, - { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104, upload-time = "2025-08-10T21:27:43.287Z" }, - { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592, upload-time = "2025-08-10T21:27:44.314Z" }, - { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281, upload-time = "2025-08-10T21:27:45.369Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009, upload-time = "2025-08-10T21:27:46.376Z" }, - { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929, upload-time = "2025-08-10T21:27:48.236Z" }, -] - [[package]] name = "librt" version = "0.7.8" @@ -1857,81 +1570,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] -[[package]] -name = "matplotlib" -version = "3.10.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "cycler" }, - { name = "fonttools" }, - { name = "kiwisolver" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "packaging" }, - { name = "pillow" }, - { name = "pyparsing" }, - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/be/a30bd917018ad220c400169fba298f2bb7003c8ccbc0c3e24ae2aacad1e8/matplotlib-3.10.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:00270d217d6b20d14b584c521f810d60c5c78406dc289859776550df837dcda7", size = 8239828, upload-time = "2025-12-10T22:55:02.313Z" }, - { url = "https://files.pythonhosted.org/packages/58/27/ca01e043c4841078e82cf6e80a6993dfecd315c3d79f5f3153afbb8e1ec6/matplotlib-3.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b3c1cc42aa184b3f738cfa18c1c1d72fd496d85467a6cf7b807936d39aa656", size = 8128050, upload-time = "2025-12-10T22:55:04.997Z" }, - { url = "https://files.pythonhosted.org/packages/cb/aa/7ab67f2b729ae6a91bcf9dcac0affb95fb8c56f7fd2b2af894ae0b0cf6fa/matplotlib-3.10.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee40c27c795bda6a5292e9cff9890189d32f7e3a0bf04e0e3c9430c4a00c37df", size = 8700452, upload-time = "2025-12-10T22:55:07.47Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/2d5817b0acee3c49b7e7ccfbf5b273f284957cc8e270adf36375db353190/matplotlib-3.10.8-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a48f2b74020919552ea25d222d5cc6af9ca3f4eb43a93e14d068457f545c2a17", size = 9534928, upload-time = "2025-12-10T22:55:10.566Z" }, - { url = "https://files.pythonhosted.org/packages/c9/5b/8e66653e9f7c39cb2e5cab25fce4810daffa2bff02cbf5f3077cea9e942c/matplotlib-3.10.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f254d118d14a7f99d616271d6c3c27922c092dac11112670b157798b89bf4933", size = 9586377, upload-time = "2025-12-10T22:55:12.362Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e2/fd0bbadf837f81edb0d208ba8f8cb552874c3b16e27cb91a31977d90875d/matplotlib-3.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:f9b587c9c7274c1613a30afabf65a272114cd6cdbe67b3406f818c79d7ab2e2a", size = 8128127, upload-time = "2025-12-10T22:55:14.436Z" }, - { url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215, upload-time = "2025-12-10T22:55:16.175Z" }, - { url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625, upload-time = "2025-12-10T22:55:17.712Z" }, - { url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614, upload-time = "2025-12-10T22:55:20.8Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f4/b8347351da9a5b3f41e26cf547252d861f685c6867d179a7c9d60ad50189/matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", size = 9540997, upload-time = "2025-12-10T22:55:23.258Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c0/c7b914e297efe0bc36917bf216b2acb91044b91e930e878ae12981e461e5/matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", size = 9596825, upload-time = "2025-12-10T22:55:25.217Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d3/a4bbc01c237ab710a1f22b4da72f4ff6d77eb4c7735ea9811a94ae239067/matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", size = 8135090, upload-time = "2025-12-10T22:55:27.162Z" }, - { url = "https://files.pythonhosted.org/packages/89/dd/a0b6588f102beab33ca6f5218b31725216577b2a24172f327eaf6417d5c9/matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", size = 8012377, upload-time = "2025-12-10T22:55:29.185Z" }, - { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, - { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, - { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, - { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, - { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, - { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, - { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, - { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, - { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, - { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, - { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, - { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, - { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, - { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, - { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, - { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, - { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, - { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, - { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, - { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, - { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, - { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, - { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, - { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/f5/43/31d59500bb950b0d188e149a2e552040528c13d6e3d6e84d0cccac593dcd/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8", size = 8237252, upload-time = "2025-12-10T22:56:39.529Z" }, - { url = "https://files.pythonhosted.org/packages/0c/2c/615c09984f3c5f907f51c886538ad785cf72e0e11a3225de2c0f9442aecc/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7", size = 8124693, upload-time = "2025-12-10T22:56:41.758Z" }, - { url = "https://files.pythonhosted.org/packages/91/e1/2757277a1c56041e1fc104b51a0f7b9a4afc8eb737865d63cababe30bc61/matplotlib-3.10.8-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3", size = 8702205, upload-time = "2025-12-10T22:56:43.415Z" }, - { url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198, upload-time = "2025-12-10T22:56:45.584Z" }, - { url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817, upload-time = "2025-12-10T22:56:47.339Z" }, - { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867, upload-time = "2025-12-10T22:56:48.954Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -2143,31 +1781,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] -[[package]] -name = "networkx" -version = "3.4.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, -] - -[[package]] -name = "networkx" -version = "3.6.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version >= '3.11' and python_full_version < '3.14'", -] -sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, -] - [[package]] name = "numpy" version = "2.2.6" @@ -2343,104 +1956,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" }, ] -[[package]] -name = "pillow" -version = "12.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/41/f73d92b6b883a579e79600d391f2e21cb0df767b2714ecbd2952315dfeef/pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd", size = 5304089, upload-time = "2026-01-02T09:10:24.953Z" }, - { url = "https://files.pythonhosted.org/packages/94/55/7aca2891560188656e4a91ed9adba305e914a4496800da6b5c0a15f09edf/pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0", size = 4657815, upload-time = "2026-01-02T09:10:27.063Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d2/b28221abaa7b4c40b7dba948f0f6a708bd7342c4d47ce342f0ea39643974/pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8", size = 6222593, upload-time = "2026-01-02T09:10:29.115Z" }, - { url = "https://files.pythonhosted.org/packages/71/b8/7a61fb234df6a9b0b479f69e66901209d89ff72a435b49933f9122f94cac/pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1", size = 8027579, upload-time = "2026-01-02T09:10:31.182Z" }, - { url = "https://files.pythonhosted.org/packages/ea/51/55c751a57cc524a15a0e3db20e5cde517582359508d62305a627e77fd295/pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda", size = 6335760, upload-time = "2026-01-02T09:10:33.02Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7c/60e3e6f5e5891a1a06b4c910f742ac862377a6fe842f7184df4a274ce7bf/pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7", size = 7027127, upload-time = "2026-01-02T09:10:35.009Z" }, - { url = "https://files.pythonhosted.org/packages/06/37/49d47266ba50b00c27ba63a7c898f1bb41a29627ced8c09e25f19ebec0ff/pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a", size = 6449896, upload-time = "2026-01-02T09:10:36.793Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/67fd87d2913902462cd9b79c6211c25bfe95fcf5783d06e1367d6d9a741f/pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef", size = 7151345, upload-time = "2026-01-02T09:10:39.064Z" }, - { url = "https://files.pythonhosted.org/packages/bd/15/f8c7abf82af68b29f50d77c227e7a1f87ce02fdc66ded9bf603bc3b41180/pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09", size = 6325568, upload-time = "2026-01-02T09:10:41.035Z" }, - { url = "https://files.pythonhosted.org/packages/d4/24/7d1c0e160b6b5ac2605ef7d8be537e28753c0db5363d035948073f5513d7/pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91", size = 7032367, upload-time = "2026-01-02T09:10:43.09Z" }, - { url = "https://files.pythonhosted.org/packages/f4/03/41c038f0d7a06099254c60f618d0ec7be11e79620fc23b8e85e5b31d9a44/pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea", size = 2452345, upload-time = "2026-01-02T09:10:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3", size = 5304057, upload-time = "2026-01-02T09:10:46.627Z" }, - { url = "https://files.pythonhosted.org/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0", size = 4657811, upload-time = "2026-01-02T09:10:49.548Z" }, - { url = "https://files.pythonhosted.org/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451", size = 6232243, upload-time = "2026-01-02T09:10:51.62Z" }, - { url = "https://files.pythonhosted.org/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e", size = 8037872, upload-time = "2026-01-02T09:10:53.446Z" }, - { url = "https://files.pythonhosted.org/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84", size = 6345398, upload-time = "2026-01-02T09:10:55.426Z" }, - { url = "https://files.pythonhosted.org/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0", size = 7034667, upload-time = "2026-01-02T09:10:57.11Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b", size = 6458743, upload-time = "2026-01-02T09:10:59.331Z" }, - { url = "https://files.pythonhosted.org/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18", size = 7159342, upload-time = "2026-01-02T09:11:01.82Z" }, - { url = "https://files.pythonhosted.org/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64", size = 6328655, upload-time = "2026-01-02T09:11:04.556Z" }, - { url = "https://files.pythonhosted.org/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75", size = 7031469, upload-time = "2026-01-02T09:11:06.538Z" }, - { url = "https://files.pythonhosted.org/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304", size = 2452515, upload-time = "2026-01-02T09:11:08.226Z" }, - { url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" }, - { url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" }, - { url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" }, - { url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" }, - { url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" }, - { url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" }, - { url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" }, - { url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" }, - { url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" }, - { url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" }, - { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" }, - { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" }, - { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" }, - { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" }, - { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" }, - { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" }, - { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" }, - { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" }, - { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" }, - { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" }, - { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" }, - { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" }, - { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" }, - { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" }, - { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" }, - { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" }, - { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" }, - { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" }, - { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" }, - { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" }, - { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" }, - { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" }, - { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" }, - { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" }, - { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" }, - { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" }, - { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" }, - { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" }, - { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" }, - { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" }, - { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" }, - { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" }, - { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" }, - { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" }, - { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" }, - { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" }, - { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" }, - { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" }, - { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" }, - { url = "https://files.pythonhosted.org/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377", size = 5228605, upload-time = "2026-01-02T09:13:14.084Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72", size = 4622245, upload-time = "2026-01-02T09:13:15.964Z" }, - { url = "https://files.pythonhosted.org/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c", size = 5247593, upload-time = "2026-01-02T09:13:17.913Z" }, - { url = "https://files.pythonhosted.org/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd", size = 6989008, upload-time = "2026-01-02T09:13:20.083Z" }, - { url = "https://files.pythonhosted.org/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc", size = 5313824, upload-time = "2026-01-02T09:13:22.405Z" }, - { url = "https://files.pythonhosted.org/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a", size = 5963278, upload-time = "2026-01-02T09:13:24.706Z" }, - { url = "https://files.pythonhosted.org/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", size = 7029809, upload-time = "2026-01-02T09:13:26.541Z" }, -] - [[package]] name = "pluggy" version = "1.6.0" @@ -2875,15 +2390,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, ] -[[package]] -name = "pyparsing" -version = "3.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, -] - [[package]] name = "pytest" version = "7.4.4" @@ -3096,140 +2602,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/e1/7348090988095e4e39560cfc2f7555b1b2a7357deba19167b600fdf5215d/ruff-0.14.13-py3-none-win_arm64.whl", hash = "sha256:7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247", size = 13080224, upload-time = "2026-01-15T20:14:45.853Z" }, ] -[[package]] -name = "scipy" -version = "1.15.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, - { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, - { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, - { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, - { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, - { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, - { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, - { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, - { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, - { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" }, - { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" }, - { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" }, - { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" }, - { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" }, - { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" }, - { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" }, - { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" }, - { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" }, - { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" }, - { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" }, - { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" }, - { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, - { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, - { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, - { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" }, - { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" }, - { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" }, - { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" }, - { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" }, - { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" }, - { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" }, - { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" }, - { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" }, - { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" }, - { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" }, - { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" }, - { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" }, - { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" }, - { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" }, - { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" }, -] - -[[package]] -name = "scipy" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version >= '3.11' and python_full_version < '3.14'", -] -dependencies = [ - { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/4b/c89c131aa87cad2b77a54eb0fb94d633a842420fa7e919dc2f922037c3d8/scipy-1.17.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:2abd71643797bd8a106dff97894ff7869eeeb0af0f7a5ce02e4227c6a2e9d6fd", size = 31381316, upload-time = "2026-01-10T21:24:33.42Z" }, - { url = "https://files.pythonhosted.org/packages/5e/5f/a6b38f79a07d74989224d5f11b55267714707582908a5f1ae854cf9a9b84/scipy-1.17.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:ef28d815f4d2686503e5f4f00edc387ae58dfd7a2f42e348bb53359538f01558", size = 27966760, upload-time = "2026-01-10T21:24:38.911Z" }, - { url = "https://files.pythonhosted.org/packages/c1/20/095ad24e031ee8ed3c5975954d816b8e7e2abd731e04f8be573de8740885/scipy-1.17.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:272a9f16d6bb4667e8b50d25d71eddcc2158a214df1b566319298de0939d2ab7", size = 20138701, upload-time = "2026-01-10T21:24:43.249Z" }, - { url = "https://files.pythonhosted.org/packages/89/11/4aad2b3858d0337756f3323f8960755704e530b27eb2a94386c970c32cbe/scipy-1.17.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:7204fddcbec2fe6598f1c5fdf027e9f259106d05202a959a9f1aecf036adc9f6", size = 22480574, upload-time = "2026-01-10T21:24:47.266Z" }, - { url = "https://files.pythonhosted.org/packages/85/bd/f5af70c28c6da2227e510875cadf64879855193a687fb19951f0f44cfd6b/scipy-1.17.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc02c37a5639ee67d8fb646ffded6d793c06c5622d36b35cfa8fe5ececb8f042", size = 32862414, upload-time = "2026-01-10T21:24:52.566Z" }, - { url = "https://files.pythonhosted.org/packages/ef/df/df1457c4df3826e908879fe3d76bc5b6e60aae45f4ee42539512438cfd5d/scipy-1.17.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dac97a27520d66c12a34fd90a4fe65f43766c18c0d6e1c0a80f114d2260080e4", size = 35112380, upload-time = "2026-01-10T21:24:58.433Z" }, - { url = "https://files.pythonhosted.org/packages/5f/bb/88e2c16bd1dd4de19d80d7c5e238387182993c2fb13b4b8111e3927ad422/scipy-1.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb7446a39b3ae0fe8f416a9a3fdc6fba3f11c634f680f16a239c5187bc487c0", size = 34922676, upload-time = "2026-01-10T21:25:04.287Z" }, - { url = "https://files.pythonhosted.org/packages/02/ba/5120242cc735f71fc002cff0303d536af4405eb265f7c60742851e7ccfe9/scipy-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:474da16199f6af66601a01546144922ce402cb17362e07d82f5a6cf8f963e449", size = 37507599, upload-time = "2026-01-10T21:25:09.851Z" }, - { url = "https://files.pythonhosted.org/packages/52/c8/08629657ac6c0da198487ce8cd3de78e02cfde42b7f34117d56a3fe249dc/scipy-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:255c0da161bd7b32a6c898e7891509e8a9289f0b1c6c7d96142ee0d2b114c2ea", size = 36380284, upload-time = "2026-01-10T21:25:15.632Z" }, - { url = "https://files.pythonhosted.org/packages/6c/4a/465f96d42c6f33ad324a40049dfd63269891db9324aa66c4a1c108c6f994/scipy-1.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:85b0ac3ad17fa3be50abd7e69d583d98792d7edc08367e01445a1e2076005379", size = 24370427, upload-time = "2026-01-10T21:25:20.514Z" }, - { url = "https://files.pythonhosted.org/packages/0b/11/7241a63e73ba5a516f1930ac8d5b44cbbfabd35ac73a2d08ca206df007c4/scipy-1.17.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:0d5018a57c24cb1dd828bcf51d7b10e65986d549f52ef5adb6b4d1ded3e32a57", size = 31364580, upload-time = "2026-01-10T21:25:25.717Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1d/5057f812d4f6adc91a20a2d6f2ebcdb517fdbc87ae3acc5633c9b97c8ba5/scipy-1.17.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:88c22af9e5d5a4f9e027e26772cc7b5922fab8bcc839edb3ae33de404feebd9e", size = 27969012, upload-time = "2026-01-10T21:25:30.921Z" }, - { url = "https://files.pythonhosted.org/packages/e3/21/f6ec556c1e3b6ec4e088da667d9987bb77cc3ab3026511f427dc8451187d/scipy-1.17.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f3cd947f20fe17013d401b64e857c6b2da83cae567adbb75b9dcba865abc66d8", size = 20140691, upload-time = "2026-01-10T21:25:34.802Z" }, - { url = "https://files.pythonhosted.org/packages/7a/fe/5e5ad04784964ba964a96f16c8d4676aa1b51357199014dce58ab7ec5670/scipy-1.17.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e8c0b331c2c1f531eb51f1b4fc9ba709521a712cce58f1aa627bc007421a5306", size = 22463015, upload-time = "2026-01-10T21:25:39.277Z" }, - { url = "https://files.pythonhosted.org/packages/4a/69/7c347e857224fcaf32a34a05183b9d8a7aca25f8f2d10b8a698b8388561a/scipy-1.17.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5194c445d0a1c7a6c1a4a4681b6b7c71baad98ff66d96b949097e7513c9d6742", size = 32724197, upload-time = "2026-01-10T21:25:44.084Z" }, - { url = "https://files.pythonhosted.org/packages/d1/fe/66d73b76d378ba8cc2fe605920c0c75092e3a65ae746e1e767d9d020a75a/scipy-1.17.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9eeb9b5f5997f75507814ed9d298ab23f62cf79f5a3ef90031b1ee2506abdb5b", size = 35009148, upload-time = "2026-01-10T21:25:50.591Z" }, - { url = "https://files.pythonhosted.org/packages/af/07/07dec27d9dc41c18d8c43c69e9e413431d20c53a0339c388bcf72f353c4b/scipy-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:40052543f7bbe921df4408f46003d6f01c6af109b9e2c8a66dd1cf6cf57f7d5d", size = 34798766, upload-time = "2026-01-10T21:25:59.41Z" }, - { url = "https://files.pythonhosted.org/packages/81/61/0470810c8a093cdacd4ba7504b8a218fd49ca070d79eca23a615f5d9a0b0/scipy-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0cf46c8013fec9d3694dc572f0b54100c28405d55d3e2cb15e2895b25057996e", size = 37405953, upload-time = "2026-01-10T21:26:07.75Z" }, - { url = "https://files.pythonhosted.org/packages/92/ce/672ed546f96d5d41ae78c4b9b02006cedd0b3d6f2bf5bb76ea455c320c28/scipy-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:0937a0b0d8d593a198cededd4c439a0ea216a3f36653901ea1f3e4be949056f8", size = 36328121, upload-time = "2026-01-10T21:26:16.509Z" }, - { url = "https://files.pythonhosted.org/packages/9d/21/38165845392cae67b61843a52c6455d47d0cc2a40dd495c89f4362944654/scipy-1.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:f603d8a5518c7426414d1d8f82e253e454471de682ce5e39c29adb0df1efb86b", size = 24314368, upload-time = "2026-01-10T21:26:23.087Z" }, - { url = "https://files.pythonhosted.org/packages/0c/51/3468fdfd49387ddefee1636f5cf6d03ce603b75205bf439bbf0e62069bfd/scipy-1.17.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:65ec32f3d32dfc48c72df4291345dae4f048749bc8d5203ee0a3f347f96c5ce6", size = 31344101, upload-time = "2026-01-10T21:26:30.25Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/9406aec58268d437636069419e6977af953d1e246df941d42d3720b7277b/scipy-1.17.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1f9586a58039d7229ce77b52f8472c972448cded5736eaf102d5658bbac4c269", size = 27950385, upload-time = "2026-01-10T21:26:36.801Z" }, - { url = "https://files.pythonhosted.org/packages/4f/98/e7342709e17afdfd1b26b56ae499ef4939b45a23a00e471dfb5375eea205/scipy-1.17.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9fad7d3578c877d606b1150135c2639e9de9cecd3705caa37b66862977cc3e72", size = 20122115, upload-time = "2026-01-10T21:26:42.107Z" }, - { url = "https://files.pythonhosted.org/packages/fd/0e/9eeeb5357a64fd157cbe0302c213517c541cc16b8486d82de251f3c68ede/scipy-1.17.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:423ca1f6584fc03936972b5f7c06961670dbba9f234e71676a7c7ccf938a0d61", size = 22442402, upload-time = "2026-01-10T21:26:48.029Z" }, - { url = "https://files.pythonhosted.org/packages/c9/10/be13397a0e434f98e0c79552b2b584ae5bb1c8b2be95db421533bbca5369/scipy-1.17.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe508b5690e9eaaa9467fc047f833af58f1152ae51a0d0aed67aa5801f4dd7d6", size = 32696338, upload-time = "2026-01-10T21:26:55.521Z" }, - { url = "https://files.pythonhosted.org/packages/63/1e/12fbf2a3bb240161651c94bb5cdd0eae5d4e8cc6eaeceb74ab07b12a753d/scipy-1.17.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6680f2dfd4f6182e7d6db161344537da644d1cf85cf293f015c60a17ecf08752", size = 34977201, upload-time = "2026-01-10T21:27:03.501Z" }, - { url = "https://files.pythonhosted.org/packages/19/5b/1a63923e23ccd20bd32156d7dd708af5bbde410daa993aa2500c847ab2d2/scipy-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eec3842ec9ac9de5917899b277428886042a93db0b227ebbe3a333b64ec7643d", size = 34777384, upload-time = "2026-01-10T21:27:11.423Z" }, - { url = "https://files.pythonhosted.org/packages/39/22/b5da95d74edcf81e540e467202a988c50fef41bd2011f46e05f72ba07df6/scipy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d7425fcafbc09a03731e1bc05581f5fad988e48c6a861f441b7ab729a49a55ea", size = 37379586, upload-time = "2026-01-10T21:27:20.171Z" }, - { url = "https://files.pythonhosted.org/packages/b9/b6/8ac583d6da79e7b9e520579f03007cb006f063642afd6b2eeb16b890bf93/scipy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:87b411e42b425b84777718cc41516b8a7e0795abfa8e8e1d573bf0ef014f0812", size = 36287211, upload-time = "2026-01-10T21:28:43.122Z" }, - { url = "https://files.pythonhosted.org/packages/55/fb/7db19e0b3e52f882b420417644ec81dd57eeef1bd1705b6f689d8ff93541/scipy-1.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:357ca001c6e37601066092e7c89cca2f1ce74e2a520ca78d063a6d2201101df2", size = 24312646, upload-time = "2026-01-10T21:28:49.893Z" }, - { url = "https://files.pythonhosted.org/packages/20/b6/7feaa252c21cc7aff335c6c55e1b90ab3e3306da3f048109b8b639b94648/scipy-1.17.0-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:ec0827aa4d36cb79ff1b81de898e948a51ac0b9b1c43e4a372c0508c38c0f9a3", size = 31693194, upload-time = "2026-01-10T21:27:27.454Z" }, - { url = "https://files.pythonhosted.org/packages/76/bb/bbb392005abce039fb7e672cb78ac7d158700e826b0515cab6b5b60c26fb/scipy-1.17.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:819fc26862b4b3c73a60d486dbb919202f3d6d98c87cf20c223511429f2d1a97", size = 28365415, upload-time = "2026-01-10T21:27:34.26Z" }, - { url = "https://files.pythonhosted.org/packages/37/da/9d33196ecc99fba16a409c691ed464a3a283ac454a34a13a3a57c0d66f3a/scipy-1.17.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:363ad4ae2853d88ebcde3ae6ec46ccca903ea9835ee8ba543f12f575e7b07e4e", size = 20537232, upload-time = "2026-01-10T21:27:40.306Z" }, - { url = "https://files.pythonhosted.org/packages/56/9d/f4b184f6ddb28e9a5caea36a6f98e8ecd2a524f9127354087ce780885d83/scipy-1.17.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:979c3a0ff8e5ba254d45d59ebd38cde48fce4f10b5125c680c7a4bfe177aab07", size = 22791051, upload-time = "2026-01-10T21:27:46.539Z" }, - { url = "https://files.pythonhosted.org/packages/9b/9d/025cccdd738a72140efc582b1641d0dd4caf2e86c3fb127568dc80444e6e/scipy-1.17.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:130d12926ae34399d157de777472bf82e9061c60cc081372b3118edacafe1d00", size = 32815098, upload-time = "2026-01-10T21:27:54.389Z" }, - { url = "https://files.pythonhosted.org/packages/48/5f/09b879619f8bca15ce392bfc1894bd9c54377e01d1b3f2f3b595a1b4d945/scipy-1.17.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e886000eb4919eae3a44f035e63f0fd8b651234117e8f6f29bad1cd26e7bc45", size = 35031342, upload-time = "2026-01-10T21:28:03.012Z" }, - { url = "https://files.pythonhosted.org/packages/f2/9a/f0f0a9f0aa079d2f106555b984ff0fbb11a837df280f04f71f056ea9c6e4/scipy-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13c4096ac6bc31d706018f06a49abe0485f96499deb82066b94d19b02f664209", size = 34893199, upload-time = "2026-01-10T21:28:10.832Z" }, - { url = "https://files.pythonhosted.org/packages/90/b8/4f0f5cf0c5ea4d7548424e6533e6b17d164f34a6e2fb2e43ffebb6697b06/scipy-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cacbaddd91fcffde703934897c5cd2c7cb0371fac195d383f4e1f1c5d3f3bd04", size = 37438061, upload-time = "2026-01-10T21:28:19.684Z" }, - { url = "https://files.pythonhosted.org/packages/f9/cc/2bd59140ed3b2fa2882fb15da0a9cb1b5a6443d67cfd0d98d4cec83a57ec/scipy-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:edce1a1cf66298cccdc48a1bdf8fb10a3bf58e8b58d6c3883dd1530e103f87c0", size = 36328593, upload-time = "2026-01-10T21:28:28.007Z" }, - { url = "https://files.pythonhosted.org/packages/13/1b/c87cc44a0d2c7aaf0f003aef2904c3d097b422a96c7e7c07f5efd9073c1b/scipy-1.17.0-cp313-cp313t-win_arm64.whl", hash = "sha256:30509da9dbec1c2ed8f168b8d8aa853bc6723fede1dbc23c7d43a56f5ab72a67", size = 24625083, upload-time = "2026-01-10T21:28:35.188Z" }, - { url = "https://files.pythonhosted.org/packages/1a/2d/51006cd369b8e7879e1c630999a19d1fbf6f8b5ed3e33374f29dc87e53b3/scipy-1.17.0-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:c17514d11b78be8f7e6331b983a65a7f5ca1fd037b95e27b280921fe5606286a", size = 31346803, upload-time = "2026-01-10T21:28:57.24Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2e/2349458c3ce445f53a6c93d4386b1c4c5c0c540917304c01222ff95ff317/scipy-1.17.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:4e00562e519c09da34c31685f6acc3aa384d4d50604db0f245c14e1b4488bfa2", size = 27967182, upload-time = "2026-01-10T21:29:04.107Z" }, - { url = "https://files.pythonhosted.org/packages/5e/7c/df525fbfa77b878d1cfe625249529514dc02f4fd5f45f0f6295676a76528/scipy-1.17.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7df7941d71314e60a481e02d5ebcb3f0185b8d799c70d03d8258f6c80f3d467", size = 20139125, upload-time = "2026-01-10T21:29:10.179Z" }, - { url = "https://files.pythonhosted.org/packages/33/11/fcf9d43a7ed1234d31765ec643b0515a85a30b58eddccc5d5a4d12b5f194/scipy-1.17.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:aabf057c632798832f071a8dde013c2e26284043934f53b00489f1773b33527e", size = 22443554, upload-time = "2026-01-10T21:29:15.888Z" }, - { url = "https://files.pythonhosted.org/packages/80/5c/ea5d239cda2dd3d31399424967a24d556cf409fbea7b5b21412b0fd0a44f/scipy-1.17.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a38c3337e00be6fd8a95b4ed66b5d988bac4ec888fd922c2ea9fe5fb1603dd67", size = 32757834, upload-time = "2026-01-10T21:29:23.406Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7e/8c917cc573310e5dc91cbeead76f1b600d3fb17cf0969db02c9cf92e3cfa/scipy-1.17.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00fb5f8ec8398ad90215008d8b6009c9db9fa924fd4c7d6be307c6f945f9cd73", size = 34995775, upload-time = "2026-01-10T21:29:31.915Z" }, - { url = "https://files.pythonhosted.org/packages/c5/43/176c0c3c07b3f7df324e7cdd933d3e2c4898ca202b090bd5ba122f9fe270/scipy-1.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f2a4942b0f5f7c23c7cd641a0ca1955e2ae83dedcff537e3a0259096635e186b", size = 34841240, upload-time = "2026-01-10T21:29:39.995Z" }, - { url = "https://files.pythonhosted.org/packages/44/8c/d1f5f4b491160592e7f084d997de53a8e896a3ac01cd07e59f43ca222744/scipy-1.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf133ced83889583156566d2bdf7a07ff89228fe0c0cb727f777de92092ec6b", size = 37394463, upload-time = "2026-01-10T21:29:48.723Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ec/42a6657f8d2d087e750e9a5dde0b481fd135657f09eaf1cf5688bb23c338/scipy-1.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3625c631a7acd7cfd929e4e31d2582cf00f42fcf06011f59281271746d77e061", size = 37053015, upload-time = "2026-01-10T21:30:51.418Z" }, - { url = "https://files.pythonhosted.org/packages/27/58/6b89a6afd132787d89a362d443a7bddd511b8f41336a1ae47f9e4f000dc4/scipy-1.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:9244608d27eafe02b20558523ba57f15c689357c85bdcfe920b1828750aa26eb", size = 24951312, upload-time = "2026-01-10T21:30:56.771Z" }, - { url = "https://files.pythonhosted.org/packages/e9/01/f58916b9d9ae0112b86d7c3b10b9e685625ce6e8248df139d0fcb17f7397/scipy-1.17.0-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:2b531f57e09c946f56ad0b4a3b2abee778789097871fc541e267d2eca081cff1", size = 31706502, upload-time = "2026-01-10T21:29:56.326Z" }, - { url = "https://files.pythonhosted.org/packages/59/8e/2912a87f94a7d1f8b38aabc0faf74b82d3b6c9e22be991c49979f0eceed8/scipy-1.17.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:13e861634a2c480bd237deb69333ac79ea1941b94568d4b0efa5db5e263d4fd1", size = 28380854, upload-time = "2026-01-10T21:30:01.554Z" }, - { url = "https://files.pythonhosted.org/packages/bd/1c/874137a52dddab7d5d595c1887089a2125d27d0601fce8c0026a24a92a0b/scipy-1.17.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:eb2651271135154aa24f6481cbae5cc8af1f0dd46e6533fb7b56aa9727b6a232", size = 20552752, upload-time = "2026-01-10T21:30:05.93Z" }, - { url = "https://files.pythonhosted.org/packages/3f/f0/7518d171cb735f6400f4576cf70f756d5b419a07fe1867da34e2c2c9c11b/scipy-1.17.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:c5e8647f60679790c2f5c76be17e2e9247dc6b98ad0d3b065861e082c56e078d", size = 22803972, upload-time = "2026-01-10T21:30:10.651Z" }, - { url = "https://files.pythonhosted.org/packages/7c/74/3498563a2c619e8a3ebb4d75457486c249b19b5b04a30600dfd9af06bea5/scipy-1.17.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fb10d17e649e1446410895639f3385fd2bf4c3c7dfc9bea937bddcbc3d7b9ba", size = 32829770, upload-time = "2026-01-10T21:30:16.359Z" }, - { url = "https://files.pythonhosted.org/packages/48/d1/7b50cedd8c6c9d6f706b4b36fa8544d829c712a75e370f763b318e9638c1/scipy-1.17.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8547e7c57f932e7354a2319fab613981cde910631979f74c9b542bb167a8b9db", size = 35051093, upload-time = "2026-01-10T21:30:22.987Z" }, - { url = "https://files.pythonhosted.org/packages/e2/82/a2d684dfddb87ba1b3ea325df7c3293496ee9accb3a19abe9429bce94755/scipy-1.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33af70d040e8af9d5e7a38b5ed3b772adddd281e3062ff23fec49e49681c38cf", size = 34909905, upload-time = "2026-01-10T21:30:28.704Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5e/e565bd73991d42023eb82bb99e51c5b3d9e2c588ca9d4b3e2cc1d3ca62a6/scipy-1.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb55bb97d00f8b7ab95cb64f873eb0bf54d9446264d9f3609130381233483f", size = 37457743, upload-time = "2026-01-10T21:30:34.819Z" }, - { url = "https://files.pythonhosted.org/packages/58/a8/a66a75c3d8f1fb2b83f66007d6455a06a6f6cf5618c3dc35bc9b69dd096e/scipy-1.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1ff269abf702f6c7e67a4b7aad981d42871a11b9dd83c58d2d2ea624efbd1088", size = 37098574, upload-time = "2026-01-10T21:30:40.782Z" }, - { url = "https://files.pythonhosted.org/packages/56/a5/df8f46ef7da168f1bc52cd86e09a9de5c6f19cc1da04454d51b7d4f43408/scipy-1.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:031121914e295d9791319a1875444d55079885bbae5bdc9c5e0f2ee5f09d34ff", size = 25246266, upload-time = "2026-01-10T21:30:45.923Z" }, -] - [[package]] name = "sentry-sdk" version = "1.45.1"