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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,3 @@
[*.py]
indent_style = space
indent_size = 4
@@ -0,0 +1 @@
3.13
@@ -0,0 +1,20 @@
# n8n Task Runner Python
Native Python task runner for n8n
## Development
Install:
- [Python 3.13+](https://www.python.org/)
- [uv](https://github.com/astral-sh/uv)
- [just](https://github.com/casey/just)
Set up dependencies:
```sh
just sync # or
just sync-all
```
See `justfile` for available commands.
+49
View File
@@ -0,0 +1,49 @@
check:
just lint
just format-check
just typecheck
sync:
uv sync --group dev
sync-all:
uv sync --all-extras --group dev
lint:
uv run ruff check
lintfix:
uv run ruff check --fix
format:
uv run ruff format
format-check:
uv run ruff format --check
test:
uv run pytest
test-cov:
uv run pytest --cov=src --cov-report=term-missing
test-v:
uv run pytest -vv
typecheck:
uv run ty check src/
# For debugging only, start the runner with a manually fetched grant token. If no broker, wait until available.
debug:
#!/usr/bin/env bash
for i in {1..30}; do
GRANT_TOKEN=$(curl -s -X POST http://127.0.0.1:5679/runners/auth -H "Content-Type: application/json" -d '{"token":"test"}' | jq -r '.data.token')
if [ -n "$GRANT_TOKEN" ] && [ "$GRANT_TOKEN" != "null" ]; then
PYTHONPATH="{{justfile_directory()}}" N8N_RUNNERS_GRANT_TOKEN="$GRANT_TOKEN" uv run python src/main.py
exit 0
fi
[ $i -eq 1 ] && echo "Waiting for n8n task broker server at http://127.0.0.1:5679..."
sleep 1
done
echo "Error: Could not connect to n8n task broker server after 30 seconds"
exit 1
@@ -0,0 +1,39 @@
[project]
name = "task-runner-python"
version = "0.1.0"
description = "Native Python task runner for n8n"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"websockets>=15.0.1",
]
[project.optional-dependencies]
sentry = ["sentry-sdk>=2.35.2"]
[tool.uv]
constraint-dependencies = ["urllib3>=2.6.3"]
[dependency-groups]
dev = [
"ruff>=0.14.10",
"ty>=0.0.5",
"pytest>=8.0.0",
"pytest-cov>=5.0.0",
"pytest-asyncio>=0.24.0",
"aiohttp>=3.10.0",
"setuptools>=80.10.2",
]
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
addopts = "-v"
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["."]
include = ["src*"]
@@ -0,0 +1,4 @@
{
"venvPath": ".",
"venv": ".venv"
}
@@ -0,0 +1,34 @@
from dataclasses import dataclass
from src.env import read_int_env, read_bool_env, read_str_env
from src.errors import ConfigurationError
from src.constants import (
DEFAULT_HEALTH_CHECK_SERVER_HOST,
DEFAULT_HEALTH_CHECK_SERVER_PORT,
ENV_HEALTH_CHECK_SERVER_ENABLED,
ENV_HEALTH_CHECK_SERVER_HOST,
ENV_HEALTH_CHECK_SERVER_PORT,
)
@dataclass
class HealthCheckConfig:
enabled: bool
host: str
port: int
@classmethod
def from_env(cls):
port = read_int_env(
ENV_HEALTH_CHECK_SERVER_PORT, DEFAULT_HEALTH_CHECK_SERVER_PORT
)
if port < 0 or port > 65535:
raise ConfigurationError(f"Port must be between 0 and 65535, got {port}")
return cls(
enabled=read_bool_env(ENV_HEALTH_CHECK_SERVER_ENABLED, default=False),
host=read_str_env(
ENV_HEALTH_CHECK_SERVER_HOST, DEFAULT_HEALTH_CHECK_SERVER_HOST
),
port=port,
)
@@ -0,0 +1,9 @@
from dataclasses import dataclass
@dataclass
class SecurityConfig:
stdlib_allow: set[str]
external_allow: set[str]
builtins_deny: set[str]
runner_env_deny: bool
@@ -0,0 +1,36 @@
from dataclasses import dataclass
from src.env import read_str_env, read_float_env
from src.constants import (
ENV_DEPLOYMENT_NAME,
ENV_ENVIRONMENT,
ENV_N8N_VERSION,
ENV_SENTRY_DSN,
ENV_SENTRY_PROFILES_SAMPLE_RATE,
ENV_SENTRY_TRACES_SAMPLE_RATE,
)
@dataclass
class SentryConfig:
dsn: str
n8n_version: str
environment: str
deployment_name: str
profiles_sample_rate: float
traces_sample_rate: float
@property
def enabled(self) -> bool:
return bool(self.dsn)
@classmethod
def from_env(cls):
return cls(
dsn=read_str_env(ENV_SENTRY_DSN, ""),
n8n_version=read_str_env(ENV_N8N_VERSION, ""),
environment=read_str_env(ENV_ENVIRONMENT, ""),
deployment_name=read_str_env(ENV_DEPLOYMENT_NAME, ""),
profiles_sample_rate=read_float_env(ENV_SENTRY_PROFILES_SAMPLE_RATE, 0),
traces_sample_rate=read_float_env(ENV_SENTRY_TRACES_SAMPLE_RATE, 0),
)
@@ -0,0 +1,122 @@
from dataclasses import dataclass
from src.env import read_bool_env, read_int_env, read_str_env
from src.errors import ConfigurationError
from src.constants import (
BUILTINS_DENY_DEFAULT,
DEFAULT_MAX_CONCURRENCY,
DEFAULT_MAX_PAYLOAD_SIZE,
DEFAULT_TASK_BROKER_URI,
DEFAULT_TASK_TIMEOUT,
DEFAULT_AUTO_SHUTDOWN_TIMEOUT,
DEFAULT_SHUTDOWN_TIMEOUT,
ENV_BLOCK_RUNNER_ENV_ACCESS,
ENV_BUILTINS_DENY,
ENV_EXTERNAL_ALLOW,
ENV_GRANT_TOKEN,
ENV_MAX_CONCURRENCY,
ENV_MAX_PAYLOAD_SIZE,
ENV_STDLIB_ALLOW,
ENV_TASK_BROKER_URI,
ENV_TASK_TIMEOUT,
ENV_AUTO_SHUTDOWN_TIMEOUT,
ENV_GRACEFUL_SHUTDOWN_TIMEOUT,
PIPE_MSG_MAX_SIZE,
)
def parse_allowlist(allowlist_str: str, list_name: str) -> set[str]:
if not allowlist_str:
return set()
modules = {
module
for raw_module in allowlist_str.split(",")
if (module := raw_module.strip())
}
if "*" in modules and len(modules) > 1:
raise ConfigurationError(
f"Wildcard '*' in {list_name} must be used alone, not with other modules. "
f"Got: {', '.join(sorted(modules))}"
)
return modules
@dataclass
class TaskRunnerConfig:
grant_token: str
task_broker_uri: str
max_concurrency: int
max_payload_size: int
task_timeout: int
auto_shutdown_timeout: int
graceful_shutdown_timeout: int
stdlib_allow: set[str]
external_allow: set[str]
builtins_deny: set[str]
env_deny: bool
@property
def is_auto_shutdown_enabled(self) -> bool:
return self.auto_shutdown_timeout > 0
@classmethod
def from_env(cls):
grant_token = read_str_env(ENV_GRANT_TOKEN, "")
if not grant_token:
raise ConfigurationError(
"Environment variable N8N_RUNNERS_GRANT_TOKEN is required"
)
task_timeout = read_int_env(ENV_TASK_TIMEOUT, DEFAULT_TASK_TIMEOUT)
if task_timeout <= 0:
raise ConfigurationError(
f"Task timeout must be positive, got {task_timeout}"
)
auto_shutdown_timeout = read_int_env(
ENV_AUTO_SHUTDOWN_TIMEOUT, DEFAULT_AUTO_SHUTDOWN_TIMEOUT
)
if auto_shutdown_timeout < 0:
raise ConfigurationError(
f"Auto shutdown timeout must be non-negative, got {auto_shutdown_timeout}"
)
graceful_shutdown_timeout = read_int_env(
ENV_GRACEFUL_SHUTDOWN_TIMEOUT, DEFAULT_SHUTDOWN_TIMEOUT
)
if graceful_shutdown_timeout <= 0:
raise ConfigurationError(
f"Graceful shutdown timeout must be positive, got {graceful_shutdown_timeout}"
)
max_payload_size = read_int_env(ENV_MAX_PAYLOAD_SIZE, DEFAULT_MAX_PAYLOAD_SIZE)
if max_payload_size > PIPE_MSG_MAX_SIZE:
raise ConfigurationError(
f"Max payload size of {max_payload_size} bytes exceeds pipe message limit of {PIPE_MSG_MAX_SIZE} bytes. Reduce {ENV_MAX_PAYLOAD_SIZE}."
)
return cls(
grant_token=grant_token,
task_broker_uri=read_str_env(ENV_TASK_BROKER_URI, DEFAULT_TASK_BROKER_URI),
max_concurrency=read_int_env(ENV_MAX_CONCURRENCY, DEFAULT_MAX_CONCURRENCY),
max_payload_size=max_payload_size,
task_timeout=task_timeout,
auto_shutdown_timeout=auto_shutdown_timeout,
graceful_shutdown_timeout=graceful_shutdown_timeout,
stdlib_allow=parse_allowlist(
read_str_env(ENV_STDLIB_ALLOW, ""), ENV_STDLIB_ALLOW
),
external_allow=parse_allowlist(
read_str_env(ENV_EXTERNAL_ALLOW, ""), ENV_EXTERNAL_ALLOW
),
builtins_deny=set(
module.strip()
for module in read_str_env(
ENV_BUILTINS_DENY, BUILTINS_DENY_DEFAULT
).split(",")
),
env_deny=read_bool_env(ENV_BLOCK_RUNNER_ENV_ACCESS, True),
)
@@ -0,0 +1,190 @@
from src.errors import (
ConfigurationError,
TaskCancelledError,
TaskRuntimeError,
TaskTimeoutError,
SecurityViolationError,
WebsocketConnectionError,
)
# Messages
BROKER_INFO_REQUEST = "broker:inforequest"
BROKER_RUNNER_REGISTERED = "broker:runnerregistered"
BROKER_TASK_OFFER_ACCEPT = "broker:taskofferaccept"
BROKER_TASK_SETTINGS = "broker:tasksettings"
BROKER_TASK_CANCEL = "broker:taskcancel"
BROKER_RPC_RESPONSE = "broker:rpcresponse"
BROKER_DRAIN = "broker:drain"
RUNNER_INFO = "runner:info"
RUNNER_TASK_OFFER = "runner:taskoffer"
RUNNER_TASK_ACCEPTED = "runner:taskaccepted"
RUNNER_TASK_REJECTED = "runner:taskrejected"
RUNNER_TASK_DONE = "runner:taskdone"
RUNNER_TASK_ERROR = "runner:taskerror"
RUNNER_RPC_CALL = "runner:rpc"
# Runner
TASK_TYPE_PYTHON = "python"
RUNNER_NAME = "Python Task Runner"
DEFAULT_MAX_CONCURRENCY = 5 # tasks
DEFAULT_MAX_PAYLOAD_SIZE = 1024 * 1024 * 1024 # 1 GiB
DEFAULT_TASK_TIMEOUT = 60 # seconds
DEFAULT_AUTO_SHUTDOWN_TIMEOUT = 0 # seconds
DEFAULT_SHUTDOWN_TIMEOUT = 10 # seconds
OFFER_INTERVAL = 0.25 # 250ms
OFFER_VALIDITY = 5000 # ms
OFFER_VALIDITY_MAX_JITTER = 500 # ms
OFFER_VALIDITY_LATENCY_BUFFER = 0.1 # 100ms
MAX_VALIDATION_CACHE_SIZE = 500 # cached validation results
# Executor
EXECUTOR_USER_OUTPUT_KEY = "__n8n_internal_user_output__"
EXECUTOR_CIRCULAR_REFERENCE_KEY = "__n8n_internal_circular_ref__"
EXECUTOR_ALL_ITEMS_FILENAME = "<all_items_task_execution>"
EXECUTOR_PER_ITEM_FILENAME = "<per_item_task_execution>"
EXECUTOR_FILENAMES = {EXECUTOR_ALL_ITEMS_FILENAME, EXECUTOR_PER_ITEM_FILENAME}
SIGTERM_EXIT_CODE = -15
SIGKILL_EXIT_CODE = -9
PIPE_MSG_PREFIX_LENGTH = 4 # bytes
PIPE_MSG_MAX_SIZE = (
2 ** (PIPE_MSG_PREFIX_LENGTH * 8) - 1
) # bytes (~4 GiB with 4-byte prefix)
# Broker
DEFAULT_TASK_BROKER_URI = "http://127.0.0.1:5679"
TASK_BROKER_WS_PATH = "/runners/_ws"
# Health check
DEFAULT_HEALTH_CHECK_SERVER_HOST = "127.0.0.1"
DEFAULT_HEALTH_CHECK_SERVER_PORT = 5681
# Env vars
ENV_TASK_BROKER_URI = "N8N_RUNNERS_TASK_BROKER_URI"
ENV_GRANT_TOKEN = "N8N_RUNNERS_GRANT_TOKEN"
ENV_MAX_CONCURRENCY = "N8N_RUNNERS_MAX_CONCURRENCY"
ENV_MAX_PAYLOAD_SIZE = "N8N_RUNNERS_MAX_PAYLOAD"
ENV_TASK_TIMEOUT = "N8N_RUNNERS_TASK_TIMEOUT"
ENV_AUTO_SHUTDOWN_TIMEOUT = "N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT"
ENV_GRACEFUL_SHUTDOWN_TIMEOUT = "N8N_RUNNERS_GRACEFUL_SHUTDOWN_TIMEOUT"
ENV_STDLIB_ALLOW = "N8N_RUNNERS_STDLIB_ALLOW"
ENV_EXTERNAL_ALLOW = "N8N_RUNNERS_EXTERNAL_ALLOW"
ENV_BUILTINS_DENY = "N8N_RUNNERS_BUILTINS_DENY"
ENV_HEALTH_CHECK_SERVER_ENABLED = "N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED"
ENV_HEALTH_CHECK_SERVER_HOST = "N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST"
ENV_HEALTH_CHECK_SERVER_PORT = "N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT"
ENV_LAUNCHER_LOG_LEVEL = "N8N_RUNNERS_LAUNCHER_LOG_LEVEL"
ENV_BLOCK_RUNNER_ENV_ACCESS = "N8N_BLOCK_RUNNER_ENV_ACCESS"
ENV_SENTRY_DSN = "N8N_SENTRY_DSN"
ENV_N8N_VERSION = "N8N_VERSION"
ENV_ENVIRONMENT = "ENVIRONMENT"
ENV_DEPLOYMENT_NAME = "DEPLOYMENT_NAME"
ENV_SENTRY_PROFILES_SAMPLE_RATE = "N8N_SENTRY_PROFILES_SAMPLE_RATE"
ENV_SENTRY_TRACES_SAMPLE_RATE = "N8N_SENTRY_TRACES_SAMPLE_RATE"
# Sentry
SENTRY_TAG_SERVER_TYPE_KEY = "server_type"
SENTRY_TAG_SERVER_TYPE_VALUE = "task_runner_python"
IGNORED_ERROR_TYPES = (
ConfigurationError,
TaskRuntimeError,
TaskCancelledError,
TaskTimeoutError,
SecurityViolationError,
WebsocketConnectionError,
SyntaxError,
)
# Logging
LOG_FORMAT = "%(asctime)s.%(msecs)03d\t%(levelname)s\t%(message)s"
LOG_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
LOG_TASK_COMPLETE = 'Completed task {task_id} in {duration} ({result_size}) for node "{node_name}" ({node_id}) in workflow "{workflow_name}" ({workflow_id})'
LOG_TASK_CANCEL = 'Cancelled task {task_id} for node "{node_name}" ({node_id}) in workflow "{workflow_name}" ({workflow_id})'
LOG_TASK_CANCEL_UNKNOWN = (
"Received cancel for unknown task: {task_id}. Discarding message."
)
LOG_TASK_CANCEL_WAITING = "Cancelled task {task_id} (waiting for settings)"
LOG_SENTRY_MISSING = "Sentry is enabled but sentry-sdk is not installed. Install with: uv sync --all-extras"
# RPC
RPC_BROWSER_CONSOLE_LOG_METHOD = "logNodeOutput"
# Rejection reasons
TASK_REJECTED_REASON_OFFER_EXPIRED = (
"Offer expired - not accepted within validity window"
)
TASK_REJECTED_REASON_AT_CAPACITY = "No open task slots - runner already at capacity"
# Security
BUILTINS_DENY_DEFAULT = "eval,exec,compile,open,input,breakpoint,getattr,object,type,vars,setattr,delattr,hasattr,dir,memoryview,__build_class__,globals,locals,license,help,credits,copyright"
BLOCKED_NAMES = {
"__loader__",
"__builtins__",
"__globals__",
"__spec__",
"__name__",
}
BLOCKED_ATTRIBUTES = {
# runtime attributes
"__subclasses__",
"__globals__",
"__builtins__",
"__traceback__",
"tb_frame",
"tb_next",
"f_back",
"f_globals",
"f_locals",
"f_code",
"f_builtins",
"__getattribute__",
"__qualname__",
"__module__",
"gi_frame",
"gi_code",
"gi_yieldfrom",
"cr_frame",
"cr_code",
"ag_frame",
"ag_code",
"obj",
"__thisclass__",
"__self_class__",
"__objclass__",
# introspection attributes
"__base__",
"__class__",
"__bases__",
"__code__",
"__closure__",
"__loader__",
"__cached__",
"__dict__",
"__import__",
"__mro__",
"__init_subclass__",
"__getattr__",
"__setattr__",
"__delattr__",
"__self__",
"__func__",
"__wrapped__",
"__annotations__",
"__spec__",
}
# errors
ERROR_RELATIVE_IMPORT = "Relative imports are disallowed."
ERROR_STDLIB_DISALLOWED = "Import of standard library module '{module}' is disallowed. Allowed stdlib modules: {allowed}"
ERROR_EXTERNAL_DISALLOWED = "Import of external package '{module}' is disallowed. Allowed external packages: {allowed}"
ERROR_DANGEROUS_NAME = "Access to name '{name}' is disallowed, because it can be used to bypass security restrictions."
ERROR_DANGEROUS_ATTRIBUTE = "Access to attribute '{attr}' is disallowed, because it can be used to bypass security restrictions."
ERROR_DANGEROUS_STRING_PATTERN = "String pattern accessing '{attr}' is disallowed, because it can be used to bypass security restrictions."
ERROR_NAME_MANGLED_ATTRIBUTE = "Access to name-mangled attributes (pattern: _ClassName__attr) is disallowed for security reasons."
ERROR_DYNAMIC_IMPORT = (
"Dynamic __import__() calls are not allowed for security reasons."
)
ERROR_MATCH_PATTERN_ATTRIBUTE = "Match pattern extracting attribute '{attr}' is disallowed, because it can be used to bypass security restrictions."
ERROR_WINDOWS_NOT_SUPPORTED = (
"Error: This task runner is not supported on Windows. "
"Please use a Unix-like system (Linux or macOS)."
)
@@ -0,0 +1,57 @@
import os
from pathlib import Path
def read_env(env_name: str) -> str | None:
if env_name in os.environ:
return os.environ[env_name]
file_path_key = f"{env_name}_FILE"
if file_path_key in os.environ:
file_path = os.environ[file_path_key]
try:
return Path(file_path).read_text(encoding="utf-8").strip()
except (OSError, IOError) as e:
raise ValueError(
f"Failed to read {env_name}_FILE from file {file_path}: {e}"
)
return None
def read_str_env(env_name: str, default: str) -> str:
value = read_env(env_name)
if value is None:
return default
return value
def read_int_env(env_name: str, default: int) -> int:
value = read_env(env_name)
if value is None:
return default
try:
return int(value)
except ValueError:
raise ValueError(
f"Environment variable {env_name} must be an integer, got '{value}'"
)
def read_bool_env(env_name: str, default: bool) -> bool:
value = read_env(env_name)
if value is None:
return default
return value.strip().lower() == "true"
def read_float_env(env_name: str, default: float) -> float:
value = read_env(env_name)
if value is None:
return default
try:
return float(value)
except ValueError:
raise ValueError(
f"Environment variable {env_name} must be a float, got '{value}'"
)
@@ -0,0 +1,31 @@
from .configuration_error import ConfigurationError
from .invalid_pipe_msg_content_error import InvalidPipeMsgContentError
from .invalid_pipe_msg_length_error import InvalidPipeMsgLengthError
from .no_idle_timeout_handler_error import NoIdleTimeoutHandlerError
from .security_violation_error import SecurityViolationError
from .task_cancelled_error import TaskCancelledError
from .task_killed_error import TaskKilledError
from .task_missing_error import TaskMissingError
from .task_result_missing_error import TaskResultMissingError
from .task_result_read_error import TaskResultReadError
from .task_subprocess_failed_error import TaskSubprocessFailedError
from .task_runtime_error import TaskRuntimeError
from .task_timeout_error import TaskTimeoutError
from .websocket_connection_error import WebsocketConnectionError
__all__ = [
"ConfigurationError",
"InvalidPipeMsgContentError",
"InvalidPipeMsgLengthError",
"NoIdleTimeoutHandlerError",
"SecurityViolationError",
"TaskCancelledError",
"TaskKilledError",
"TaskMissingError",
"TaskSubprocessFailedError",
"TaskResultMissingError",
"TaskResultReadError",
"TaskRuntimeError",
"TaskTimeoutError",
"WebsocketConnectionError",
]
@@ -0,0 +1,6 @@
class ConfigurationError(Exception):
"""Raised when runner configuration set by the user is invalid."""
def __init__(self, message: str = "Configuration error"):
super().__init__(message)
self.message = message
@@ -0,0 +1,3 @@
class InvalidPipeMsgContentError(Exception):
def __init__(self, message: str):
super().__init__(f"Invalid pipe message content: {message}")
@@ -0,0 +1,3 @@
class InvalidPipeMsgLengthError(Exception):
def __init__(self, length: int):
super().__init__(f"Invalid pipe message length: {length} bytes")
@@ -0,0 +1,9 @@
class NoIdleTimeoutHandlerError(Exception):
"""Raised when idle timeout is reached but no shutdown handler is configured."""
def __init__(self, timeout: int):
super().__init__(
f"Idle timeout is configured ({timeout}s) but no handler is set. "
"Set task_runner.on_idle_timeout before calling task_runner.start(). "
"This is an internal error."
)
@@ -0,0 +1,9 @@
class SecurityViolationError(Exception):
"""Raised when code violates security policies, typically through the use of disallowed modules or builtins."""
def __init__(
self, message: str = "Security violations detected", description: str = ""
):
super().__init__(message)
self.message = message
self.description = description
@@ -0,0 +1,5 @@
class TaskCancelledError(Exception):
"""Raised when a task is cancelled by broker message, by runner shutdown, etc."""
def __init__(self):
super().__init__("Task was cancelled")
@@ -0,0 +1,11 @@
class TaskKilledError(Exception):
"""Raised when a task process is forcefully killed (SIGKILL).
This usually indicates:
- Out of memory (OOM killer)
- Process exceeded resource limits
- Manual operator intervention
"""
def __init__(self):
super().__init__("Process was forcefully killed (SIGKILL)")
@@ -0,0 +1,12 @@
class TaskMissingError(Exception):
"""Raised when attempting to operate on a task that does not exist.
This typically indicates an internal error where the task runner
received a message referencing a task ID that is not currently
being tracked in the runner's running tasks.
"""
def __init__(self, task_id: str):
super().__init__(
f"Failed to find task {task_id}. This is likely an internal error."
)
@@ -0,0 +1,11 @@
class TaskResultMissingError(Exception):
"""Raised when a task subprocess exits successfully but returns no result.
This typically indicates an internal error where the subprocess did not
put any data in the communication queue.
"""
def __init__(self):
super().__init__(
"Process completed but returned no result. This is likely an internal error."
)
@@ -0,0 +1,4 @@
class TaskResultReadError(Exception):
def __init__(self, original_error: Exception | None = None):
super().__init__("Failed to read result from child process")
self.original_error = original_error
@@ -0,0 +1,16 @@
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from src.message_types.pipe import TaskErrorInfo
class TaskRuntimeError(Exception):
"""Raised when user code raises an exception during task execution."""
def __init__(self, task_error_info: "TaskErrorInfo"):
message = task_error_info["message"]
super().__init__(message)
self.stack_trace = task_error_info.get("stack", "")
self.description = task_error_info.get(
"description", ""
) or task_error_info.get("stderr", "")
@@ -0,0 +1,7 @@
class TaskSubprocessFailedError(Exception):
"""Raised when a task subprocess exits with a non-zero exit code, excluding SIGTERM and SIGKILL."""
def __init__(self, exit_code: int, original_error: Exception | None = None):
super().__init__(f"Task subprocess exited with code {exit_code}")
self.exit_code = exit_code
self.original_error = original_error
@@ -0,0 +1,7 @@
class TaskTimeoutError(Exception):
def __init__(self, task_timeout: int):
"""Raised when a task execution takes longer than the timeout limit."""
message = f"Task execution timed out after {task_timeout} {'second' if task_timeout == 1 else 'seconds'}"
super().__init__(message)
self.task_timeout = task_timeout
@@ -0,0 +1,10 @@
class WebsocketConnectionError(ConnectionError):
"""Raised when the task runner fails to establish a WebSocket connection to the broker.
Common causes include network issues, incorrect broker URI, or the broker service being unavailable.
"""
def __init__(self, broker_uri: str):
super().__init__(
f"Failed to connect to broker. Please check if broker is reachable at: {broker_uri}"
)
@@ -0,0 +1,50 @@
import asyncio
import errno
import logging
from src.config.health_check_config import HealthCheckConfig
HEALTH_CHECK_RESPONSE = (
b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nOK"
)
class HealthCheckServer:
def __init__(self):
self.server: asyncio.Server | None = None
self.logger = logging.getLogger(__name__)
async def start(self, config: HealthCheckConfig) -> None:
try:
self.server = await asyncio.start_server(
self._handle_request, config.host, config.port
)
# for OS-assigned port in tests
actual_port = self.server.sockets[0].getsockname()[1]
self.logger.info(
f"Health check server listening on {config.host}, port {actual_port}"
)
except OSError as e:
if e.errno == errno.EADDRINUSE:
raise OSError(f"Port {config.port} is already in use") from e
else:
raise
async def stop(self) -> None:
if self.server:
self.server.close()
await self.server.wait_closed()
self.server = None
self.logger.info("Health check server stopped")
async def _handle_request(
self, _reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
try:
writer.write(HEALTH_CHECK_RESPONSE)
await writer.drain()
except Exception:
pass
finally:
writer.close()
await writer.wait_closed()
@@ -0,0 +1,37 @@
import sys
from src.config.security_config import SecurityConfig
from src.constants import ERROR_STDLIB_DISALLOWED, ERROR_EXTERNAL_DISALLOWED
def validate_module_import(
module_path: str,
security_config: SecurityConfig,
) -> tuple[bool, str | None]:
stdlib_allow = security_config.stdlib_allow
external_allow = security_config.external_allow
module_name = module_path.split(".")[0]
is_stdlib = module_name in sys.stdlib_module_names
is_external = not is_stdlib
if is_stdlib and ("*" in stdlib_allow or module_name in stdlib_allow):
return (True, None)
if is_external and ("*" in external_allow or module_name in external_allow):
return (True, None)
if is_stdlib:
stdlib_allowed_str = ", ".join(sorted(stdlib_allow)) if stdlib_allow else "none"
error_msg = ERROR_STDLIB_DISALLOWED.format(
module=module_path, allowed=stdlib_allowed_str
)
else:
external_allowed_str = (
", ".join(sorted(external_allow)) if external_allow else "none"
)
error_msg = ERROR_EXTERNAL_DISALLOWED.format(
module=module_path, allowed=external_allowed_str
)
return (False, error_msg)
@@ -0,0 +1,66 @@
import sys
import logging
import os
from src.constants import LOG_FORMAT, LOG_TIMESTAMP_FORMAT, ENV_LAUNCHER_LOG_LEVEL
COLORS = {
"DEBUG": "\033[34m", # blue
"INFO": "\033[32m", # green
"WARNING": "\033[33m", # yellow
"ERROR": "\033[31m", # red
"CRITICAL": "\033[31m", # red
}
RESET = "\033[0m"
class ColorFormatter(logging.Formatter):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.use_colors = os.getenv("NO_COLOR") is None
# When started by launcher, log level and timestamp are handled by launcher.
self.short_form = not sys.stdout.isatty()
def format(self, record):
if self.short_form:
return record.getMessage()
formatted = super().format(record)
if not self.use_colors:
return formatted
parts = formatted.split("\t")
if len(parts) >= 3:
timestamp = parts[0]
level = parts[1]
message = " ".join(parts[2:])
level_color = COLORS.get(record.levelname, "")
if level_color:
level = level_color + level + RESET
message = level_color + message + RESET
formatted = f"{timestamp} {level} {message}"
return formatted
def setup_logging():
logger = logging.getLogger()
log_level_str = os.getenv(ENV_LAUNCHER_LOG_LEVEL, "INFO").upper()
log_level = getattr(logging, log_level_str, logging.INFO)
logger.setLevel(log_level)
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setFormatter(ColorFormatter(LOG_FORMAT, LOG_TIMESTAMP_FORMAT))
logger.addHandler(stream_handler)
# Hardcoded to INFO as websocket logs are too verbose
logging.getLogger("websockets.client").setLevel(logging.INFO)
logging.getLogger("websockets.server").setLevel(logging.INFO)
logging.getLogger("websockets").setLevel(logging.INFO)
@@ -0,0 +1,72 @@
import asyncio
import logging
import sys
import platform
from src.constants import ERROR_WINDOWS_NOT_SUPPORTED
from src.config.health_check_config import HealthCheckConfig
from src.config.sentry_config import SentryConfig
from src.config.task_runner_config import TaskRunnerConfig
from src.errors import ConfigurationError
from src.logs import setup_logging
from src.task_runner import TaskRunner
from src.shutdown import Shutdown
async def main():
setup_logging()
logger = logging.getLogger(__name__)
sentry = None
sentry_config = SentryConfig.from_env()
if sentry_config.enabled:
from src.sentry import setup_sentry
sentry = setup_sentry(sentry_config)
try:
health_check_config = HealthCheckConfig.from_env()
except ConfigurationError as e:
logger.error(f"Invalid health check configuration: {e}")
sys.exit(1)
health_check_server: "HealthCheckServer | None" = None
if health_check_config.enabled:
from src.health_check_server import HealthCheckServer
health_check_server = HealthCheckServer()
try:
await health_check_server.start(health_check_config)
except OSError as e:
logger.error(f"Failed to start health check server: {e}")
sys.exit(1)
try:
task_runner_config = TaskRunnerConfig.from_env()
except ConfigurationError as e:
logger.error(str(e))
sys.exit(1)
task_runner = TaskRunner(task_runner_config)
logger.info("Starting runner...")
shutdown = Shutdown(task_runner, health_check_server, sentry)
task_runner.on_idle_timeout = shutdown.start_auto_shutdown
try:
await task_runner.start()
except Exception:
logger.error("Unexpected error", exc_info=True)
await shutdown.start_shutdown()
exit_code = await shutdown.wait_for_shutdown()
sys.exit(exit_code)
if __name__ == "__main__":
if platform.system() == "Windows":
print(ERROR_WINDOWS_NOT_SUPPORTED, file=sys.stderr)
sys.exit(1)
asyncio.run(main())
@@ -0,0 +1,141 @@
import json
from dataclasses import asdict
from typing import cast
from src.message_types.broker import NodeMode, TaskSettings
from src.constants import (
BROKER_INFO_REQUEST,
BROKER_RUNNER_REGISTERED,
BROKER_TASK_CANCEL,
BROKER_TASK_OFFER_ACCEPT,
BROKER_TASK_SETTINGS,
BROKER_RPC_RESPONSE,
BROKER_DRAIN,
)
from src.message_types import (
BrokerMessage,
RunnerMessage,
BrokerInfoRequest,
BrokerRunnerRegistered,
BrokerTaskOfferAccept,
BrokerTaskSettings,
BrokerTaskCancel,
BrokerRpcResponse,
BrokerDrain,
)
NODE_MODE_MAP = {
"runOnceForAllItems": "all_items",
"runOnceForEachItem": "per_item",
}
def _get_node_mode(node_mode_str: str) -> NodeMode:
if node_mode_str not in NODE_MODE_MAP:
raise ValueError(f"Unknown nodeMode: {node_mode_str}")
return cast(NodeMode, NODE_MODE_MAP[node_mode_str])
def _parse_task_settings(d: dict) -> BrokerTaskSettings:
try:
# required
task_id = d["taskId"]
settings_dict = d["settings"]
code = settings_dict["code"]
node_mode = _get_node_mode(settings_dict["nodeMode"])
items = settings_dict["items"]
# optional
continue_on_fail = settings_dict.get("continueOnFail", False)
workflow_name = settings_dict.get("workflowName", "Unknown")
workflow_id = settings_dict.get("workflowId", "Unknown")
node_name = settings_dict.get("nodeName", "Unknown")
node_id = settings_dict.get("nodeId", "Unknown")
query = settings_dict.get("query")
except KeyError as e:
raise ValueError(f"Missing field in task settings message: {e}")
return BrokerTaskSettings(
task_id=task_id,
settings=TaskSettings(
code=code,
node_mode=node_mode,
continue_on_fail=continue_on_fail,
items=items,
workflow_name=workflow_name,
workflow_id=workflow_id,
node_name=node_name,
node_id=node_id,
query=query,
),
)
def _parse_task_offer_accept(d: dict) -> BrokerTaskOfferAccept:
try:
task_id = d["taskId"]
offer_id = d["offerId"]
except KeyError as e:
raise ValueError(f"Missing field in task offer acceptance message: {e}")
return BrokerTaskOfferAccept(task_id=task_id, offer_id=offer_id)
def _parse_task_cancel(d: dict) -> BrokerTaskCancel:
try:
task_id = d["taskId"]
reason = d["reason"]
except KeyError as e:
raise ValueError(f"Missing field in task cancel message: {e}")
return BrokerTaskCancel(task_id=task_id, reason=reason)
def _parse_rpc_response(d: dict) -> BrokerRpcResponse:
try:
call_id = d["callId"]
task_id = d["taskId"]
status = d["status"]
except KeyError as e:
raise ValueError(f"Missing field in RPC response message: {e}")
return BrokerRpcResponse(call_id, task_id, status)
MESSAGE_TYPE_MAP = {
BROKER_INFO_REQUEST: lambda _: BrokerInfoRequest(),
BROKER_RUNNER_REGISTERED: lambda _: BrokerRunnerRegistered(),
BROKER_TASK_OFFER_ACCEPT: _parse_task_offer_accept,
BROKER_TASK_SETTINGS: _parse_task_settings,
BROKER_TASK_CANCEL: _parse_task_cancel,
BROKER_RPC_RESPONSE: _parse_rpc_response,
BROKER_DRAIN: lambda _: BrokerDrain(),
}
class MessageSerde:
"""Responsible for deserializing incoming messages and serializing outgoing messages."""
@staticmethod
def deserialize_broker_message(data: str) -> BrokerMessage:
message_dict = json.loads(data)
message_type = message_dict.get("type")
if message_type not in MESSAGE_TYPE_MAP:
raise ValueError(f"Unknown message type: {message_type}")
return MESSAGE_TYPE_MAP[message_type](message_dict)
@staticmethod
def serialize_runner_message(message: RunnerMessage) -> str:
data = asdict(message)
camel_case_data = {
MessageSerde._snake_to_camel_case(k): v for k, v in data.items()
}
return json.dumps(camel_case_data)
@staticmethod
def _snake_to_camel_case(snake_case_str: str) -> str:
parts = snake_case_str.split("_")
return parts[0] + "".join(word.capitalize() for word in parts[1:])
@@ -0,0 +1,39 @@
from .broker import (
BrokerMessage,
BrokerInfoRequest,
BrokerRunnerRegistered,
BrokerTaskOfferAccept,
BrokerTaskSettings,
BrokerTaskCancel,
BrokerRpcResponse,
BrokerDrain,
)
from .runner import (
RunnerMessage,
RunnerInfo,
RunnerTaskOffer,
RunnerTaskAccepted,
RunnerTaskRejected,
RunnerTaskDone,
RunnerTaskError,
RunnerRpcCall,
)
__all__ = [
"BrokerMessage",
"BrokerInfoRequest",
"BrokerRunnerRegistered",
"BrokerTaskOfferAccept",
"BrokerTaskSettings",
"BrokerTaskCancel",
"BrokerRpcResponse",
"BrokerDrain",
"RunnerMessage",
"RunnerInfo",
"RunnerTaskOffer",
"RunnerTaskAccepted",
"RunnerTaskRejected",
"RunnerTaskDone",
"RunnerTaskError",
"RunnerRpcCall",
]
@@ -0,0 +1,87 @@
from dataclasses import dataclass
from typing import Literal, Any
from src.constants import (
BROKER_INFO_REQUEST,
BROKER_RUNNER_REGISTERED,
BROKER_TASK_CANCEL,
BROKER_TASK_OFFER_ACCEPT,
BROKER_TASK_SETTINGS,
BROKER_RPC_RESPONSE,
BROKER_DRAIN,
)
@dataclass
class BrokerInfoRequest:
type: Literal["broker:inforequest"] = BROKER_INFO_REQUEST
@dataclass
class BrokerRunnerRegistered:
type: Literal["broker:runnerregistered"] = BROKER_RUNNER_REGISTERED
@dataclass
class BrokerTaskOfferAccept:
task_id: str
offer_id: str
type: Literal["broker:taskofferaccept"] = BROKER_TASK_OFFER_ACCEPT
NodeMode = Literal["all_items", "per_item"]
Items = list[dict[str, Any]] # INodeExecutionData[]
Query = str | dict[str, Any] | None # tool input
@dataclass
class TaskSettings:
code: str
node_mode: NodeMode
continue_on_fail: bool
items: Items
workflow_name: str
workflow_id: str
node_name: str
node_id: str
query: Query = None
@dataclass
class BrokerTaskSettings:
task_id: str
settings: TaskSettings
type: Literal["broker:tasksettings"] = BROKER_TASK_SETTINGS
@dataclass
class BrokerTaskCancel:
task_id: str
reason: str
type: Literal["broker:taskcancel"] = BROKER_TASK_CANCEL
@dataclass
class BrokerRpcResponse:
call_id: str
task_id: str
status: str
type: Literal["broker:rpcresponse"] = BROKER_RPC_RESPONSE
@dataclass
class BrokerDrain:
type: Literal["broker:drain"] = BROKER_DRAIN
BrokerMessage = (
BrokerInfoRequest
| BrokerRunnerRegistered
| BrokerTaskOfferAccept
| BrokerTaskSettings
| BrokerTaskCancel
| BrokerRpcResponse
| BrokerDrain
)
@@ -0,0 +1,25 @@
from typing import Any, TypedDict
from src.message_types.broker import Items
PrintArgs = list[list[Any]] # Args to all `print()` calls in a Python code task
class TaskErrorInfo(TypedDict):
message: str
description: str
stack: str
stderr: str
class PipeResultMessage(TypedDict):
result: Items
print_args: PrintArgs
class PipeErrorMessage(TypedDict):
error: TaskErrorInfo
print_args: PrintArgs
PipeMessage = PipeResultMessage | PipeErrorMessage
@@ -0,0 +1,74 @@
from dataclasses import dataclass
from typing import Literal, Any
from src.constants import (
RUNNER_INFO,
RUNNER_RPC_CALL,
RUNNER_TASK_ACCEPTED,
RUNNER_TASK_DONE,
RUNNER_TASK_ERROR,
RUNNER_TASK_OFFER,
RUNNER_TASK_REJECTED,
)
@dataclass
class RunnerInfo:
name: str
types: list[str]
type: Literal["runner:info"] = RUNNER_INFO
@dataclass
class RunnerTaskOffer:
offer_id: str
task_type: str
valid_for: int
type: Literal["runner:taskoffer"] = RUNNER_TASK_OFFER
@dataclass
class RunnerTaskAccepted:
task_id: str
type: Literal["runner:taskaccepted"] = RUNNER_TASK_ACCEPTED
@dataclass
class RunnerTaskRejected:
task_id: str
reason: str
type: Literal["runner:taskrejected"] = RUNNER_TASK_REJECTED
@dataclass
class RunnerTaskDone:
task_id: str
data: dict[str, Any]
type: Literal["runner:taskdone"] = RUNNER_TASK_DONE
@dataclass
class RunnerTaskError:
task_id: str
error: dict[str, Any]
type: Literal["runner:taskerror"] = RUNNER_TASK_ERROR
@dataclass
class RunnerRpcCall:
call_id: str
task_id: str
name: str
params: list[Any]
type: Literal["runner:rpc"] = RUNNER_RPC_CALL
RunnerMessage = (
RunnerInfo
| RunnerTaskOffer
| RunnerTaskAccepted
| RunnerTaskRejected
| RunnerTaskDone
| RunnerTaskError
| RunnerRpcCall
)
@@ -0,0 +1,21 @@
import secrets
import string
NANOID_CHARSET = string.ascii_uppercase + string.ascii_lowercase + string.digits
TARGET_NANOID_LEN = 22
CHARSET_LEN = len(NANOID_CHARSET)
# Collision probability is roughly k^2/(2n) where k=IDs generated, n=possibilities
# At 10^12 IDs generated with 62^22 possibilities -> ~1.8e-16 chance of collision
def nanoid() -> str:
chars = []
while len(chars) < TARGET_NANOID_LEN:
index = secrets.randbits(6)
if index < CHARSET_LEN:
chars.append(NANOID_CHARSET[index])
return "".join(chars)
@@ -0,0 +1,85 @@
import json
import os
import threading
from typing import cast
from multiprocessing.connection import Connection
from src.errors import (
InvalidPipeMsgContentError,
InvalidPipeMsgLengthError,
)
from src.message_types.pipe import PipeMessage
from src.constants import PIPE_MSG_PREFIX_LENGTH
type PipeConnection = Connection
class PipeReader(threading.Thread):
"""Background thread that reads result from pipe."""
def __init__(self, read_fd: int, read_conn: PipeConnection):
super().__init__()
self.read_fd = read_fd
self.read_conn = read_conn
self.pipe_message: PipeMessage | None = None
self.message_size: int | None = None # bytes
self.error: Exception | None = None
def run(self):
try:
length_bytes = PipeReader._read_exact_bytes(
self.read_fd, PIPE_MSG_PREFIX_LENGTH
)
length_int = int.from_bytes(length_bytes, "big")
if length_int <= 0:
raise InvalidPipeMsgLengthError(length_int)
self.message_size = length_int
data = PipeReader._read_exact_bytes(self.read_fd, length_int)
parsed_msg = json.loads(data.decode("utf-8"))
self.pipe_message = self._validate_pipe_message(parsed_msg)
except Exception as e:
self.error = e
finally:
self.read_conn.close()
@staticmethod
def _read_exact_bytes(fd: int, n: int) -> bytes:
"""Read exactly n bytes from file descriptor.
Uses os.read() instead of Connection.recv() because recv() pickles.
Preallocates bytearray to avoid repeated reallocation.
"""
result = bytearray(n)
offset = 0
while offset < n:
chunk = os.read(fd, n - offset)
if not chunk:
raise EOFError("Pipe closed before reading all data")
result[offset : offset + len(chunk)] = chunk
offset += len(chunk)
return bytes(result)
def _validate_pipe_message(self, msg) -> PipeMessage:
if not isinstance(msg, dict):
raise InvalidPipeMsgContentError(f"Expected dict, got {type(msg).__name__}")
if "print_args" not in msg:
raise InvalidPipeMsgContentError("Message missing 'print_args' key")
if not isinstance(msg["print_args"], list):
raise InvalidPipeMsgContentError("'print_args' must be a list")
has_result = "result" in msg
has_error = "error" in msg
if not has_result and not has_error:
raise InvalidPipeMsgContentError("Msg is missing 'result' or 'error' key")
if has_result and has_error:
raise InvalidPipeMsgContentError("Msg has both 'result' and 'error' keys")
if has_error and not isinstance(msg["error"], dict):
raise InvalidPipeMsgContentError("'error' must be a dict")
return cast(PipeMessage, msg)
@@ -0,0 +1,118 @@
import logging
from typing import Any
from src.config.sentry_config import SentryConfig
from src.constants import (
EXECUTOR_FILENAMES,
IGNORED_ERROR_TYPES,
LOG_SENTRY_MISSING,
SENTRY_TAG_SERVER_TYPE_KEY,
SENTRY_TAG_SERVER_TYPE_VALUE,
)
class TaskRunnerSentry:
def __init__(self, config: SentryConfig):
self.config = config
self.logger = logging.getLogger(__name__)
def init(self) -> None:
import sentry_sdk
from sentry_sdk.integrations.logging import LoggingIntegration
integrations = [LoggingIntegration(level=logging.ERROR)]
is_profiling_enabled = self.config.profiles_sample_rate > 0
if is_profiling_enabled:
try:
# Import profiling integration lazily to avoid hard dependency
import sentry_sdk.profiler # noqa: F401
self.logger.info("Sentry profiling integration loaded")
except ImportError:
self.logger.warning(
"Sentry profiling is enabled but sentry-sdk profiling is not available. "
"Install with: uv sync --all-extras"
)
is_tracing_enabled = self.config.traces_sample_rate > 0
if is_profiling_enabled and not is_tracing_enabled:
self.logger.warning(
"Profiling is enabled but tracing is disabled. Profiling will not work."
)
init_options = {
"dsn": self.config.dsn,
"release": f"n8n@{self.config.n8n_version}",
"environment": self.config.environment,
"server_name": self.config.deployment_name,
"before_send": self._filter_out_ignored_errors,
"attach_stacktrace": True,
"send_default_pii": False,
"auto_enabling_integrations": False,
"default_integrations": True,
"integrations": integrations,
}
if self.config.traces_sample_rate > 0:
init_options["traces_sample_rate"] = self.config.traces_sample_rate
if is_profiling_enabled:
init_options["profiles_sample_rate"] = self.config.profiles_sample_rate
sentry_sdk.init(**init_options)
sentry_sdk.set_tag(SENTRY_TAG_SERVER_TYPE_KEY, SENTRY_TAG_SERVER_TYPE_VALUE)
self.logger.info("Sentry ready")
def shutdown(self) -> None:
import sentry_sdk
sentry_sdk.flush(timeout=2.0)
self.logger.info("Sentry stopped")
def _filter_out_ignored_errors(self, event: Any, hint: Any) -> Any | None:
if "exc_info" in hint:
exc_type, _, _ = hint["exc_info"]
for ignored_type in IGNORED_ERROR_TYPES:
if (
isinstance(exc_type, type)
and isinstance(ignored_type, type)
and issubclass(exc_type, ignored_type)
):
return None
for exception in event.get("exception", {}).get("values", []):
if self._is_from_user_code(exception):
return None
exc_type_name = exception.get("type", "")
for ignored_type in IGNORED_ERROR_TYPES:
if ignored_type.__name__ == exc_type_name:
return None
return event
def _is_from_user_code(self, exception: dict[str, Any]):
for frame in exception.get("stacktrace", {}).get("frames", []):
if frame.get("filename", "") in EXECUTOR_FILENAMES:
return True
return False
def setup_sentry(sentry_config: SentryConfig) -> TaskRunnerSentry | None:
if not sentry_config.enabled:
return None
try:
sentry = TaskRunnerSentry(sentry_config)
sentry.init()
return sentry
except ImportError:
logger = logging.getLogger(__name__)
logger.warning(LOG_SENTRY_MISSING)
return None
except Exception as e:
logger = logging.getLogger(__name__)
logger.warning(f"Failed to initialize Sentry: {e}")
return None
@@ -0,0 +1,86 @@
import asyncio
import logging
import signal
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from src.task_runner import TaskRunner
from src.health_check_server import HealthCheckServer
from src.sentry import TaskRunnerSentry
class Shutdown:
"""Responsible for managing the shutdown routine of the task runner."""
def __init__(
self,
task_runner: "TaskRunner",
health_check_server: "HealthCheckServer | None" = None,
sentry: "TaskRunnerSentry | None" = None,
):
self.logger = logging.getLogger(__name__)
self.is_shutting_down = False
self.shutdown_complete = asyncio.Event()
self.exit_code = 0
self.task_runner = task_runner
self.health_check_server = health_check_server
self.sentry = sentry
self._register_handler(signal.SIGINT)
self._register_handler(signal.SIGTERM)
async def start_shutdown(self, custom_timeout: int | None = None):
if self.is_shutting_down:
return
self.is_shutting_down = True
timeout = (
custom_timeout
if custom_timeout is not None
else self.task_runner.config.graceful_shutdown_timeout
)
try:
await asyncio.wait_for(self._perform_shutdown(), timeout=timeout)
self.exit_code = 0
except asyncio.TimeoutError:
self.logger.warning(f"Shutdown timed out after {timeout}s, forcing exit...")
self.exit_code = 1
except Exception as e:
self.logger.error(f"Error during shutdown: {e}", exc_info=True)
self.exit_code = 1
finally:
self.shutdown_complete.set()
async def wait_for_shutdown(self) -> int:
await self.shutdown_complete.wait()
return self.exit_code
def _register_handler(self, sig: signal.Signals):
async def handler():
self.logger.info(f"Received {sig.name} signal, starting shutdown...")
await self.start_shutdown()
try:
asyncio.get_running_loop().add_signal_handler(
sig, lambda: asyncio.create_task(handler())
)
except NotImplementedError:
self.logger.warning(
f"Signal handler for {sig.name} not supported on this platform"
) # e.g. Windows
async def start_auto_shutdown(self):
self.logger.info("Reached idle timeout, starting shutdown...")
await self.start_shutdown(3) # no tasks so no grace period
async def _perform_shutdown(self):
await self.task_runner.stop()
if self.health_check_server:
await self.health_check_server.stop()
if self.sentry:
self.sentry.shutdown()
@@ -0,0 +1,261 @@
import ast
import hashlib
import re
from collections import OrderedDict
from src.errors import SecurityViolationError
from src.import_validation import validate_module_import
from src.config.security_config import SecurityConfig
from src.constants import (
MAX_VALIDATION_CACHE_SIZE,
ERROR_RELATIVE_IMPORT,
ERROR_DANGEROUS_NAME,
ERROR_DANGEROUS_ATTRIBUTE,
ERROR_NAME_MANGLED_ATTRIBUTE,
ERROR_DYNAMIC_IMPORT,
ERROR_DANGEROUS_STRING_PATTERN,
ERROR_MATCH_PATTERN_ATTRIBUTE,
BLOCKED_ATTRIBUTES,
BLOCKED_NAMES,
)
CacheKey = tuple[str, tuple] # (code_hash, allowlists_tuple)
CachedViolations = list[str]
ValidationCache = OrderedDict[CacheKey, CachedViolations]
FORMAT_FIELD_PATTERN = re.compile(r"\{([^}]*)\}")
class SecurityValidator(ast.NodeVisitor):
"""AST visitor that enforces import allowlists and blocks dangerous attribute access."""
def __init__(self, security_config: SecurityConfig):
self.checked_modules: set[str] = set()
self.violations: list[str] = []
self.security_config = security_config
# ========== Detection ==========
def visit_Import(self, node: ast.Import) -> None:
"""Detect bare import statements (e.g., import os), including aliased (e.g., import numpy as np)."""
for alias in node.names:
module_name = alias.name
self._validate_import(module_name, node.lineno)
self.generic_visit(node)
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
"""Detect from import statements (e.g., from os import path)."""
if node.level > 0:
self._add_violation(node.lineno, ERROR_RELATIVE_IMPORT)
elif node.module:
self._validate_import(node.module, node.lineno)
self.generic_visit(node)
def visit_Name(self, node: ast.Name) -> None:
if node.id in BLOCKED_NAMES:
self._add_violation(node.lineno, ERROR_DANGEROUS_NAME.format(name=node.id))
self.generic_visit(node)
def visit_Attribute(self, node: ast.Attribute) -> None:
"""Detect access to unsafe attributes that could bypass security restrictions."""
if node.attr in BLOCKED_ATTRIBUTES:
self._add_violation(
node.lineno, ERROR_DANGEROUS_ATTRIBUTE.format(attr=node.attr)
)
if node.attr.startswith("_") and "__" in node.attr:
parts = node.attr.split("__", 1)
if len(parts) == 2 and parts[0].startswith("_"):
self._add_violation(node.lineno, ERROR_NAME_MANGLED_ATTRIBUTE)
self.generic_visit(node)
def visit_Call(self, node: ast.Call) -> None:
"""Detect calls to __import__() that could bypass security restrictions."""
is_import_call = (
# __import__()
(isinstance(node.func, ast.Name) and node.func.id == "__import__")
or
# builtins.__import__() or __builtins__.__import__()
(
isinstance(node.func, ast.Attribute)
and node.func.attr == "__import__"
and isinstance(node.func.value, ast.Name)
and node.func.value.id in {"builtins", "__builtins__"}
)
)
if is_import_call:
if (
node.args
and isinstance(node.args[0], ast.Constant)
and isinstance(node.args[0].value, str)
):
module_name = node.args[0].value
self._validate_import(module_name, node.lineno)
else:
self._add_violation(node.lineno, ERROR_DYNAMIC_IMPORT)
self.generic_visit(node)
def visit_Subscript(self, node: ast.Subscript) -> None:
"""Detect dict access to blocked attributes, e.g. __builtins__['__spec__']"""
is_builtins_access = (
# __builtins__['__spec__']
(
isinstance(node.value, ast.Name)
and node.value.id in {"__builtins__", "builtins"}
)
# obj.__builtins__['__spec__']
or (
isinstance(node.value, ast.Attribute)
and node.value.attr in {"__builtins__", "builtins"}
)
)
if (
is_builtins_access
and isinstance(node.slice, ast.Constant)
and isinstance(node.slice.value, str)
):
key = node.slice.value
if key in BLOCKED_ATTRIBUTES:
self._add_violation(
node.lineno, ERROR_DANGEROUS_ATTRIBUTE.format(attr=key)
)
self.generic_visit(node)
def visit_Constant(self, node: ast.Constant) -> None:
"""Detect string constants containing dangerous format patterns."""
if isinstance(node.value, str):
self._check_format_string(node.value, node.lineno)
self.generic_visit(node)
def visit_MatchClass(self, node: ast.MatchClass) -> None:
"""Detect match patterns that extract blocked attributes, e.g. `case AttributeError(obj=x)`"""
for attr in node.kwd_attrs:
if attr in BLOCKED_ATTRIBUTES:
self._add_violation(
node.lineno, ERROR_MATCH_PATTERN_ATTRIBUTE.format(attr=attr)
)
self.generic_visit(node)
def _check_format_string(self, s: str, lineno: int) -> None:
"""Check if a string contains format patterns that access blocked attributes."""
# escaped braces produce literal braces, not format fields
s = s.replace("{{", "").replace("}}", "")
for match in FORMAT_FIELD_PATTERN.finditer(s):
field = match.group(1)
# attribute access
for attr_match in re.finditer(r"\.(\w+)", field):
attr = attr_match.group(1)
if attr in BLOCKED_ATTRIBUTES or attr in BLOCKED_NAMES:
self._add_violation(
lineno, ERROR_DANGEROUS_STRING_PATTERN.format(attr=attr)
)
# subscript access
for subscript_match in re.finditer(r"\[(['\"]?)(\w+)\1\]", field):
key = subscript_match.group(2)
if key in BLOCKED_ATTRIBUTES or key in BLOCKED_NAMES:
self._add_violation(
lineno, ERROR_DANGEROUS_STRING_PATTERN.format(attr=key)
)
# ========== Validation ==========
def _validate_import(self, module_path: str, lineno: int) -> None:
"""Validate that a module import is allowed based on allowlists. Also disallow relative imports."""
if module_path.startswith("."):
self._add_violation(lineno, ERROR_RELATIVE_IMPORT)
return
module_name = module_path.split(".")[0] # e.g., os.path -> os
if module_name in self.checked_modules:
return
self.checked_modules.add(module_name)
is_allowed, error_msg = validate_module_import(
module_path, self.security_config
)
if not is_allowed:
assert error_msg is not None
self._add_violation(lineno, error_msg)
def _add_violation(self, lineno: int, message: str) -> None:
self.violations.append(f"Line {lineno}: {message}")
class TaskAnalyzer:
_cache: ValidationCache = OrderedDict()
def __init__(self, security_config: SecurityConfig):
self._security_config = security_config
self._allowlists = (
tuple(sorted(security_config.stdlib_allow)),
tuple(sorted(security_config.external_allow)),
)
self._allow_all = (
"*" in security_config.stdlib_allow
and "*" in security_config.external_allow
)
def validate(self, code: str) -> None:
if self._allow_all:
return
cache_key = self._to_cache_key(code)
cached_violations = self._cache.get(cache_key)
if cached_violations is not None:
self._cache.move_to_end(cache_key)
if len(cached_violations) == 0:
return
self._raise_security_error(cached_violations)
tree = ast.parse(code)
security_validator = SecurityValidator(self._security_config)
security_validator.visit(tree)
self._set_in_cache(cache_key, security_validator.violations)
if security_validator.violations:
self._raise_security_error(security_validator.violations)
def _raise_security_error(self, violations: CachedViolations) -> None:
raise SecurityViolationError(
message="Security violations detected", description="\n".join(violations)
)
def _to_cache_key(self, code: str) -> CacheKey:
code_hash = hashlib.sha256(code.encode()).hexdigest()
return (code_hash, self._allowlists)
def _set_in_cache(self, cache_key: CacheKey, violations: CachedViolations) -> None:
if len(self._cache) >= MAX_VALIDATION_CACHE_SIZE:
self._cache.popitem(last=False) # FIFO
self._cache[cache_key] = violations.copy()
self._cache.move_to_end(cache_key)
@@ -0,0 +1,505 @@
import multiprocessing
import traceback
import textwrap
import json
import io
import os
import sys
import logging
from typing import cast
from src.errors import (
TaskCancelledError,
TaskKilledError,
TaskResultMissingError,
TaskResultReadError,
TaskRuntimeError,
TaskTimeoutError,
TaskSubprocessFailedError,
SecurityViolationError,
)
from src.import_validation import validate_module_import
from src.config.security_config import SecurityConfig
from src.message_types.broker import NodeMode, Items, Query
from src.message_types.pipe import (
PipeResultMessage,
PipeErrorMessage,
TaskErrorInfo,
PrintArgs,
)
from src.pipe_reader import PipeReader
from src.constants import (
EXECUTOR_CIRCULAR_REFERENCE_KEY,
EXECUTOR_USER_OUTPUT_KEY,
EXECUTOR_ALL_ITEMS_FILENAME,
EXECUTOR_PER_ITEM_FILENAME,
SIGTERM_EXIT_CODE,
SIGKILL_EXIT_CODE,
PIPE_MSG_PREFIX_LENGTH,
)
from multiprocessing.context import ForkServerProcess
from multiprocessing.connection import Connection
logger = logging.getLogger(__name__)
MULTIPROCESSING_CONTEXT = multiprocessing.get_context("forkserver")
MAX_PRINT_ARGS_ALLOWED = 100
type PipeConnection = Connection
class TaskExecutor:
"""Responsible for executing Python code tasks in isolated subprocesses."""
@staticmethod
def create_process(
code: str,
node_mode: NodeMode,
items: Items,
security_config: SecurityConfig,
query: Query = None,
) -> tuple[ForkServerProcess, PipeConnection, PipeConnection]:
"""Create a subprocess for executing a Python code task and a pipe for communication."""
fn = (
TaskExecutor._all_items
if node_mode == "all_items"
else TaskExecutor._per_item
)
# thread in runner process reads, subprocess writes
read_conn, write_conn = MULTIPROCESSING_CONTEXT.Pipe(duplex=False)
process = MULTIPROCESSING_CONTEXT.Process(
target=fn,
args=(
code,
items,
write_conn,
security_config,
query,
),
)
return process, read_conn, write_conn
@staticmethod
def execute_process(
process: ForkServerProcess,
read_conn: PipeConnection,
write_conn: PipeConnection,
task_timeout: int,
continue_on_fail: bool,
) -> tuple[Items, PrintArgs, int]:
"""Execute a subprocess for a Python code task."""
print_args: PrintArgs = []
pipe_reader = PipeReader(read_conn.fileno(), read_conn)
pipe_reader.start()
try:
try:
process.start()
except Exception as e:
raise TaskSubprocessFailedError(-1, e)
finally:
write_conn.close()
process.join(timeout=task_timeout)
if process.is_alive():
TaskExecutor.stop_process(process)
raise TaskTimeoutError(task_timeout)
if process.exitcode == SIGTERM_EXIT_CODE:
raise TaskCancelledError()
if process.exitcode == SIGKILL_EXIT_CODE:
raise TaskKilledError()
if process.exitcode != 0:
assert process.exitcode is not None
raise TaskSubprocessFailedError(process.exitcode)
pipe_reader.join(timeout=task_timeout)
if pipe_reader.is_alive():
try:
read_conn.close()
except Exception:
pass
raise TaskResultReadError(
TimeoutError(f"Pipe reader timed out after {task_timeout}s")
)
if pipe_reader.error:
raise TaskResultReadError(pipe_reader.error)
if pipe_reader.pipe_message is None:
raise TaskResultMissingError()
returned = pipe_reader.pipe_message
if "error" in returned:
error_msg = cast(PipeErrorMessage, returned)
raise TaskRuntimeError(error_msg["error"])
if "result" not in returned:
raise TaskResultMissingError()
result_msg = cast(PipeResultMessage, returned)
result = result_msg["result"]
print_args = result_msg.get("print_args", [])
assert pipe_reader.message_size is not None
result_size_bytes = pipe_reader.message_size
return result, print_args, result_size_bytes
except Exception as e:
if continue_on_fail:
return [{"json": {"error": str(e)}}], print_args, 0
raise
@staticmethod
def stop_process(process: ForkServerProcess | None):
"""Stop a running subprocess, gracefully else force-killing."""
if process is None or not process.is_alive():
return
try:
process.terminate()
process.join(timeout=1) # 1s grace period
if process.is_alive():
process.kill()
process.join()
except (ProcessLookupError, ConnectionError, BrokenPipeError):
# subprocess is dead or unreachable
pass
@staticmethod
def _all_items(
raw_code: str,
items: Items,
write_conn,
security_config: SecurityConfig,
query: Query = None,
):
"""Execute a Python code task in all-items mode."""
if security_config.runner_env_deny:
os.environ.clear()
TaskExecutor._sanitize_sys_modules(security_config)
print_args: PrintArgs = []
sys.stderr = stderr_capture = io.StringIO()
try:
wrapped_code = TaskExecutor._wrap_code(raw_code)
compiled_code = compile(wrapped_code, EXECUTOR_ALL_ITEMS_FILENAME, "exec")
globals = {
"__builtins__": TaskExecutor._filter_builtins(security_config),
"_items": items,
"_query": query,
"print": TaskExecutor._create_custom_print(print_args),
}
exec(compiled_code, globals)
result = cast(Items, globals[EXECUTOR_USER_OUTPUT_KEY])
TaskExecutor._put_result(write_conn.fileno(), result, print_args)
except BaseException as e:
TaskExecutor._put_error(
write_conn.fileno(), e, stderr_capture.getvalue(), print_args
)
@staticmethod
def _per_item(
raw_code: str,
items: Items,
write_conn,
security_config: SecurityConfig,
_query: Query = None, # unused, only to keep signatures consistent across modes
):
"""Execute a Python code task in per-item mode."""
if security_config.runner_env_deny:
os.environ.clear()
TaskExecutor._sanitize_sys_modules(security_config)
print_args: PrintArgs = []
sys.stderr = stderr_capture = io.StringIO()
try:
wrapped_code = TaskExecutor._wrap_code(raw_code)
compiled_code = compile(wrapped_code, EXECUTOR_PER_ITEM_FILENAME, "exec")
filtered_builtins = TaskExecutor._filter_builtins(security_config)
custom_print = TaskExecutor._create_custom_print(print_args)
result: Items = []
for index, item in enumerate(items):
globals = {
"__builtins__": filtered_builtins,
"_item": item,
"print": custom_print,
}
exec(compiled_code, globals)
user_output = globals[EXECUTOR_USER_OUTPUT_KEY]
if user_output is None:
continue
json_data = TaskExecutor._extract_json_data_per_item(user_output)
output_item = {"json": json_data, "pairedItem": {"item": index}}
if isinstance(user_output, dict) and "binary" in user_output:
output_item["binary"] = user_output["binary"]
result.append(output_item)
TaskExecutor._put_result(write_conn.fileno(), result, print_args)
except BaseException as e:
TaskExecutor._put_error(
write_conn.fileno(), e, stderr_capture.getvalue(), print_args
)
@staticmethod
def _wrap_code(raw_code: str) -> str:
indented_code = textwrap.indent(raw_code, " ")
return f"def _user_function():\n{indented_code}\n\n{EXECUTOR_USER_OUTPUT_KEY} = _user_function()"
@staticmethod
def _extract_json_data_per_item(user_output):
if not isinstance(user_output, dict):
return user_output
if "json" in user_output:
return user_output["json"]
if "binary" in user_output:
return {k: v for k, v in user_output.items() if k != "binary"}
return user_output
@staticmethod
def _put_result(write_fd: int, result: Items, print_args: PrintArgs):
message: PipeResultMessage = {
"result": result,
"print_args": TaskExecutor._truncate_print_args(print_args),
}
data = json.dumps(message, default=str, ensure_ascii=False).encode("utf-8")
length_bytes = len(data).to_bytes(PIPE_MSG_PREFIX_LENGTH, "big")
try:
TaskExecutor._write_bytes(write_fd, length_bytes)
TaskExecutor._write_bytes(write_fd, data)
finally:
try:
os.close(write_fd)
except Exception:
pass
@staticmethod
def _put_error(
write_fd: int,
e: BaseException,
stderr: str = "",
print_args: PrintArgs | None = None,
):
if print_args is None:
print_args = []
task_error_info: TaskErrorInfo = {
"message": f"Process exited with code {e.code}"
if isinstance(e, SystemExit)
else str(e),
"description": getattr(e, "description", ""),
"stack": traceback.format_exc(),
"stderr": stderr,
}
message: PipeErrorMessage = {
"error": task_error_info,
"print_args": TaskExecutor._truncate_print_args(print_args),
}
data = json.dumps(message, default=str, ensure_ascii=False).encode("utf-8")
length_bytes = len(data).to_bytes(PIPE_MSG_PREFIX_LENGTH, "big")
try:
TaskExecutor._write_bytes(write_fd, length_bytes)
TaskExecutor._write_bytes(write_fd, data)
finally:
try:
os.close(write_fd)
except Exception:
pass
# ========== print() ==========
@staticmethod
def _create_custom_print(print_args: PrintArgs):
def custom_print(*args):
serializable_args = []
for arg in args:
try:
json.dumps(arg, default=str, ensure_ascii=False)
serializable_args.append(arg)
except Exception as _:
# Ensure args are serializable so they are transmissible
# through the multiprocessing queue and via websockets.
serializable_args.append(
{
EXECUTOR_CIRCULAR_REFERENCE_KEY: repr(arg),
"__type__": type(arg).__name__,
}
)
formatted = TaskExecutor._format_print_args(*serializable_args)
print_args.append(formatted)
print("[user code]", *args)
return custom_print
@staticmethod
def _format_print_args(*args) -> list[str]:
"""
Takes the args passed to a `print()` call in user code and converts them
to string representations suitable for display in a browser console.
Expects all args to be serializable.
"""
formatted = []
for arg in args:
if isinstance(arg, str):
formatted.append(f"'{arg}'")
elif arg is None or isinstance(arg, (int, float, bool)):
formatted.append(str(arg))
elif isinstance(arg, dict) and EXECUTOR_CIRCULAR_REFERENCE_KEY in arg:
formatted.append(f"[Circular {arg.get('__type__', 'Object')}]")
else:
formatted.append(json.dumps(arg, default=str, ensure_ascii=False))
return formatted
@staticmethod
def _truncate_print_args(print_args: PrintArgs) -> PrintArgs:
"""Truncate print_args to prevent pipe buffer overflow."""
if not print_args or len(print_args) <= MAX_PRINT_ARGS_ALLOWED:
return print_args
truncated = print_args[:MAX_PRINT_ARGS_ALLOWED]
truncated.append(
[
f"[Output truncated - {len(print_args) - MAX_PRINT_ARGS_ALLOWED} more print statements]"
]
)
return truncated
# ========== security ==========
@staticmethod
def _filter_builtins(security_config: SecurityConfig):
"""Get __builtins__ with denied ones removed."""
if len(security_config.builtins_deny) == 0:
filtered = dict(__builtins__)
else:
filtered = {
k: v
for k, v in __builtins__.items()
if k not in security_config.builtins_deny
}
filtered["__import__"] = TaskExecutor._create_safe_import(security_config)
return filtered
@staticmethod
def _sanitize_sys_modules(security_config: SecurityConfig):
safe_modules = {
"builtins",
"__main__",
"sys",
"traceback",
"linecache",
"importlib",
"importlib.machinery",
}
if "*" in security_config.stdlib_allow:
safe_modules.update(sys.stdlib_module_names)
else:
safe_modules.update(security_config.stdlib_allow)
if "*" in security_config.external_allow:
safe_modules.update(
name
for name in sys.modules.keys()
if name not in sys.stdlib_module_names
)
else:
safe_modules.update(security_config.external_allow)
# keep modules marked as safe and submodules of those
safe_prefixes = [safe + "." for safe in safe_modules]
modules_to_remove = [
name
for name in sys.modules.keys()
if name not in safe_modules
and not any(name.startswith(prefix) for prefix in safe_prefixes)
]
for module_name in modules_to_remove:
del sys.modules[module_name]
@staticmethod
def _create_safe_import(security_config: SecurityConfig):
original_import = __builtins__["__import__"]
def safe_import(name, *args, **kwargs):
is_allowed, error_msg = validate_module_import(name, security_config)
if not is_allowed:
assert error_msg is not None
raise SecurityViolationError(
message="Security violation detected",
description=error_msg,
)
return original_import(name, *args, **kwargs)
return safe_import
# ========== pipe I/O ==========
@staticmethod
def _write_bytes(fd: int, data: bytes):
total_written = 0
while total_written < len(data):
written = os.write(fd, data[total_written:])
if written == 0:
raise OSError("Write failed")
total_written += written
@@ -0,0 +1,510 @@
import asyncio
import logging
import time
from typing import Callable, Awaitable
from dataclasses import dataclass
from urllib.parse import urlparse
import websockets
from websockets.exceptions import InvalidStatus
from websockets.asyncio.client import ClientConnection
import random
from src.errors import TaskCancelledError
from src.config.task_runner_config import TaskRunnerConfig
from src.errors import (
NoIdleTimeoutHandlerError,
TaskMissingError,
WebsocketConnectionError,
)
from src.message_types.broker import TaskSettings
from src.nanoid import nanoid
from src.constants import (
RUNNER_NAME,
TASK_REJECTED_REASON_AT_CAPACITY,
TASK_REJECTED_REASON_OFFER_EXPIRED,
TASK_TYPE_PYTHON,
OFFER_INTERVAL,
OFFER_VALIDITY,
OFFER_VALIDITY_MAX_JITTER,
OFFER_VALIDITY_LATENCY_BUFFER,
TASK_BROKER_WS_PATH,
RPC_BROWSER_CONSOLE_LOG_METHOD,
LOG_TASK_COMPLETE,
LOG_TASK_CANCEL,
LOG_TASK_CANCEL_UNKNOWN,
LOG_TASK_CANCEL_WAITING,
)
from src.message_types import (
BrokerMessage,
RunnerMessage,
BrokerInfoRequest,
BrokerRunnerRegistered,
BrokerTaskOfferAccept,
BrokerTaskSettings,
BrokerTaskCancel,
BrokerRpcResponse,
BrokerDrain,
RunnerInfo,
RunnerTaskOffer,
RunnerTaskAccepted,
RunnerTaskRejected,
RunnerTaskDone,
RunnerTaskError,
RunnerRpcCall,
)
from src.message_serde import MessageSerde
from src.task_state import TaskState, TaskStatus
from src.task_executor import TaskExecutor
from src.task_analyzer import TaskAnalyzer
from src.config.security_config import SecurityConfig
@dataclass
class TaskOffer:
offer_id: str
valid_until: float
@property
def has_expired(self) -> bool:
return time.time() > self.valid_until
class TaskRunner:
def __init__(
self,
config: TaskRunnerConfig,
):
self.runner_id = nanoid()
self.name = RUNNER_NAME
self.config = config
self.websocket_connection: ClientConnection | None = None
self.can_send_offers = False
self.open_offers: dict[str, TaskOffer] = {}
self.running_tasks: dict[str, TaskState] = {}
self.offers_coroutine: asyncio.Task | None = None
self.serde = MessageSerde()
self.executor = TaskExecutor()
self.security_config = SecurityConfig(
stdlib_allow=config.stdlib_allow,
external_allow=config.external_allow,
builtins_deny=config.builtins_deny,
runner_env_deny=config.env_deny,
)
self.analyzer = TaskAnalyzer(self.security_config)
self.logger = logging.getLogger(__name__)
self.idle_coroutine: asyncio.Task | None = None
self.on_idle_timeout: Callable[[], Awaitable[None]] | None = None
self.last_activity_time = time.time()
self.is_shutting_down = False
self.task_broker_uri = config.task_broker_uri
websocket_host = urlparse(config.task_broker_uri).netloc
self.websocket_url = (
f"ws://{websocket_host}{TASK_BROKER_WS_PATH}?id={self.runner_id}"
)
@property
def running_tasks_count(self) -> int:
return len(self.running_tasks)
async def start(self) -> None:
if self.config.is_auto_shutdown_enabled and not self.on_idle_timeout:
raise NoIdleTimeoutHandlerError(self.config.auto_shutdown_timeout)
headers = {"Authorization": f"Bearer {self.config.grant_token}"}
while not self.is_shutting_down:
try:
self.websocket_connection = await websockets.connect(
self.websocket_url,
additional_headers=headers,
max_size=self.config.max_payload_size,
)
self.logger.info("Connected to broker")
await self._listen_for_messages()
except InvalidStatus as e:
if e.response.status_code == 403:
self.logger.error(
f"Authentication failed with status {e.response.status_code}: {e}"
)
raise
self.logger.warning(f"Failed to connect to broker: {e} - retrying...")
except Exception as e:
self.logger.warning(f"Failed to connect to broker: {e} - retrying...")
if not self.is_shutting_down:
self.websocket_connection = None
self.can_send_offers = False
await self._cancel_coroutine(self.offers_coroutine)
await self._cancel_coroutine(self.idle_coroutine)
await asyncio.sleep(5)
async def _cancel_coroutine(self, coroutine: asyncio.Task | None) -> None:
if coroutine and not coroutine.done():
coroutine.cancel()
try:
await coroutine
except asyncio.CancelledError:
pass
# ========== Shutdown ==========
async def stop(self) -> None:
self.is_shutting_down = True
self.can_send_offers = False
await self._cancel_coroutine(self.offers_coroutine)
await self._cancel_coroutine(self.idle_coroutine)
await self._wait_for_tasks()
await self._terminate_tasks()
if self.websocket_connection:
await self.websocket_connection.close()
self.logger.info("Disconnected from broker")
self.logger.info("Runner stopped")
async def _wait_for_tasks(self):
if not self.running_tasks:
return
timeout = self.config.graceful_shutdown_timeout
self.logger.debug(
f"Waiting for {self.running_tasks_count} tasks to complete (timeout: {timeout}s)..."
)
start_time = time.time()
while self.running_tasks and (time.time() - start_time) < timeout:
await asyncio.sleep(0.5)
if self.running_tasks:
self.logger.warning(
f"Timed out waiting for {self.running_tasks_count} tasks to complete"
)
async def _terminate_tasks(self):
if not self.running_tasks:
return
self.logger.warning(f"Terminating {self.running_tasks_count} tasks...")
tasks_to_terminate = [
asyncio.to_thread(self.executor.stop_process, task_state.process)
for task_state in self.running_tasks.values()
if task_state.process
]
if tasks_to_terminate:
await asyncio.gather(*tasks_to_terminate, return_exceptions=True)
self.running_tasks.clear()
self.logger.warning("Terminated tasks")
# ========== Messages ==========
async def _listen_for_messages(self) -> None:
if self.websocket_connection is None:
raise WebsocketConnectionError(self.task_broker_uri)
async for raw_message in self.websocket_connection:
try:
if isinstance(raw_message, bytes):
raw_message = raw_message.decode("utf-8")
message = self.serde.deserialize_broker_message(raw_message)
await self._handle_message(message)
except websockets.ConnectionClosedOK:
break
except Exception as e:
self.logger.error(f"Error handling message: {e}")
async def _handle_message(self, message: BrokerMessage) -> None:
match message:
case BrokerInfoRequest():
await self._handle_info_request()
case BrokerRunnerRegistered():
await self._handle_runner_registered()
case BrokerTaskOfferAccept():
await self._handle_task_offer_accept(message)
case BrokerTaskSettings():
await self._handle_task_settings(message)
case BrokerTaskCancel():
await self._handle_task_cancel(message)
case BrokerRpcResponse():
pass # currently only logging, already handled by browser
case BrokerDrain():
await self._handle_drain()
case _:
self.logger.warning(f"Unhandled message type: {type(message)}")
async def _handle_info_request(self) -> None:
response = RunnerInfo(name=self.name, types=[TASK_TYPE_PYTHON])
await self._send_message(response)
async def _handle_drain(self) -> None:
self.can_send_offers = False
await self._cancel_coroutine(self.offers_coroutine)
self.logger.info("Received drain signal, stopped accepting new tasks")
async def _handle_runner_registered(self) -> None:
self.can_send_offers = True
self.offers_coroutine = asyncio.create_task(self._send_offers_loop())
self.logger.info("Registered with broker")
self._reset_idle_timer()
async def _handle_task_offer_accept(self, message: BrokerTaskOfferAccept) -> None:
offer = self.open_offers.get(message.offer_id)
if offer is None or offer.has_expired:
response = RunnerTaskRejected(
task_id=message.task_id,
reason=TASK_REJECTED_REASON_OFFER_EXPIRED,
)
await self._send_message(response)
return
if self.running_tasks_count >= self.config.max_concurrency:
response = RunnerTaskRejected(
task_id=message.task_id,
reason=TASK_REJECTED_REASON_AT_CAPACITY,
)
await self._send_message(response)
return
del self.open_offers[message.offer_id]
task_state = TaskState(message.task_id)
self.running_tasks[message.task_id] = task_state
response = RunnerTaskAccepted(task_id=message.task_id)
await self._send_message(response)
self.logger.info(f"Accepted task {message.task_id}")
self._reset_idle_timer()
async def _handle_task_settings(self, message: BrokerTaskSettings) -> None:
task_state = self.running_tasks.get(message.task_id)
if task_state is None:
raise TaskMissingError(message.task_id)
if task_state.status != TaskStatus.WAITING_FOR_SETTINGS:
self.logger.warning(
f"Received settings for task but it is already {task_state.status}. Discarding message."
)
return
task_state.workflow_name = message.settings.workflow_name
task_state.workflow_id = message.settings.workflow_id
task_state.node_name = message.settings.node_name
task_state.node_id = message.settings.node_id
task_state.status = TaskStatus.RUNNING
asyncio.create_task(self._execute_task(message.task_id, message.settings))
self.logger.info(f"Received task {message.task_id}")
async def _execute_task(self, task_id: str, task_settings: TaskSettings) -> None:
start_time = time.time()
try:
task_state = self.running_tasks.get(task_id)
if task_state is None:
raise TaskMissingError(task_id)
self.analyzer.validate(task_settings.code)
process, read_conn, write_conn = self.executor.create_process(
code=task_settings.code,
node_mode=task_settings.node_mode,
items=task_settings.items,
security_config=self.security_config,
query=task_settings.query,
)
task_state.process = process
result, print_args, result_size_bytes = await asyncio.to_thread(
self.executor.execute_process,
process=process,
read_conn=read_conn,
write_conn=write_conn,
task_timeout=self.config.task_timeout,
continue_on_fail=task_settings.continue_on_fail,
)
for print_args_per_call in print_args:
await self._send_rpc_message(
task_id, RPC_BROWSER_CONSOLE_LOG_METHOD, print_args_per_call
)
response = RunnerTaskDone(task_id=task_id, data={"result": result})
await self._send_message(response)
self.logger.info(
LOG_TASK_COMPLETE.format(
task_id=task_id,
duration=self._get_duration(start_time),
result_size=self._get_result_size(result_size_bytes),
**task_state.context(),
)
)
except TaskCancelledError as e:
response = RunnerTaskError(task_id=task_id, error={"message": str(e)})
await self._send_message(response)
except SyntaxError as e:
self.logger.warning(f"Task {task_id} failed syntax validation")
error = {"message": str(e)}
response = RunnerTaskError(task_id=task_id, error=error)
await self._send_message(response)
except Exception as e:
self.logger.error(f"Task {task_id} failed", exc_info=True)
error = {
"message": getattr(e, "message", str(e)),
"description": getattr(e, "description", ""),
}
response = RunnerTaskError(task_id=task_id, error=error)
await self._send_message(response)
finally:
self.running_tasks.pop(task_id, None)
self._reset_idle_timer()
async def _handle_task_cancel(self, message: BrokerTaskCancel) -> None:
task_id = message.task_id
task_state = self.running_tasks.get(task_id)
if task_state is None:
self.logger.warning(LOG_TASK_CANCEL_UNKNOWN.format(task_id=task_id))
return
if task_state.status == TaskStatus.WAITING_FOR_SETTINGS:
self.running_tasks.pop(task_id, None)
self.logger.info(LOG_TASK_CANCEL_WAITING.format(task_id=task_id))
await self._send_offers()
return
if task_state.status == TaskStatus.RUNNING:
task_state.status = TaskStatus.ABORTING
await asyncio.to_thread(self.executor.stop_process, task_state.process)
self.logger.info(
LOG_TASK_CANCEL.format(task_id=task_id, **task_state.context())
)
async def _send_rpc_message(self, task_id: str, method_name: str, params: list):
message = RunnerRpcCall(
call_id=nanoid(), task_id=task_id, name=method_name, params=params
)
await self._send_message(message)
async def _send_message(self, message: RunnerMessage) -> None:
if self.websocket_connection is None:
raise WebsocketConnectionError(self.task_broker_uri)
serialized = self.serde.serialize_runner_message(message)
await self.websocket_connection.send(serialized)
# ========== Formatting ==========
def _get_duration(self, start_time: float) -> str:
elapsed = time.time() - start_time
if elapsed < 1:
return f"{int(elapsed * 1000)}ms"
if elapsed < 60:
return f"{int(elapsed)}s"
return f"{int(elapsed) // 60}m"
def _get_result_size(self, size_bytes: int) -> str:
if size_bytes < 1024:
return f"{size_bytes} bytes"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
else:
return f"{size_bytes / (1024 * 1024):.1f} MB"
# ========== Offers ==========
async def _send_offers_loop(self) -> None:
while self.can_send_offers:
try:
await self._send_offers()
await asyncio.sleep(OFFER_INTERVAL)
except asyncio.CancelledError:
break
except Exception as e:
self.logger.error(f"Error sending offers: {e}")
async def _send_offers(self) -> None:
if not self.can_send_offers:
return
expired_offer_ids = [
offer_id
for offer_id, offer in self.open_offers.items()
if offer.has_expired
]
for offer_id in expired_offer_ids:
self.open_offers.pop(offer_id, None)
offers_to_send = self.config.max_concurrency - (
len(self.open_offers) + self.running_tasks_count
)
for _ in range(offers_to_send):
offer_id = nanoid()
valid_for_ms = OFFER_VALIDITY + random.randint(0, OFFER_VALIDITY_MAX_JITTER)
valid_until = (
time.time() + (valid_for_ms / 1000) + OFFER_VALIDITY_LATENCY_BUFFER
)
self.open_offers[offer_id] = TaskOffer(offer_id, valid_until)
message = RunnerTaskOffer(
offer_id=offer_id, task_type=TASK_TYPE_PYTHON, valid_for=valid_for_ms
)
await self._send_message(message)
# ========== Inactivity ==========
def _reset_idle_timer(self):
"""Reset idle timer when key event occurs, namely runner registration, task acceptance, and task completion or failure."""
if not self.config.is_auto_shutdown_enabled:
return
self.last_activity_time = time.time()
if self.idle_coroutine and not self.idle_coroutine.done():
self.idle_coroutine.cancel()
self.idle_coroutine = asyncio.create_task(self._idle_timer_coroutine())
async def _idle_timer_coroutine(self):
try:
await asyncio.sleep(self.config.auto_shutdown_timeout)
if self.running_tasks_count > 0:
return
assert self.on_idle_timeout is not None # validated at start()
await self.on_idle_timeout()
except asyncio.CancelledError:
pass
@@ -0,0 +1,37 @@
from enum import Enum
from dataclasses import dataclass
from multiprocessing.context import ForkServerProcess
class TaskStatus(Enum):
WAITING_FOR_SETTINGS = "waiting_for_settings"
RUNNING = "running"
ABORTING = "aborting"
@dataclass
class TaskState:
task_id: str
status: TaskStatus
process: ForkServerProcess | None = None
workflow_name: str | None = None
workflow_id: str | None = None
node_name: str | None = None
node_id: str | None = None
def __init__(self, task_id: str):
self.task_id = task_id
self.status = TaskStatus.WAITING_FOR_SETTINGS
self.process = None
self.workflow_name = None
self.workflow_id = None
self.node_name = None
self.node_id = None
def context(self):
return {
"node_name": self.node_name,
"node_id": self.node_id,
"workflow_name": self.workflow_name,
"workflow_id": self.workflow_id,
}
@@ -0,0 +1,190 @@
import asyncio
import json
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from aiohttp import web, web_ws
from src.nanoid import nanoid
from tests.fixtures.test_constants import (
TASK_RESPONSE_WAIT,
LOCAL_TASK_BROKER_WS_PATH,
)
TaskId = str
TaskSettings = dict[str, Any]
WebsocketMessage = dict[str, Any]
@dataclass
class ActiveTask:
settings: TaskSettings
class LocalTaskBroker:
def __init__(self):
self.port: int | None = None
self.app = web.Application()
self.runner: web.AppRunner | None = None
self.site: web.TCPSite | None = None
self.connections: dict[str, web_ws.WebSocketResponse] = {}
self.pending_messages: dict[str, asyncio.Queue[WebsocketMessage]] = {}
self.received_messages: list[WebsocketMessage] = []
self.active_tasks: dict[TaskId, ActiveTask] = {}
self.task_settings: dict[TaskId, TaskSettings] = {}
self.rpc_messages: dict[TaskId, list[dict]] = {}
self.app.router.add_get(LOCAL_TASK_BROKER_WS_PATH, self.websocket_handler)
async def start(self) -> None:
self.runner = web.AppRunner(self.app)
await self.runner.setup()
self.site = web.TCPSite(self.runner, "localhost", 0)
await self.site.start()
assert self.site._server is not None
self.port = self.site._server.sockets[0].getsockname()[1]
print(f"Local task broker started on port {self.port}")
def get_url(self) -> str:
return f"http://localhost:{self.port}"
async def stop(self) -> None:
for ws in self.connections.values():
await ws.close()
self.connections.clear()
if self.site:
await self.site.stop()
if self.runner:
await self.runner.cleanup()
async def websocket_handler(self, request: web.Request) -> web_ws.WebSocketResponse:
print(f"WebSocket connection request from {request.remote}")
ws = web_ws.WebSocketResponse()
await ws.prepare(request)
connection_id = nanoid()
self.connections[connection_id] = ws
self.pending_messages[connection_id] = asyncio.Queue()
sender_coroutine = asyncio.create_task(self._message_sender(connection_id, ws))
try:
await self.send_to_connection(connection_id, {"type": "broker:inforequest"})
async for message in ws:
if message.type == web_ws.WSMsgType.TEXT:
json_message = json.loads(message.data)
self.received_messages.append(json_message)
await self._handle_message(connection_id, json_message)
finally:
sender_coroutine.cancel()
try:
await sender_coroutine
except asyncio.CancelledError:
pass
del self.connections[connection_id]
del self.pending_messages[connection_id]
return ws
async def _message_sender(self, connection_id: str, ws: web_ws.WebSocketResponse):
while True:
message = await self.pending_messages[connection_id].get()
await ws.send_str(json.dumps(message))
async def _handle_message(self, connection_id: str, message: WebsocketMessage):
match message.get("type"):
case "runner:info":
await self.send_to_connection(
connection_id, {"type": "broker:runnerregistered"}
)
case "runner:taskoffer":
pass # Handled by send_task() which waits for them
case "runner:taskaccepted":
task_id = message.get("taskId")
if task_id in self.task_settings:
await self.send_to_connection(
connection_id,
{
"type": "broker:tasksettings",
"taskId": task_id,
"settings": self.task_settings[task_id],
},
)
case "runner:taskdone" | "runner:taskerror":
task_id = message.get("taskId")
if task_id in self.active_tasks:
del self.active_tasks[task_id]
case "runner:rpc":
task_id = message.get("taskId")
if task_id:
if task_id not in self.rpc_messages:
self.rpc_messages[task_id] = []
self.rpc_messages[task_id].append(
{"method": message.get("name"), "params": message.get("params")}
)
async def send_to_connection(self, connection_id: str, message: WebsocketMessage):
if connection_id in self.pending_messages:
await self.pending_messages[connection_id].put(message)
async def send_task(
self,
task_id: TaskId,
task_settings: TaskSettings,
):
self.active_tasks[task_id] = ActiveTask(task_settings)
self.task_settings[task_id] = task_settings
offer = await self.wait_for_msg("runner:taskoffer", timeout=2.0)
if offer:
accept = {
"type": "broker:taskofferaccept",
"taskId": task_id,
"offerId": offer.get("offerId"),
}
if self.connections:
connection = next(iter(self.connections.keys()))
await self.send_to_connection(connection, accept)
async def cancel_task(self, task_id: TaskId, reason: str):
cancel_message = {
"type": "broker:taskcancel",
"taskId": task_id,
"reason": reason,
}
for connection_id in self.connections:
await self.send_to_connection(connection_id, cancel_message)
async def wait_for_msg(
self,
msg_type: str,
timeout: float = TASK_RESPONSE_WAIT,
predicate: Callable[[WebsocketMessage], bool] | None = None,
) -> WebsocketMessage | None:
start_time = asyncio.get_running_loop().time()
while asyncio.get_running_loop().time() - start_time < timeout:
for msg in self.received_messages:
if msg.get("type") == msg_type:
if predicate is None or predicate(msg):
return msg
await asyncio.sleep(0.1)
return None
def get_messages_of_type(self, msg_type: str) -> list[WebsocketMessage]:
return [msg for msg in self.received_messages if msg.get("type") == msg_type]
def get_task_rpc_messages(self, task_id: TaskId) -> list[dict]:
return self.rpc_messages.get(task_id, [])
@@ -0,0 +1,127 @@
import asyncio
import os
import re
import sys
from pathlib import Path
from src.constants import (
ENV_GRACEFUL_SHUTDOWN_TIMEOUT,
ENV_GRANT_TOKEN,
ENV_HEALTH_CHECK_SERVER_ENABLED,
ENV_HEALTH_CHECK_SERVER_PORT,
ENV_LAUNCHER_LOG_LEVEL,
ENV_TASK_BROKER_URI,
ENV_TASK_TIMEOUT,
)
from tests.fixtures.test_constants import (
GRACEFUL_SHUTDOWN_TIMEOUT,
TASK_TIMEOUT,
)
class TaskRunnerManager:
"""Responsible for managing the lifecycle of a task runner subprocess."""
def __init__(
self,
task_broker_url: str | None = None,
graceful_shutdown_timeout: float | None = None,
custom_env: dict[str, str] | None = None,
):
self.task_broker_url = task_broker_url
self.graceful_shutdown_timeout = graceful_shutdown_timeout
self.custom_env = custom_env or {}
self.subprocess: asyncio.subprocess.Process | None = None
self.stdout_buffer: list[str] = []
self.stderr_buffer: list[str] = []
self.health_check_port: int | None = None
async def start(self):
project_root = Path(__file__).parent.parent.parent
runner_path = project_root / "src" / "main.py"
env_vars = os.environ.copy()
env_vars[ENV_GRANT_TOKEN] = "test_token"
env_vars[ENV_TASK_BROKER_URI] = self.task_broker_url
env_vars[ENV_TASK_TIMEOUT] = str(TASK_TIMEOUT)
env_vars[ENV_HEALTH_CHECK_SERVER_ENABLED] = "true"
env_vars[ENV_HEALTH_CHECK_SERVER_PORT] = "0"
env_vars[ENV_LAUNCHER_LOG_LEVEL] = "INFO"
if self.graceful_shutdown_timeout is not None:
env_vars[ENV_GRACEFUL_SHUTDOWN_TIMEOUT] = str(
self.graceful_shutdown_timeout
)
env_vars["PYTHONPATH"] = str(project_root)
env_vars.update(self.custom_env)
self.subprocess = await asyncio.create_subprocess_exec(
sys.executable,
str(runner_path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env_vars,
cwd=str(project_root),
)
asyncio.create_task(self._read_stdout())
asyncio.create_task(self._read_stderr())
await self._wait_for_health_check_port()
def is_running(self) -> bool:
return self.subprocess is not None and self.subprocess.returncode is None
def get_health_check_url(self) -> str:
return f"http://localhost:{self.health_check_port}"
async def stop(self) -> None:
if not self.subprocess or self.subprocess.returncode is not None:
return
self.subprocess.terminate()
try:
await asyncio.wait_for(
self.subprocess.wait(), timeout=GRACEFUL_SHUTDOWN_TIMEOUT
)
except asyncio.TimeoutError:
self.subprocess.kill()
await self.subprocess.wait()
async def _read_stdout(self):
if not self.subprocess or not self.subprocess.stdout:
return
while True:
line = await self.subprocess.stdout.readline()
if not line:
break
self.stdout_buffer.append(line.decode().strip())
async def _read_stderr(self):
if not self.subprocess or not self.subprocess.stderr:
return
while True:
line = await self.subprocess.stderr.readline()
if not line:
break
self.stderr_buffer.append(line.decode().strip())
async def _wait_for_health_check_port(self, timeout: float = 5.0):
pattern = re.compile(r"Health check server listening on .+, port (\d+)")
start_time = asyncio.get_running_loop().time()
while asyncio.get_running_loop().time() - start_time < timeout:
for line in self.stdout_buffer:
match = pattern.search(line)
if match:
self.health_check_port = int(match.group(1))
return
await asyncio.sleep(0.1)
raise TimeoutError(
f"Failed to detect health check port within {timeout}s. "
f"Stdout: {self.stdout_buffer}"
)
@@ -0,0 +1,7 @@
# Local task broker
LOCAL_TASK_BROKER_WS_PATH = "/runners/_ws"
# Timing
TASK_RESPONSE_WAIT = 3
TASK_TIMEOUT = 2
GRACEFUL_SHUTDOWN_TIMEOUT = 1
@@ -0,0 +1,108 @@
import pytest_asyncio
from src.message_types.broker import Items
from src.message_serde import NODE_MODE_MAP
from tests.fixtures.local_task_broker import LocalTaskBroker
from tests.fixtures.task_runner_manager import TaskRunnerManager
from tests.fixtures.test_constants import (
TASK_RESPONSE_WAIT,
)
NODE_MODE_TO_BROKER_STYLE = {v: k for k, v in NODE_MODE_MAP.items()}
@pytest_asyncio.fixture
async def broker():
broker = LocalTaskBroker()
await broker.start()
yield broker
await broker.stop()
@pytest_asyncio.fixture
async def manager(broker):
manager = TaskRunnerManager(task_broker_url=broker.get_url())
await manager.start()
yield manager
await manager.stop()
@pytest_asyncio.fixture
async def manager_with_stdlib_wildcard(broker):
manager = TaskRunnerManager(
task_broker_url=broker.get_url(),
custom_env={
"N8N_RUNNERS_STDLIB_ALLOW": "*",
},
)
await manager.start()
yield manager
await manager.stop()
@pytest_asyncio.fixture
async def manager_with_env_access_blocked(broker):
manager = TaskRunnerManager(
task_broker_url=broker.get_url(),
custom_env={
"N8N_RUNNERS_STDLIB_ALLOW": "os",
"N8N_BLOCK_RUNNER_ENV_ACCESS": "true",
},
)
await manager.start()
yield manager
await manager.stop()
@pytest_asyncio.fixture
async def manager_with_env_access_allowed(broker):
manager = TaskRunnerManager(
task_broker_url=broker.get_url(),
custom_env={
"N8N_RUNNERS_STDLIB_ALLOW": "os",
"N8N_BLOCK_RUNNER_ENV_ACCESS": "false",
},
)
await manager.start()
yield manager
await manager.stop()
def create_task_settings(
code: str,
node_mode: str,
items: Items | None = None,
continue_on_fail: bool = False,
):
return {
"code": code,
"nodeMode": NODE_MODE_TO_BROKER_STYLE[node_mode],
"items": items if items is not None else [],
"continueOnFail": continue_on_fail,
}
async def wait_for_task_done(broker, task_id: str, timeout: float = TASK_RESPONSE_WAIT):
return await broker.wait_for_msg(
"runner:taskdone",
timeout=timeout,
predicate=lambda msg: msg.get("taskId") == task_id,
)
async def wait_for_task_error(
broker, task_id: str, timeout: float = TASK_RESPONSE_WAIT
):
return await broker.wait_for_msg(
"runner:taskerror",
timeout=timeout,
predicate=lambda msg: msg.get("taskId") == task_id,
)
def get_browser_console_msgs(broker: LocalTaskBroker, task_id: str) -> list[list[str]]:
console_msgs = []
for msg in broker.get_task_rpc_messages(task_id):
if msg.get("method") == "logNodeOutput":
console_msgs.append(msg.get("params", []))
return console_msgs
@@ -0,0 +1,458 @@
import asyncio
import textwrap
import pytest
from src.nanoid import nanoid
from tests.integration.conftest import (
create_task_settings,
wait_for_task_done,
wait_for_task_error,
)
from tests.fixtures.test_constants import TASK_TIMEOUT
# ========== all_items mode ==========
@pytest.mark.asyncio
async def test_all_items_with_success(broker, manager):
task_id = nanoid()
items = [
{"json": {"name": "Alice", "age": 30}},
{"json": {"name": "Bob", "age": 16}},
{"json": {"name": "Charlie", "age": 35}},
]
code = textwrap.dedent("""
result = []
for item in _items:
person = item['json']
result.append({
'name': person['name'],
'age': person['age'],
'adult': person['age'] >= 18
})
return result
""")
task_settings = create_task_settings(code=code, node_mode="all_items", items=items)
await broker.send_task(task_id=task_id, task_settings=task_settings)
result = await wait_for_task_done(broker, task_id)
assert result["data"]["result"] == [
{"name": "Alice", "age": 30, "adult": True},
{"name": "Bob", "age": 16, "adult": False},
{"name": "Charlie", "age": 35, "adult": True},
]
@pytest.mark.asyncio
async def test_all_items_with_error(broker, manager):
task_id = nanoid()
code = "raise ValueError('Intentional error')"
task_settings = create_task_settings(code=code, node_mode="all_items")
await broker.send_task(task_id=task_id, task_settings=task_settings)
error_msg = await wait_for_task_error(broker, task_id)
assert error_msg["taskId"] == task_id
assert "Intentional error" in str(error_msg["error"]["message"])
@pytest.mark.asyncio
async def test_all_items_with_continue_on_fail(broker, manager):
task_id = nanoid()
code = "raise ValueError('Intentional error')"
task_settings = create_task_settings(
code=code, node_mode="all_items", continue_on_fail=True
)
await broker.send_task(task_id=task_id, task_settings=task_settings)
done_msg = await wait_for_task_done(broker, task_id)
assert len(done_msg["data"]["result"]) == 1
assert "error" in done_msg["data"]["result"][0]["json"]
assert "Intentional error" in str(done_msg["data"]["result"][0]["json"]["error"])
# ========== per_item mode ==========
@pytest.mark.asyncio
async def test_per_item_with_success(broker, manager):
task_id = nanoid()
items = [
{"json": {"value": 10}},
{"json": {"value": 20}},
{"json": {"value": 30}},
]
code = "return {'doubled': _item['json']['value'] * 2}"
task_settings = create_task_settings(code=code, node_mode="per_item", items=items)
await broker.send_task(task_id=task_id, task_settings=task_settings)
done_msg = await wait_for_task_done(broker, task_id)
assert done_msg["taskId"] == task_id
assert done_msg["data"]["result"] == [
{"json": {"doubled": 20}, "pairedItem": {"item": 0}},
{"json": {"doubled": 40}, "pairedItem": {"item": 1}},
{"json": {"doubled": 60}, "pairedItem": {"item": 2}},
]
@pytest.mark.asyncio
async def test_per_item_with_explicit_json_and_binary(broker, manager):
task_id = nanoid()
items = [{"json": {"value": 10}}]
code = "return {'json': {'custom': 'data'}, 'binary': {'file': 'data'}}"
task_settings = create_task_settings(code=code, node_mode="per_item", items=items)
await broker.send_task(task_id=task_id, task_settings=task_settings)
result = await wait_for_task_done(broker, task_id)
assert result["data"]["result"] == [
{
"json": {"custom": "data"},
"binary": {"file": "data"},
"pairedItem": {"item": 0},
}
]
@pytest.mark.asyncio
async def test_per_item_with_binary_only(broker, manager):
task_id = nanoid()
items = [{"json": {"value": 10}}]
code = "return {'binary': {'file': 'data'}}"
task_settings = create_task_settings(code=code, node_mode="per_item", items=items)
await broker.send_task(task_id=task_id, task_settings=task_settings)
result = await wait_for_task_done(broker, task_id)
assert result["data"]["result"] == [
{"json": {}, "binary": {"file": "data"}, "pairedItem": {"item": 0}}
]
@pytest.mark.asyncio
async def test_per_item_with_filtering(broker, manager):
task_id = nanoid()
items = [
{"json": {"value": 5}},
{"json": {"value": 15}},
{"json": {"value": 25}},
{"json": {"value": 8}},
]
code = textwrap.dedent("""
value = _item['json']['value']
if value > 10:
return {'value': value, 'passed': True}
else:
return None # Filter out this item
""")
task_settings = create_task_settings(code=code, node_mode="per_item", items=items)
await broker.send_task(task_id=task_id, task_settings=task_settings)
result = await wait_for_task_done(broker, task_id)
assert result["data"]["result"] == [
{"json": {"value": 15, "passed": True}, "pairedItem": {"item": 1}},
{"json": {"value": 25, "passed": True}, "pairedItem": {"item": 2}},
]
@pytest.mark.asyncio
async def test_per_item_with_continue_on_fail(broker, manager):
task_id = nanoid()
items = [
{"json": {"value": 10}},
{"json": {"value": 0}}, # Will cause division by zero
{"json": {"value": 20}},
]
code = "return {'result': 100 / _item['json']['value']}"
task_settings = create_task_settings(
code=code,
node_mode="per_item",
items=items,
continue_on_fail=True,
)
await broker.send_task(task_id=task_id, task_settings=task_settings)
done_msg = await wait_for_task_done(broker, task_id)
assert len(done_msg["data"]["result"]) == 1
assert "error" in done_msg["data"]["result"][0]["json"]
assert "division by zero" in done_msg["data"]["result"][0]["json"]["error"]
# ========== Security ===========
@pytest.mark.asyncio
async def test_cannot_access_builtins_via_globals(broker, manager):
task_id = nanoid()
code = textwrap.dedent("""
b = globals()['__builtins__']
imp = b['__import__']
os = imp('os')
return [{"json": {"pid": os.getpid()}}]
""")
task_settings = create_task_settings(code=code, node_mode="all_items")
await broker.send_task(task_id=task_id, task_settings=task_settings)
error_msg = await wait_for_task_error(broker, task_id)
assert error_msg["taskId"] == task_id
assert "globals" in str(error_msg["error"]["message"]).lower()
@pytest.mark.asyncio
async def test_cannot_access_builtins_via_locals(broker, manager):
task_id = nanoid()
code = textwrap.dedent("""
b = locals()['__builtins__']
imp = b['__import__']
os = imp('os')
return [{"json": {"pid": os.getpid()}}]
""")
task_settings = create_task_settings(code=code, node_mode="all_items")
await broker.send_task(task_id=task_id, task_settings=task_settings)
error_msg = await wait_for_task_error(broker, task_id)
assert error_msg["taskId"] == task_id
assert "locals" in str(error_msg["error"]["message"]).lower()
# ========== edge cases ===========
@pytest.mark.asyncio
async def test_cancel_during_execution(broker, manager):
task_id = nanoid()
code = textwrap.dedent("""
import time
for i in range(20):
time.sleep(0.05)
if i == 10:
# Should be cancelled around here
pass
return [{"completed": "should not reach here"}]
""")
task_settings = create_task_settings(code=code, node_mode="all_items")
await broker.send_task(task_id=task_id, task_settings=task_settings)
await asyncio.sleep(0.3)
await broker.cancel_task(task_id, reason="Cancelled during execution")
error_msg = await wait_for_task_error(broker, task_id)
assert error_msg["taskId"] == task_id
assert "error" in error_msg
@pytest.mark.asyncio
async def test_timeout_during_execution(broker, manager):
task_id = nanoid()
code = textwrap.dedent("""
while True:
pass
""")
task_settings = create_task_settings(code=code, node_mode="all_items")
await broker.send_task(task_id=task_id, task_settings=task_settings)
error_msg = await wait_for_task_error(broker, task_id, timeout=TASK_TIMEOUT + 1.5)
assert error_msg["taskId"] == task_id
assert "timed out" in error_msg["error"]["message"].lower()
@pytest.mark.asyncio
async def test_stdlib_submodules_with_wildcard(broker, manager_with_stdlib_wildcard):
task_id = nanoid()
code = textwrap.dedent("""
from collections.abc import Iterable
result = isinstance([1, 2, 3], Iterable)
return [{"json": {"is_iterable": result}}]
""")
task_settings = create_task_settings(code=code, node_mode="all_items")
await broker.send_task(task_id=task_id, task_settings=task_settings)
result = await wait_for_task_done(broker, task_id)
assert result["data"]["result"] == [{"json": {"is_iterable": True}}]
@pytest.mark.asyncio
async def test_cannot_bypass_import_restrictions_via_builtins_dict(broker, manager):
task_id = nanoid()
code = textwrap.dedent("""
os = __builtins__['__import__']('os')
print(os.getpid())
return []
""")
task_settings = create_task_settings(code=code, node_mode="all_items")
await broker.send_task(task_id=task_id, task_settings=task_settings)
error_msg = await wait_for_task_error(broker, task_id)
assert error_msg["taskId"] == task_id
assert "error" in error_msg
assert "__import__" in str(error_msg["error"]["description"]).lower()
@pytest.mark.asyncio
async def test_cannot_bypass_import_restrictions_via_builtins_spec_loader(
broker, manager
):
task_id = nanoid()
code = textwrap.dedent("""
sys = __builtins__['__spec__'].loader.load_module('sys')
os = sys.meta_path[-1].find_spec("os").loader.load_module('os')
return [{"json": {"pid": os.getpid()}}]
""")
task_settings = create_task_settings(code=code, node_mode="all_items")
await broker.send_task(task_id=task_id, task_settings=task_settings)
error_msg = await wait_for_task_error(broker, task_id)
assert error_msg["taskId"] == task_id
assert "error" in error_msg
@pytest.mark.asyncio
async def test_cannot_bypass_import_restrictions_via_sys_builtins_spec_leader(
broker, manager_with_stdlib_wildcard
):
task_id = nanoid()
code = textwrap.dedent("""
import sys
os = sys.__builtins__['__spec__'].loader.load_module('os')
return [{"json": {"pid": os.getpid()}}]
""")
task_settings = create_task_settings(code=code, node_mode="all_items")
await broker.send_task(task_id=task_id, task_settings=task_settings)
error_msg = await wait_for_task_error(broker, task_id)
assert error_msg["taskId"] == task_id
assert "error" in error_msg
@pytest.mark.asyncio
async def test_cannot_bypass_import_restrictions_via_format_string(broker, manager):
task_id = nanoid()
code = textwrap.dedent("""
ex = None
try:
"{.__builtins__[__import__].__call__.a}".format(print)
except Exception as e:
ex = e
return [{"json": {"error": str(ex)}}]
""")
task_settings = create_task_settings(code=code, node_mode="all_items")
await broker.send_task(task_id=task_id, task_settings=task_settings)
error_msg = await wait_for_task_error(broker, task_id)
assert error_msg["taskId"] == task_id
assert "error" in error_msg
assert "__builtins__" in str(error_msg["error"]["description"]).lower()
@pytest.mark.asyncio
async def test_env_blocked_by_default_all_items(
broker, manager_with_env_access_blocked
):
task_id = nanoid()
code = textwrap.dedent("""
import os
path = os.environ.get('PATH', 'NOT_FOUND')
home = os.environ.get('HOME', 'NOT_FOUND')
env_dict = dict(os.environ)
return [{"json": {
"path": path,
"home": home,
"env_count": len(env_dict)
}}]
""")
task_settings = create_task_settings(code=code, node_mode="all_items")
await broker.send_task(task_id=task_id, task_settings=task_settings)
result = await wait_for_task_done(broker, task_id)
assert result["data"]["result"][0]["json"]["path"] == "NOT_FOUND"
assert result["data"]["result"][0]["json"]["home"] == "NOT_FOUND"
assert result["data"]["result"][0]["json"]["env_count"] == 0
@pytest.mark.asyncio
async def test_env_blocked_by_default_per_item(broker, manager_with_env_access_blocked):
task_id = nanoid()
items = [
{"json": {"index": 0}},
{"json": {"index": 1}},
]
code = textwrap.dedent("""
import os
path = os.environ.get('PATH', 'NOT_FOUND')
return {"path": path, "env_count": len(dict(os.environ))}
""")
task_settings = create_task_settings(code=code, node_mode="per_item", items=items)
await broker.send_task(task_id=task_id, task_settings=task_settings)
result = await wait_for_task_done(broker, task_id)
assert len(result["data"]["result"]) == 2
for item in result["data"]["result"]:
assert item["json"]["path"] == "NOT_FOUND"
assert item["json"]["env_count"] == 0
@pytest.mark.asyncio
async def test_env_accessible_when_allowed_all_items(
broker, manager_with_env_access_allowed
):
task_id = nanoid()
code = textwrap.dedent("""
import os
path = os.environ.get('PATH', 'NOT_FOUND')
env_dict = dict(os.environ)
return [{"json": {
"has_path": path != 'NOT_FOUND',
"env_count": len(env_dict)
}}]
""")
task_settings = create_task_settings(code=code, node_mode="all_items")
await broker.send_task(task_id=task_id, task_settings=task_settings)
result = await wait_for_task_done(broker, task_id)
assert result["data"]["result"][0]["json"]["has_path"] is True
assert result["data"]["result"][0]["json"]["env_count"] > 0
@pytest.mark.asyncio
async def test_env_accessible_when_allowed_per_item(
broker, manager_with_env_access_allowed
):
task_id = nanoid()
items = [
{"json": {"index": 0}},
{"json": {"index": 1}},
]
code = textwrap.dedent("""
import os
path = os.environ.get('PATH', 'NOT_FOUND')
return {
"has_path": path != 'NOT_FOUND',
"env_count": len(dict(os.environ))
}
""")
task_settings = create_task_settings(code=code, node_mode="per_item", items=items)
await broker.send_task(task_id=task_id, task_settings=task_settings)
result = await wait_for_task_done(broker, task_id)
assert len(result["data"]["result"]) == 2
for item in result["data"]["result"]:
assert item["json"]["has_path"] is True
assert item["json"]["env_count"] > 0
@@ -0,0 +1,40 @@
import asyncio
import textwrap
import aiohttp
import pytest
from src.nanoid import nanoid
from tests.integration.conftest import create_task_settings
@pytest.mark.asyncio
async def test_health_check_server_responds(broker, manager):
health_check_url = manager.get_health_check_url()
async with aiohttp.ClientSession() as session:
for _ in range(10):
try:
response = await session.get(health_check_url)
if response.status == 200:
assert await response.text() == "OK"
return
except aiohttp.ClientConnectionError:
await asyncio.sleep(0.1)
@pytest.mark.asyncio
async def test_health_check_server_ressponds_mid_execution(broker, manager):
task_id = nanoid()
code = textwrap.dedent("""
for _ in range(10_000_000):
pass
return [{"result": "completed"}]
""")
task_settings = create_task_settings(code=code, node_mode="all_items")
await broker.send_task(task_id=task_id, task_settings=task_settings)
await asyncio.sleep(0.3)
async with aiohttp.ClientSession() as session:
response = await session.get(manager.get_health_check_url())
assert response.status == 200
assert await response.text() == "OK"
@@ -0,0 +1,116 @@
import textwrap
import pytest
from src.nanoid import nanoid
from tests.integration.conftest import (
create_task_settings,
get_browser_console_msgs,
wait_for_task_done,
)
@pytest.mark.asyncio
async def test_print_basic_types(broker, manager):
task_id = nanoid()
code = textwrap.dedent("""
print("Hello, World!")
print(42)
print(3.14)
print(True)
print(None)
print("Multiple", "args", 123, False)
return [{"printed": "ok"}]
""")
task_settings = create_task_settings(code=code, node_mode="all_items")
await broker.send_task(task_id=task_id, task_settings=task_settings)
done_msg = await wait_for_task_done(broker, task_id, timeout=5.0)
assert done_msg["taskId"] == task_id
assert done_msg["data"]["result"] == [{"printed": "ok"}]
msgs = get_browser_console_msgs(broker, task_id)
assert len(msgs) > 0, "Should have captured console messages"
all_args = []
for msg in msgs:
all_args.extend(msg)
expected = [
"'Hello, World!'",
"42",
"3.14",
"True",
"None",
"'Multiple'",
"'args'",
"123",
"False",
]
for item in expected:
assert item in all_args, f"Expected '{item}' not found in console output"
@pytest.mark.asyncio
async def test_print_complex_types(broker, manager):
task_id = nanoid()
code = textwrap.dedent("""
print({"name": "John", "age": 30, "active": True})
print([1, 2, "three", {"four": 4}])
print({"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]})
return [{"result": "success"}]
""")
task_settings = create_task_settings(code=code, node_mode="all_items")
await broker.send_task(task_id=task_id, task_settings=task_settings)
result_msg = await wait_for_task_done(broker, task_id, timeout=5.0)
assert result_msg["data"]["result"] == [{"result": "success"}]
msgs = get_browser_console_msgs(broker, task_id)
assert len(msgs) > 0, "Should have captured console messages"
all_output = " ".join(["".join(msg) for msg in msgs]).replace(" ", "")
expected = [
'{"name":"John","age":30,"active":true}',
'[1,2,"three",{"four":4}]',
]
for item in expected:
assert item in all_output, f"Expected '{item}' not found in console output"
@pytest.mark.asyncio
async def test_print_edge_cases(broker, manager):
task_id = nanoid()
code = textwrap.dedent("""
print("Hello 世界 🌍")
print({"emoji": "🚀", "chinese": "你好", "arabic": "مرحبا"})
print("Line\\nbreak")
print("Tab\\tseparated")
print('Quote "test" here')
print()
print("")
print(" ")
print([])
print({})
print(())
print("x" * 1_000)
return [{"test": "complete"}]
""")
task_settings = create_task_settings(code=code, node_mode="all_items")
await broker.send_task(task_id=task_id, task_settings=task_settings)
done_msg = await wait_for_task_done(broker, task_id, timeout=5.0)
assert done_msg["data"]["result"] == [{"test": "complete"}]
msgs = get_browser_console_msgs(broker, task_id)
assert len(msgs) > 0, "Should have captured console messages"
all_output = " ".join(["".join(msg) for msg in msgs])
expected = ["世界", "🌍", "🚀", "你好", "[]", "{}"]
for item in expected:
assert item in all_output, f"Expected '{item}' not found in console output"
@@ -0,0 +1,223 @@
import os
import tempfile
from pathlib import Path
import pytest
from unittest.mock import patch
from src.env import read_env, read_int_env, read_bool_env, read_str_env, read_float_env
class TestReadEnv:
def test_returns_direct_env_var_when_exists(self):
with patch.dict(os.environ, {"TEST_VAR": "direct_value"}):
result = read_env("TEST_VAR")
assert result == "direct_value"
def test_returns_none_when_no_env_var(self):
with patch.dict(os.environ, clear=True):
result = read_env("NONEXISTENT_VAR")
assert result is None
def test_reads_from_file_when_file_env_var_exists(self):
with tempfile.NamedTemporaryFile(mode="w", delete=True) as f:
f.write("file_value\n")
f.flush()
with patch.dict(os.environ, {"TEST_VAR_FILE": f.name}):
result = read_env("TEST_VAR")
assert result == "file_value"
def test_strips_whitespace_from_file_content(self):
with tempfile.NamedTemporaryFile(mode="w", delete=True) as f:
f.write(" value_with_spaces \n\n")
f.flush()
with patch.dict(os.environ, {"TEST_VAR_FILE": f.name}):
result = read_env("TEST_VAR")
assert result == "value_with_spaces"
def test_direct_env_var_takes_precedence_over_file(self):
with tempfile.NamedTemporaryFile(mode="w", delete=True) as f:
f.write("file_value")
f.flush()
with patch.dict(
os.environ,
{"TEST_VAR": "direct_value", "TEST_VAR_FILE": f.name},
):
result = read_env("TEST_VAR")
assert result == "direct_value"
def test_raises_error_when_file_not_found(self):
with patch.dict(os.environ, {"TEST_VAR_FILE": "/nonexistent/file.txt"}):
with pytest.raises(ValueError) as exc_info:
read_env("TEST_VAR")
assert "Failed to read TEST_VAR_FILE from file" in str(exc_info.value)
def test_handles_empty_file(self):
with tempfile.NamedTemporaryFile(mode="w", delete=True) as f:
f.write("")
f.flush()
with patch.dict(os.environ, {"TEST_VAR_FILE": f.name}):
result = read_env("TEST_VAR")
assert result == ""
def test_handles_multiline_file_content(self):
with tempfile.NamedTemporaryFile(mode="w", delete=True) as f:
f.write("line1\nline2\nline3")
f.flush()
with patch.dict(os.environ, {"TEST_VAR_FILE": f.name}):
result = read_env("TEST_VAR")
assert result == "line1\nline2\nline3"
def test_handles_unicode_content(self):
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", delete=True) as f:
f.write("unicode: 你好世界 🌍")
f.flush()
with patch.dict(os.environ, {"TEST_VAR_FILE": f.name}):
result = read_env("TEST_VAR")
assert result == "unicode: 你好世界 🌍"
def test_raises_error_with_permission_denied(self):
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
f.write("secret_value")
temp_file_path = f.name
try:
Path(temp_file_path).chmod(0o000) # Make file unreadable
with patch.dict(os.environ, {"TEST_VAR_FILE": temp_file_path}):
with pytest.raises(ValueError) as exc_info:
read_env("TEST_VAR")
assert "Failed to read TEST_VAR_FILE from file" in str(exc_info.value)
finally:
Path(temp_file_path).chmod(0o644)
Path(temp_file_path).unlink()
class TestReadStrEnv:
def test_returns_string_from_direct_env(self):
with patch.dict(os.environ, {"TEST_STR": "hello world"}):
result = read_str_env("TEST_STR", default="default")
assert result == "hello world"
def test_returns_string_from_file(self):
with tempfile.NamedTemporaryFile(mode="w", delete=True) as f:
f.write("file content")
f.flush()
with patch.dict(os.environ, {"TEST_STR_FILE": f.name}):
result = read_str_env("TEST_STR", default="default")
assert result == "file content"
def test_returns_default_when_not_set(self):
with patch.dict(os.environ, clear=True):
result = read_str_env("TEST_STR", default="fallback")
assert result == "fallback"
def test_handles_empty_string_from_env(self):
with patch.dict(os.environ, {"TEST_STR": ""}):
result = read_str_env("TEST_STR", default="default")
assert result == ""
class TestReadIntEnv:
def test_returns_int_from_direct_env(self):
with patch.dict(os.environ, {"TEST_INT": "42"}):
result = read_int_env("TEST_INT", default=0)
assert result == 42
def test_returns_int_from_file(self):
with tempfile.NamedTemporaryFile(mode="w", delete=True) as f:
f.write("123")
f.flush()
with patch.dict(os.environ, {"TEST_INT_FILE": f.name}):
result = read_int_env("TEST_INT", default=0)
assert result == 123
def test_returns_default_when_not_set(self):
with patch.dict(os.environ, clear=True):
result = read_int_env("TEST_INT", default=999)
assert result == 999
def test_raises_error_for_invalid_int(self):
with patch.dict(os.environ, {"TEST_INT": "not_a_number"}):
with pytest.raises(ValueError) as exc_info:
read_int_env("TEST_INT", default=0)
assert "must be an integer" in str(exc_info.value)
def test_handles_negative_numbers(self):
with patch.dict(os.environ, {"TEST_INT": "-42"}):
result = read_int_env("TEST_INT", default=0)
assert result == -42
class TestReadBoolEnv:
def test_returns_true_for_true_string(self):
with patch.dict(os.environ, {"TEST_BOOL": "true"}):
result = read_bool_env("TEST_BOOL", default=False)
assert result is True
def test_returns_false_for_false_string(self):
with patch.dict(os.environ, {"TEST_BOOL": "false"}):
result = read_bool_env("TEST_BOOL", default=True)
assert result is False
def test_returns_true_from_file(self):
with tempfile.NamedTemporaryFile(mode="w", delete=True) as f:
f.write("true")
f.flush()
with patch.dict(os.environ, {"TEST_BOOL_FILE": f.name}):
result = read_bool_env("TEST_BOOL", default=False)
assert result is True
def test_returns_default_when_not_set(self):
with patch.dict(os.environ, clear=True):
result = read_bool_env("TEST_BOOL", default=True)
assert result is True
class TestReadFloatEnv:
def test_returns_float_from_direct_env(self):
with patch.dict(os.environ, {"TEST_FLOAT": "3.14"}):
result = read_float_env("TEST_FLOAT", default=0.0)
assert result == 3.14
def test_returns_float_from_file(self):
with tempfile.NamedTemporaryFile(mode="w", delete=True) as f:
f.write("2.718")
f.flush()
with patch.dict(os.environ, {"TEST_FLOAT_FILE": f.name}):
result = read_float_env("TEST_FLOAT", default=0.0)
assert result == 2.718
def test_returns_default_when_not_set(self):
with patch.dict(os.environ, clear=True):
result = read_float_env("TEST_FLOAT", default=9.99)
assert result == 9.99
def test_raises_error_for_invalid_float(self):
with patch.dict(os.environ, {"TEST_FLOAT": "not_a_number"}):
with pytest.raises(ValueError) as exc_info:
read_float_env("TEST_FLOAT", default=0.0)
assert "must be a float" in str(exc_info.value)
def test_handles_negative_numbers(self):
with patch.dict(os.environ, {"TEST_FLOAT": "-42.5"}):
result = read_float_env("TEST_FLOAT", default=0.0)
assert result == -42.5
def test_handles_integer_values(self):
with patch.dict(os.environ, {"TEST_FLOAT": "42"}):
result = read_float_env("TEST_FLOAT", default=0.0)
assert result == 42.0
def test_handles_zero(self):
with patch.dict(os.environ, {"TEST_FLOAT": "0"}):
result = read_float_env("TEST_FLOAT", default=1.0)
assert result == 0.0
@@ -0,0 +1,266 @@
import logging
from unittest.mock import Mock, patch
import pytest
pytest.importorskip("sentry_sdk")
from src.config.sentry_config import SentryConfig
from src.sentry import TaskRunnerSentry, setup_sentry
from src.constants import (
EXECUTOR_ALL_ITEMS_FILENAME,
EXECUTOR_PER_ITEM_FILENAME,
IGNORED_ERROR_TYPES,
LOG_SENTRY_MISSING,
SENTRY_TAG_SERVER_TYPE_KEY,
SENTRY_TAG_SERVER_TYPE_VALUE,
)
@pytest.fixture
def sentry_config():
return SentryConfig(
dsn="https://test@sentry.io/123456",
n8n_version="1.0.0",
environment="test",
deployment_name="test-deployment",
profiles_sample_rate=0,
traces_sample_rate=0,
)
@pytest.fixture
def disabled_sentry_config():
return SentryConfig(
dsn="",
n8n_version="1.0.0",
environment="test",
deployment_name="test-deployment",
profiles_sample_rate=0,
traces_sample_rate=0,
)
class TestTaskRunnerSentry:
def test_init_configures_sentry_correctly(self, sentry_config):
with (
patch("sentry_sdk.init") as mock_init,
patch("sentry_sdk.set_tag") as mock_set_tag,
patch("sentry_sdk.integrations.logging.LoggingIntegration") as mock_logging,
):
mock_logging_instance = Mock()
mock_logging.return_value = mock_logging_instance
sentry = TaskRunnerSentry(sentry_config)
sentry.init()
mock_init.assert_called_once_with(
dsn="https://test@sentry.io/123456",
release="n8n@1.0.0",
environment="test",
server_name="test-deployment",
before_send=sentry._filter_out_ignored_errors,
attach_stacktrace=True,
send_default_pii=False,
auto_enabling_integrations=False,
default_integrations=True,
integrations=[mock_logging_instance],
)
mock_set_tag.assert_called_once_with(
SENTRY_TAG_SERVER_TYPE_KEY, SENTRY_TAG_SERVER_TYPE_VALUE
)
def test_shutdown_flushes_sentry(self, sentry_config):
with patch("sentry_sdk.flush") as mock_flush:
sentry = TaskRunnerSentry(sentry_config)
sentry.shutdown()
mock_flush.assert_called_once_with(timeout=2.0)
@pytest.mark.parametrize(
"error_type",
IGNORED_ERROR_TYPES,
)
def test_filter_out_ignored_errors(self, sentry_config, error_type):
sentry = TaskRunnerSentry(sentry_config)
event = {"exception": {"values": []}}
hint = {"exc_info": (error_type, None, None)}
result = sentry._filter_out_ignored_errors(event, hint)
assert result is None
def test_filter_out_syntax_error_subclasses(self, sentry_config):
sentry = TaskRunnerSentry(sentry_config)
event = {"exception": {"values": []}}
hint = {"exc_info": (IndentationError, None, None)}
result = sentry._filter_out_ignored_errors(event, hint)
assert result is None
def test_filter_out_errors_by_type_name(self, sentry_config):
sentry = TaskRunnerSentry(sentry_config)
for ignored_type in IGNORED_ERROR_TYPES:
event = {
"exception": {
"values": [
{
"type": ignored_type.__name__,
"stacktrace": {"frames": [{"filename": "some_file.py"}]},
}
]
}
}
hint = {} # No exc_info, so it falls back to type name matching
result = sentry._filter_out_ignored_errors(event, hint)
assert result is None
def test_filter_out_user_code_errors_from_executors(self, sentry_config):
sentry = TaskRunnerSentry(sentry_config)
for executor_filename in [
EXECUTOR_ALL_ITEMS_FILENAME,
EXECUTOR_PER_ITEM_FILENAME,
]:
event = {
"exception": {
"values": [
{
"stacktrace": {
"frames": [
{"filename": "some_file.py"},
{"filename": executor_filename},
]
}
}
]
}
}
hint = {}
result = sentry._filter_out_ignored_errors(event, hint)
assert result is None
def test_allows_non_user_code_errors(self, sentry_config):
sentry = TaskRunnerSentry(sentry_config)
event = {
"exception": {
"values": [
{
"stacktrace": {
"frames": [
{"filename": "some_system_file.py"},
{"filename": "another_system_file.py"},
]
}
}
]
}
}
hint = {}
result = sentry._filter_out_ignored_errors(event, hint)
assert result == event
def test_handles_malformed_exception_data(self, sentry_config):
sentry = TaskRunnerSentry(sentry_config)
test_cases = [
{},
{"exception": {"values": []}},
{"exception": {"values": [{"type": "ValueError"}]}},
{"exception": {"values": [{"stacktrace": {}}]}},
{"exception": {"values": [{"stacktrace": {"frames": []}}]}},
]
for event in test_cases:
result = sentry._filter_out_ignored_errors(event, {})
assert result == event
class TestSetupSentry:
def test_returns_none_when_disabled(self, disabled_sentry_config):
result = setup_sentry(disabled_sentry_config)
assert result is None
@patch("src.sentry.TaskRunnerSentry")
def test_initializes_sentry_when_enabled(self, mock_sentry_class, sentry_config):
mock_sentry = Mock()
mock_sentry_class.return_value = mock_sentry
result = setup_sentry(sentry_config)
mock_sentry_class.assert_called_once_with(sentry_config)
mock_sentry.init.assert_called_once()
assert result == mock_sentry
@patch("src.sentry.TaskRunnerSentry")
def test_handles_import_error(self, mock_sentry_class, sentry_config, caplog):
mock_sentry = Mock()
mock_sentry.init.side_effect = ImportError("sentry_sdk not found")
mock_sentry_class.return_value = mock_sentry
with caplog.at_level(logging.WARNING):
result = setup_sentry(sentry_config)
assert result is None
assert LOG_SENTRY_MISSING in caplog.text
@patch("src.sentry.TaskRunnerSentry")
def test_handles_general_exception(self, mock_sentry_class, sentry_config, caplog):
mock_sentry = Mock()
mock_sentry.init.side_effect = Exception("Something went wrong")
mock_sentry_class.return_value = mock_sentry
with caplog.at_level(logging.WARNING):
result = setup_sentry(sentry_config)
assert result is None
assert "Failed to initialize Sentry: Something went wrong" in caplog.text
class TestSentryConfig:
def test_enabled_returns_true_with_dsn(self, sentry_config):
assert sentry_config.enabled is True
def test_enabled_returns_false_without_dsn(self, disabled_sentry_config):
assert disabled_sentry_config.enabled is False
@patch.dict(
"os.environ",
{
"N8N_SENTRY_DSN": "https://test@sentry.io/789",
"N8N_VERSION": "2.0.0",
"ENVIRONMENT": "production",
"DEPLOYMENT_NAME": "prod-deployment",
"N8N_SENTRY_PROFILES_SAMPLE_RATE": "0.5",
"N8N_SENTRY_TRACES_SAMPLE_RATE": "0.1",
},
)
def test_from_env_creates_config_from_environment(self):
config = SentryConfig.from_env()
assert config.dsn == "https://test@sentry.io/789"
assert config.n8n_version == "2.0.0"
assert config.environment == "production"
assert config.deployment_name == "prod-deployment"
assert config.profiles_sample_rate == 0.5
assert config.traces_sample_rate == 0.1
@patch.dict("os.environ", {}, clear=True)
def test_from_env_uses_defaults_when_missing(self):
config = SentryConfig.from_env()
assert config.dsn == ""
assert config.n8n_version == ""
assert config.environment == ""
assert config.deployment_name == ""
assert config.profiles_sample_rate == 0
assert config.traces_sample_rate == 0
@@ -0,0 +1,339 @@
import pytest
from src.errors.security_violation_error import SecurityViolationError
from src.task_analyzer import TaskAnalyzer
from src.config.security_config import SecurityConfig
from src.constants import BLOCKED_ATTRIBUTES, BLOCKED_NAMES
class TestTaskAnalyzer:
@pytest.fixture
def analyzer(self) -> TaskAnalyzer:
security_config = SecurityConfig(
stdlib_allow={
"json",
"math",
"re",
"datetime",
"random",
"string",
"collections",
"itertools",
"functools",
"operator",
},
external_allow=set(),
builtins_deny=set(),
runner_env_deny=True,
)
return TaskAnalyzer(security_config)
class TestImportValidation(TestTaskAnalyzer):
def test_allowed_standard_imports(self, analyzer: TaskAnalyzer) -> None:
valid_imports = [
"import json",
"import math",
"from datetime import datetime",
"from collections import Counter",
"import re as regex",
"from itertools import chain, cycle",
"from math import *",
]
for code in valid_imports:
analyzer.validate(code)
def test_blocked_dangerous_imports(self, analyzer: TaskAnalyzer) -> None:
dangerous_imports = [
"import os",
"import sys",
"import subprocess",
"from os import path",
"import socket",
]
for code in dangerous_imports:
with pytest.raises(SecurityViolationError):
analyzer.validate(code)
def test_blocked_relative_imports(self, analyzer: TaskAnalyzer) -> None:
relative_imports = [
"from . import module",
"from .. import parent_module",
"from ...package import something",
]
for code in relative_imports:
with pytest.raises(SecurityViolationError):
analyzer.validate(code)
class TestAttributeAccessValidation(TestTaskAnalyzer):
def test_all_blocked_attributes_are_blocked(self, analyzer: TaskAnalyzer) -> None:
for attr in BLOCKED_ATTRIBUTES:
code = f"obj.{attr}"
with pytest.raises(SecurityViolationError) as exc_info:
analyzer.validate(code)
assert attr in exc_info.value.description.lower()
def test_all_blocked_names_are_blocked(self, analyzer: TaskAnalyzer) -> None:
for name in BLOCKED_NAMES:
code = f"{name}"
with pytest.raises(SecurityViolationError) as exc_info:
analyzer.validate(code)
assert name in exc_info.value.description
def test_loader_access_attempts_blocked(self, analyzer: TaskAnalyzer) -> None:
exploit_attempts = [
"__loader__.load_module('posix')",
"posix = __loader__.load_module('posix'); posix.system('echo')",
"module = __loader__.load_module('os')",
]
for code in exploit_attempts:
with pytest.raises(SecurityViolationError) as exc_info:
analyzer.validate(code)
assert "__loader__" in exc_info.value.description
def test_spec_access_attempts_blocked(self, analyzer: TaskAnalyzer) -> None:
exploit_attempts = [
"__spec__.loader().load_module('posix')",
"posix = __spec__.loader().load_module('posix')",
"__spec__",
"loader = __spec__.loader()",
]
for code in exploit_attempts:
with pytest.raises(SecurityViolationError) as exc_info:
analyzer.validate(code)
assert "__spec__" in exc_info.value.description
def test_dunder_name_attempts_blocked(self, analyzer: TaskAnalyzer) -> None:
exploit_attempts = [
"sys.modules[__name__]",
"builtins_module = sys.modules[__name__]",
"sys.modules[__name__].open('/etc/passwd', 'r')",
"builtins_module = sys.modules[__name__]; unfiltered_open = builtins_module.open",
]
for code in exploit_attempts:
with pytest.raises(SecurityViolationError) as exc_info:
analyzer.validate(code)
assert "__name__" in exc_info.value.description
def test_allowed_attribute_access(self, analyzer: TaskAnalyzer) -> None:
allowed_attributes = [
"obj.value",
"data.items()",
"list.append(item)",
"dict.keys()",
"str.upper()",
"math.pi",
]
for code in allowed_attributes:
analyzer.validate(code)
def test_name_mangled_attributes_blocked(self, analyzer: TaskAnalyzer) -> None:
exploit_attempts = [
"license._Printer__filenames",
"obj._SomeClass__private_attr",
"help._Helper__name",
"credits._Printer__data",
"instance._MyClass__secret",
]
for code in exploit_attempts:
with pytest.raises(SecurityViolationError) as exc_info:
analyzer.validate(code)
assert "name-mangled" in exc_info.value.description.lower()
def test_objclass_attribute_blocked(self, analyzer: TaskAnalyzer) -> None:
exploit_attempts = [
"str.__or__.__objclass__",
"str.__init__.__objclass__",
"type_ref = str.__or__.__objclass__",
"object_ref = str.__init__.__objclass__",
]
for code in exploit_attempts:
with pytest.raises(SecurityViolationError) as exc_info:
analyzer.validate(code)
assert "__objclass__" in exc_info.value.description
def test_attribute_error_obj_blocked(self, analyzer: TaskAnalyzer) -> None:
exploit_attempts = [
"e.obj",
"exception.obj",
"error.obj",
]
for code in exploit_attempts:
with pytest.raises(SecurityViolationError) as exc_info:
analyzer.validate(code)
assert "obj" in exc_info.value.description
class TestDynamicImportDetection(TestTaskAnalyzer):
def test_various_dynamic_import_patterns(self, analyzer: TaskAnalyzer) -> None:
disallowed_dynamic_imports = [
"__import__('os')",
"import builtins; builtins.__import__('sys')",
"module_name = 'subprocess'; __import__(module_name)",
]
for code in disallowed_dynamic_imports:
with pytest.raises(SecurityViolationError):
analyzer.validate(code)
def test_allowed_modules_via_dynamic_import(self, analyzer: TaskAnalyzer) -> None:
allowed_dynamic_imports = [
"__import__('json')",
"__import__('math')",
"__import__('collections')",
"module = __import__('datetime')",
]
for code in allowed_dynamic_imports:
analyzer.validate(code)
class TestFormatStringAttacks(TestTaskAnalyzer):
def test_dangerous_format_patterns_blocked(self, analyzer: TaskAnalyzer) -> None:
dangerous_strings = [
# Attribute access patterns
'"{.__builtins__}".format(print)',
'"{.__class__}".format(obj)',
'"{.__globals__}".format(fn)',
'"{.__class__.__mro__}".format(obj)',
# Subscript access patterns
'"{.__builtins__[__import__]}".format(print)',
'"{[__import__]}".format(__builtins__)',
'fmt = "{.__class__}"',
'fmt = "{.__builtins__}"; fmt.format(obj)',
]
for code in dangerous_strings:
with pytest.raises(SecurityViolationError) as exc_info:
analyzer.validate(code)
assert "disallowed" in exc_info.value.description.lower()
def test_safe_format_strings_allowed(self, analyzer: TaskAnalyzer) -> None:
safe_format_strings = [
'"Hello {}".format(name)',
'"{0} {1}".format(a, b)',
'"{name}".format(name="world")',
'"{.value}".format(obj)',
'"{[key]}".format(data)',
'"{:.2f}".format(3.14159)',
]
for code in safe_format_strings:
analyzer.validate(code)
def test_escaped_braces_allowed(self, analyzer: TaskAnalyzer) -> None:
safe_escaped = [
'"{{.__class__}}".format()',
'"{{.__builtins__}}".format()',
'"{{.__globals__}}".format()',
]
for code in safe_escaped:
analyzer.validate(code)
def test_fstring_blocked_attributes_detected(self, analyzer: TaskAnalyzer) -> None:
exploit_attempts = [
'f"{obj.__class__}"',
'f"{fn.__globals__}"',
]
for code in exploit_attempts:
with pytest.raises(SecurityViolationError) as exc_info:
analyzer.validate(code)
assert "disallowed" in exc_info.value.description.lower()
class TestMatchPatternValidation(TestTaskAnalyzer):
def test_match_pattern_with_blocked_attributes_blocked(
self, analyzer: TaskAnalyzer
) -> None:
attempts = [
"""
ex = None
try:
pass
except Exception as e:
ex = e
match ex:
case AttributeError(obj=rip):
pass
""",
"""
match error:
case ValueError(obj=x):
pass
""",
"""
match e:
case Exception(__traceback__=tb):
pass
""",
]
for code in attempts:
with pytest.raises(SecurityViolationError) as exc_info:
analyzer.validate(code)
assert "disallowed" in exc_info.value.description.lower()
def test_safe_match_patterns_allowed(self, analyzer: TaskAnalyzer) -> None:
safe_patterns = [
"""
match value:
case 1:
pass
case "hello":
pass
""",
"""
match point:
case Point(x=x, y=y):
pass
""",
"""
match data:
case {"key": value}:
pass
""",
"""
match result:
case [first, *rest]:
pass
""",
]
for code in safe_patterns:
analyzer.validate(code)
class TestAllowAll(TestTaskAnalyzer):
def test_allow_all_bypasses_validation(self) -> None:
security_config = SecurityConfig(
stdlib_allow={"*"},
external_allow={"*"},
builtins_deny=set(),
runner_env_deny=True,
)
analyzer = TaskAnalyzer(security_config)
unsafe_allowed_code = [
"import os",
"import sys",
"__import__('subprocess')",
"obj.__subclasses__",
"from . import relative",
]
for code in unsafe_allowed_code:
analyzer.validate(code)
@@ -0,0 +1,198 @@
import pytest
import json
from unittest.mock import MagicMock, patch
from src.task_executor import TaskExecutor
from src.pipe_reader import PipeReader
from src.errors import TaskCancelledError, TaskKilledError, TaskSubprocessFailedError
from src.constants import SIGTERM_EXIT_CODE, SIGKILL_EXIT_CODE, PIPE_MSG_PREFIX_LENGTH
from src.message_types.pipe import (
PipeResultMessage,
PipeErrorMessage,
TaskErrorInfo,
)
class TestTaskExecutorProcessExitHandling:
def test_sigterm_raises_task_cancelled_error(self):
process = MagicMock()
process.is_alive.return_value = False
process.exitcode = SIGTERM_EXIT_CODE
read_conn = MagicMock()
write_conn = MagicMock()
read_conn.fileno.return_value = 999
with pytest.raises(TaskCancelledError):
TaskExecutor.execute_process(
process=process,
read_conn=read_conn,
write_conn=write_conn,
task_timeout=60,
continue_on_fail=False,
)
def test_sigkill_raises_task_killed_error(self):
process = MagicMock()
process.is_alive.return_value = False
process.exitcode = SIGKILL_EXIT_CODE
read_conn = MagicMock()
write_conn = MagicMock()
read_conn.fileno.return_value = 999
with pytest.raises(TaskKilledError):
TaskExecutor.execute_process(
process=process,
read_conn=read_conn,
write_conn=write_conn,
task_timeout=60,
continue_on_fail=False,
)
def test_other_non_zero_exit_code_raises_task_subprocess_failed_error(self):
process = MagicMock()
process.is_alive.return_value = False
process.exitcode = -1 # Some other error code
read_conn = MagicMock()
write_conn = MagicMock()
read_conn.fileno.return_value = 999
with pytest.raises(TaskSubprocessFailedError) as exc_info:
TaskExecutor.execute_process(
process=process,
read_conn=read_conn,
write_conn=write_conn,
task_timeout=60,
continue_on_fail=False,
)
assert exc_info.value.exit_code == -1
def test_zero_exit_code_with_empty_pipe_raises_task_result_read_error(self):
from src.errors import TaskResultReadError
process = MagicMock()
process.is_alive.return_value = False
process.exitcode = 0
read_conn = MagicMock()
write_conn = MagicMock()
read_conn.fileno.return_value = 999
with pytest.raises(TaskResultReadError):
TaskExecutor.execute_process(
process=process,
read_conn=read_conn,
write_conn=write_conn,
task_timeout=60,
continue_on_fail=False,
)
class TestTaskExecutorPipeCommunication:
@patch("os.read")
def test_successful_result_communication(self, mock_os_read):
result_data: PipeResultMessage = {
"result": [{"json": {"foo": "bar"}}],
"print_args": [],
}
result_json = json.dumps(result_data).encode("utf-8")
result_length = len(result_json).to_bytes(PIPE_MSG_PREFIX_LENGTH, "big")
mock_os_read.side_effect = [result_length, result_json]
process = MagicMock()
process.is_alive.return_value = False
process.exitcode = 0
read_conn = MagicMock()
write_conn = MagicMock()
read_conn.fileno.return_value = 999
result, print_args, size = TaskExecutor.execute_process(
process=process,
read_conn=read_conn,
write_conn=write_conn,
task_timeout=60,
continue_on_fail=False,
)
assert result == [{"json": {"foo": "bar"}}]
assert print_args == []
assert size == len(result_json)
@patch("os.read")
def test_successful_error_communication(self, mock_os_read):
from src.errors import TaskRuntimeError
error_info: TaskErrorInfo = {
"message": "Test error",
"description": "",
"stack": "traceback...",
"stderr": "",
}
error_data: PipeErrorMessage = {
"error": error_info,
"print_args": [],
}
error_json = json.dumps(error_data).encode("utf-8")
error_length = len(error_json).to_bytes(PIPE_MSG_PREFIX_LENGTH, "big")
mock_os_read.side_effect = [error_length, error_json]
process = MagicMock()
process.is_alive.return_value = False
process.exitcode = 0
read_conn = MagicMock()
write_conn = MagicMock()
read_conn.fileno.return_value = 999
with pytest.raises(TaskRuntimeError) as exc_info:
TaskExecutor.execute_process(
process=process,
read_conn=read_conn,
write_conn=write_conn,
task_timeout=60,
continue_on_fail=False,
)
assert str(exc_info.value) == "Test error"
assert exc_info.value.stack_trace == "traceback..."
class TestTaskExecutorLowLevelIO:
@patch("os.read")
def test_read_exact_bytes_single_read(self, mock_os_read):
data = b"test data"
mock_os_read.return_value = data
result = PipeReader._read_exact_bytes(999, len(data))
assert result == data
mock_os_read.assert_called_once_with(999, len(data))
@patch("os.read")
def test_read_exact_bytes_multiple_reads(self, mock_os_read):
mock_os_read.side_effect = [b"test", b" ", b"data"]
result = PipeReader._read_exact_bytes(999, 9)
assert result == b"test data"
assert mock_os_read.call_count == 3
@patch("os.read")
def test_read_exact_bytes_eof_error(self, mock_os_read):
mock_os_read.side_effect = [b"test", b""] # empty for EOF
with pytest.raises(EOFError, match="Pipe closed before reading all data"):
PipeReader._read_exact_bytes(999, 10)
@patch("os.write")
def test_write_bytes_write_failure(self, mock_os_write):
mock_os_write.return_value = 0
with pytest.raises(OSError, match="Write failed"):
TaskExecutor._write_bytes(999, b"test data")
@@ -0,0 +1,105 @@
import asyncio
import pytest
from unittest.mock import patch, Mock
from websockets.exceptions import InvalidStatus
from src.task_runner import TaskRunner
from src.config.task_runner_config import TaskRunnerConfig
class TestTaskRunnerConnectionRetry:
@pytest.fixture
def config(self):
return TaskRunnerConfig(
grant_token="test-token",
task_broker_uri="http://127.0.0.1:5679",
max_concurrency=5,
max_payload_size=1024 * 1024,
task_timeout=60,
auto_shutdown_timeout=0,
graceful_shutdown_timeout=10,
stdlib_allow={"*"},
external_allow={"*"},
builtins_deny=set(),
env_deny=False,
)
@pytest.mark.asyncio
async def test_connection_failure_logs_warning_not_crash(self, config):
runner = TaskRunner(config)
def connection_side_effect(*args, **kwargs):
if mock_connect.call_count >= 2:
runner.is_shutting_down = True
raise ConnectionRefusedError("Connection refused")
with (
patch("src.task_runner.websockets.connect") as mock_connect,
patch.object(runner, "logger") as mock_logger,
patch("src.task_runner.asyncio.sleep"),
):
mock_connect.side_effect = connection_side_effect
await runner.start()
assert mock_connect.call_count >= 2
mock_logger.warning.assert_called()
args = mock_logger.warning.call_args[0][0]
assert "Failed to connect to broker" in args
@pytest.mark.asyncio
async def test_auth_failure_raises_without_retry(self, config):
runner = TaskRunner(config)
with (
patch("src.task_runner.websockets.connect") as mock_connect,
patch.object(runner, "logger") as mock_logger,
):
mock_response = Mock()
mock_response.status_code = 403
auth_error = InvalidStatus(mock_response)
mock_connect.side_effect = auth_error
with pytest.raises(InvalidStatus):
await runner.start()
mock_logger.error.assert_called_once()
args = mock_logger.error.call_args[0][0]
assert "Authentication failed with status 403" in args
assert mock_connect.call_count == 1
class TestTaskRunnerDrain:
@pytest.fixture
def config(self):
return TaskRunnerConfig(
grant_token="test-token",
task_broker_uri="http://127.0.0.1:5679",
max_concurrency=5,
max_payload_size=1024 * 1024,
task_timeout=60,
auto_shutdown_timeout=0,
graceful_shutdown_timeout=10,
stdlib_allow={"*"},
external_allow={"*"},
builtins_deny=set(),
env_deny=False,
)
@pytest.mark.asyncio
async def test_drain_stops_sending_offers(self, config):
runner = TaskRunner(config)
runner.can_send_offers = True
async def wait_forever():
await asyncio.sleep(1000)
runner.offers_coroutine = asyncio.create_task(wait_forever())
await runner._handle_drain()
assert runner.can_send_offers is False
assert runner.offers_coroutine.cancelled()
+549
View File
@@ -0,0 +1,549 @@
version = 1
revision = 3
requires-python = ">=3.13"
[manifest]
constraints = [{ name = "urllib3", specifier = ">=2.6.3" }]
[[package]]
name = "aiohappyeyeballs"
version = "2.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" },
]
[[package]]
name = "aiohttp"
version = "3.12.15"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
{ name = "aiosignal" },
{ name = "attrs" },
{ name = "frozenlist" },
{ name = "multidict" },
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload-time = "2025-07-29T05:52:32.215Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/33/918091abcf102e39d15aba2476ad9e7bd35ddb190dcdd43a854000d3da0d/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315", size = 696741, upload-time = "2025-07-29T05:51:19.021Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2a/7495a81e39a998e400f3ecdd44a62107254803d1681d9189be5c2e4530cd/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd", size = 474407, upload-time = "2025-07-29T05:51:21.165Z" },
{ url = "https://files.pythonhosted.org/packages/49/fc/a9576ab4be2dcbd0f73ee8675d16c707cfc12d5ee80ccf4015ba543480c9/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4", size = 466703, upload-time = "2025-07-29T05:51:22.948Z" },
{ url = "https://files.pythonhosted.org/packages/09/2f/d4bcc8448cf536b2b54eed48f19682031ad182faa3a3fee54ebe5b156387/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7", size = 1705532, upload-time = "2025-07-29T05:51:25.211Z" },
{ url = "https://files.pythonhosted.org/packages/f1/f3/59406396083f8b489261e3c011aa8aee9df360a96ac8fa5c2e7e1b8f0466/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d", size = 1686794, upload-time = "2025-07-29T05:51:27.145Z" },
{ url = "https://files.pythonhosted.org/packages/dc/71/164d194993a8d114ee5656c3b7ae9c12ceee7040d076bf7b32fb98a8c5c6/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b", size = 1738865, upload-time = "2025-07-29T05:51:29.366Z" },
{ url = "https://files.pythonhosted.org/packages/1c/00/d198461b699188a93ead39cb458554d9f0f69879b95078dce416d3209b54/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d", size = 1788238, upload-time = "2025-07-29T05:51:31.285Z" },
{ url = "https://files.pythonhosted.org/packages/85/b8/9e7175e1fa0ac8e56baa83bf3c214823ce250d0028955dfb23f43d5e61fd/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d", size = 1710566, upload-time = "2025-07-29T05:51:33.219Z" },
{ url = "https://files.pythonhosted.org/packages/59/e4/16a8eac9df39b48ae102ec030fa9f726d3570732e46ba0c592aeeb507b93/aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645", size = 1624270, upload-time = "2025-07-29T05:51:35.195Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f8/cd84dee7b6ace0740908fd0af170f9fab50c2a41ccbc3806aabcb1050141/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461", size = 1677294, upload-time = "2025-07-29T05:51:37.215Z" },
{ url = "https://files.pythonhosted.org/packages/ce/42/d0f1f85e50d401eccd12bf85c46ba84f947a84839c8a1c2c5f6e8ab1eb50/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9", size = 1708958, upload-time = "2025-07-29T05:51:39.328Z" },
{ url = "https://files.pythonhosted.org/packages/d5/6b/f6fa6c5790fb602538483aa5a1b86fcbad66244997e5230d88f9412ef24c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d", size = 1651553, upload-time = "2025-07-29T05:51:41.356Z" },
{ url = "https://files.pythonhosted.org/packages/04/36/a6d36ad545fa12e61d11d1932eef273928b0495e6a576eb2af04297fdd3c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693", size = 1727688, upload-time = "2025-07-29T05:51:43.452Z" },
{ url = "https://files.pythonhosted.org/packages/aa/c8/f195e5e06608a97a4e52c5d41c7927301bf757a8e8bb5bbf8cef6c314961/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64", size = 1761157, upload-time = "2025-07-29T05:51:45.643Z" },
{ url = "https://files.pythonhosted.org/packages/05/6a/ea199e61b67f25ba688d3ce93f63b49b0a4e3b3d380f03971b4646412fc6/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51", size = 1710050, upload-time = "2025-07-29T05:51:48.203Z" },
{ url = "https://files.pythonhosted.org/packages/b4/2e/ffeb7f6256b33635c29dbed29a22a723ff2dd7401fff42ea60cf2060abfb/aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0", size = 422647, upload-time = "2025-07-29T05:51:50.718Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067, upload-time = "2025-07-29T05:51:52.549Z" },
]
[[package]]
name = "aiosignal"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "frozenlist" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
[[package]]
name = "attrs"
version = "25.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" },
]
[[package]]
name = "certifi"
version = "2025.8.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.10.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/14/70/025b179c993f019105b79575ac6edb5e084fb0f0e63f15cdebef4e454fb5/coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90", size = 823736, upload-time = "2025-08-29T15:35:16.668Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/e7/917e5953ea29a28c1057729c1d5af9084ab6d9c66217523fd0e10f14d8f6/coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6", size = 217351, upload-time = "2025-08-29T15:33:45.438Z" },
{ url = "https://files.pythonhosted.org/packages/eb/86/2e161b93a4f11d0ea93f9bebb6a53f113d5d6e416d7561ca41bb0a29996b/coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80", size = 217600, upload-time = "2025-08-29T15:33:47.269Z" },
{ url = "https://files.pythonhosted.org/packages/0e/66/d03348fdd8df262b3a7fb4ee5727e6e4936e39e2f3a842e803196946f200/coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003", size = 248600, upload-time = "2025-08-29T15:33:48.953Z" },
{ url = "https://files.pythonhosted.org/packages/73/dd/508420fb47d09d904d962f123221bc249f64b5e56aa93d5f5f7603be475f/coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27", size = 251206, upload-time = "2025-08-29T15:33:50.697Z" },
{ url = "https://files.pythonhosted.org/packages/e9/1f/9020135734184f439da85c70ea78194c2730e56c2d18aee6e8ff1719d50d/coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4", size = 252478, upload-time = "2025-08-29T15:33:52.303Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a4/3d228f3942bb5a2051fde28c136eea23a761177dc4ff4ef54533164ce255/coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d", size = 250637, upload-time = "2025-08-29T15:33:53.67Z" },
{ url = "https://files.pythonhosted.org/packages/36/e3/293dce8cdb9a83de971637afc59b7190faad60603b40e32635cbd15fbf61/coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc", size = 248529, upload-time = "2025-08-29T15:33:55.022Z" },
{ url = "https://files.pythonhosted.org/packages/90/26/64eecfa214e80dd1d101e420cab2901827de0e49631d666543d0e53cf597/coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc", size = 250143, upload-time = "2025-08-29T15:33:56.386Z" },
{ url = "https://files.pythonhosted.org/packages/3e/70/bd80588338f65ea5b0d97e424b820fb4068b9cfb9597fbd91963086e004b/coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e", size = 219770, upload-time = "2025-08-29T15:33:58.063Z" },
{ url = "https://files.pythonhosted.org/packages/a7/14/0b831122305abcc1060c008f6c97bbdc0a913ab47d65070a01dc50293c2b/coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32", size = 220566, upload-time = "2025-08-29T15:33:59.766Z" },
{ url = "https://files.pythonhosted.org/packages/83/c6/81a83778c1f83f1a4a168ed6673eeedc205afb562d8500175292ca64b94e/coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2", size = 219195, upload-time = "2025-08-29T15:34:01.191Z" },
{ url = "https://files.pythonhosted.org/packages/d7/1c/ccccf4bf116f9517275fa85047495515add43e41dfe8e0bef6e333c6b344/coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b", size = 218059, upload-time = "2025-08-29T15:34:02.91Z" },
{ url = "https://files.pythonhosted.org/packages/92/97/8a3ceff833d27c7492af4f39d5da6761e9ff624831db9e9f25b3886ddbca/coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393", size = 218287, upload-time = "2025-08-29T15:34:05.106Z" },
{ url = "https://files.pythonhosted.org/packages/92/d8/50b4a32580cf41ff0423777a2791aaf3269ab60c840b62009aec12d3970d/coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27", size = 259625, upload-time = "2025-08-29T15:34:06.575Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7e/6a7df5a6fb440a0179d94a348eb6616ed4745e7df26bf2a02bc4db72c421/coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df", size = 261801, upload-time = "2025-08-29T15:34:08.006Z" },
{ url = "https://files.pythonhosted.org/packages/3a/4c/a270a414f4ed5d196b9d3d67922968e768cd971d1b251e1b4f75e9362f75/coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb", size = 264027, upload-time = "2025-08-29T15:34:09.806Z" },
{ url = "https://files.pythonhosted.org/packages/9c/8b/3210d663d594926c12f373c5370bf1e7c5c3a427519a8afa65b561b9a55c/coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282", size = 261576, upload-time = "2025-08-29T15:34:11.585Z" },
{ url = "https://files.pythonhosted.org/packages/72/d0/e1961eff67e9e1dba3fc5eb7a4caf726b35a5b03776892da8d79ec895775/coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4", size = 259341, upload-time = "2025-08-29T15:34:13.159Z" },
{ url = "https://files.pythonhosted.org/packages/3a/06/d6478d152cd189b33eac691cba27a40704990ba95de49771285f34a5861e/coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21", size = 260468, upload-time = "2025-08-29T15:34:14.571Z" },
{ url = "https://files.pythonhosted.org/packages/ed/73/737440247c914a332f0b47f7598535b29965bf305e19bbc22d4c39615d2b/coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0", size = 220429, upload-time = "2025-08-29T15:34:16.394Z" },
{ url = "https://files.pythonhosted.org/packages/bd/76/b92d3214740f2357ef4a27c75a526eb6c28f79c402e9f20a922c295c05e2/coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5", size = 221493, upload-time = "2025-08-29T15:34:17.835Z" },
{ url = "https://files.pythonhosted.org/packages/fc/8e/6dcb29c599c8a1f654ec6cb68d76644fe635513af16e932d2d4ad1e5ac6e/coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b", size = 219757, upload-time = "2025-08-29T15:34:19.248Z" },
{ url = "https://files.pythonhosted.org/packages/d3/aa/76cf0b5ec00619ef208da4689281d48b57f2c7fde883d14bf9441b74d59f/coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e", size = 217331, upload-time = "2025-08-29T15:34:20.846Z" },
{ url = "https://files.pythonhosted.org/packages/65/91/8e41b8c7c505d398d7730206f3cbb4a875a35ca1041efc518051bfce0f6b/coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb", size = 217607, upload-time = "2025-08-29T15:34:22.433Z" },
{ url = "https://files.pythonhosted.org/packages/87/7f/f718e732a423d442e6616580a951b8d1ec3575ea48bcd0e2228386805e79/coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034", size = 248663, upload-time = "2025-08-29T15:34:24.425Z" },
{ url = "https://files.pythonhosted.org/packages/e6/52/c1106120e6d801ac03e12b5285e971e758e925b6f82ee9b86db3aa10045d/coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1", size = 251197, upload-time = "2025-08-29T15:34:25.906Z" },
{ url = "https://files.pythonhosted.org/packages/3d/ec/3a8645b1bb40e36acde9c0609f08942852a4af91a937fe2c129a38f2d3f5/coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a", size = 252551, upload-time = "2025-08-29T15:34:27.337Z" },
{ url = "https://files.pythonhosted.org/packages/a1/70/09ecb68eeb1155b28a1d16525fd3a9b65fbe75337311a99830df935d62b6/coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb", size = 250553, upload-time = "2025-08-29T15:34:29.065Z" },
{ url = "https://files.pythonhosted.org/packages/c6/80/47df374b893fa812e953b5bc93dcb1427a7b3d7a1a7d2db33043d17f74b9/coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d", size = 248486, upload-time = "2025-08-29T15:34:30.897Z" },
{ url = "https://files.pythonhosted.org/packages/4a/65/9f98640979ecee1b0d1a7164b589de720ddf8100d1747d9bbdb84be0c0fb/coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747", size = 249981, upload-time = "2025-08-29T15:34:32.365Z" },
{ url = "https://files.pythonhosted.org/packages/1f/55/eeb6603371e6629037f47bd25bef300387257ed53a3c5fdb159b7ac8c651/coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5", size = 220054, upload-time = "2025-08-29T15:34:34.124Z" },
{ url = "https://files.pythonhosted.org/packages/15/d1/a0912b7611bc35412e919a2cd59ae98e7ea3b475e562668040a43fb27897/coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713", size = 220851, upload-time = "2025-08-29T15:34:35.651Z" },
{ url = "https://files.pythonhosted.org/packages/ef/2d/11880bb8ef80a45338e0b3e0725e4c2d73ffbb4822c29d987078224fd6a5/coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32", size = 219429, upload-time = "2025-08-29T15:34:37.16Z" },
{ url = "https://files.pythonhosted.org/packages/83/c0/1f00caad775c03a700146f55536ecd097a881ff08d310a58b353a1421be0/coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65", size = 218080, upload-time = "2025-08-29T15:34:38.919Z" },
{ url = "https://files.pythonhosted.org/packages/a9/c4/b1c5d2bd7cc412cbeb035e257fd06ed4e3e139ac871d16a07434e145d18d/coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6", size = 218293, upload-time = "2025-08-29T15:34:40.425Z" },
{ url = "https://files.pythonhosted.org/packages/3f/07/4468d37c94724bf6ec354e4ec2f205fda194343e3e85fd2e59cec57e6a54/coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0", size = 259800, upload-time = "2025-08-29T15:34:41.996Z" },
{ url = "https://files.pythonhosted.org/packages/82/d8/f8fb351be5fee31690cd8da768fd62f1cfab33c31d9f7baba6cd8960f6b8/coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e", size = 261965, upload-time = "2025-08-29T15:34:43.61Z" },
{ url = "https://files.pythonhosted.org/packages/e8/70/65d4d7cfc75c5c6eb2fed3ee5cdf420fd8ae09c4808723a89a81d5b1b9c3/coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5", size = 264220, upload-time = "2025-08-29T15:34:45.387Z" },
{ url = "https://files.pythonhosted.org/packages/98/3c/069df106d19024324cde10e4ec379fe2fb978017d25e97ebee23002fbadf/coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7", size = 261660, upload-time = "2025-08-29T15:34:47.288Z" },
{ url = "https://files.pythonhosted.org/packages/fc/8a/2974d53904080c5dc91af798b3a54a4ccb99a45595cc0dcec6eb9616a57d/coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5", size = 259417, upload-time = "2025-08-29T15:34:48.779Z" },
{ url = "https://files.pythonhosted.org/packages/30/38/9616a6b49c686394b318974d7f6e08f38b8af2270ce7488e879888d1e5db/coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0", size = 260567, upload-time = "2025-08-29T15:34:50.718Z" },
{ url = "https://files.pythonhosted.org/packages/76/16/3ed2d6312b371a8cf804abf4e14895b70e4c3491c6e53536d63fd0958a8d/coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7", size = 220831, upload-time = "2025-08-29T15:34:52.653Z" },
{ url = "https://files.pythonhosted.org/packages/d5/e5/d38d0cb830abede2adb8b147770d2a3d0e7fecc7228245b9b1ae6c24930a/coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930", size = 221950, upload-time = "2025-08-29T15:34:54.212Z" },
{ url = "https://files.pythonhosted.org/packages/f4/51/e48e550f6279349895b0ffcd6d2a690e3131ba3a7f4eafccc141966d4dea/coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b", size = 219969, upload-time = "2025-08-29T15:34:55.83Z" },
{ url = "https://files.pythonhosted.org/packages/44/0c/50db5379b615854b5cf89146f8f5bd1d5a9693d7f3a987e269693521c404/coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3", size = 208986, upload-time = "2025-08-29T15:35:14.506Z" },
]
[[package]]
name = "frozenlist"
version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" },
{ url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" },
{ url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" },
{ url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" },
{ url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" },
{ url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" },
{ url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" },
{ url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" },
{ url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" },
{ url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" },
{ url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" },
{ url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" },
{ url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" },
{ url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" },
{ url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" },
{ url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" },
{ url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" },
{ url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" },
{ url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" },
{ url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" },
{ url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" },
{ url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" },
{ url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" },
{ url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" },
{ url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" },
{ url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" },
{ url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" },
{ url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" },
{ url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" },
{ url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" },
{ url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" },
{ url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" },
{ url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" },
{ url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" },
{ url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
]
[[package]]
name = "multidict"
version = "6.6.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843, upload-time = "2025-08-11T12:08:48.217Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/5d/e1db626f64f60008320aab00fbe4f23fc3300d75892a3381275b3d284580/multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e", size = 75848, upload-time = "2025-08-11T12:07:19.912Z" },
{ url = "https://files.pythonhosted.org/packages/4c/aa/8b6f548d839b6c13887253af4e29c939af22a18591bfb5d0ee6f1931dae8/multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657", size = 45060, upload-time = "2025-08-11T12:07:21.163Z" },
{ url = "https://files.pythonhosted.org/packages/eb/c6/f5e97e5d99a729bc2aa58eb3ebfa9f1e56a9b517cc38c60537c81834a73f/multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da", size = 43269, upload-time = "2025-08-11T12:07:22.392Z" },
{ url = "https://files.pythonhosted.org/packages/dc/31/d54eb0c62516776f36fe67f84a732f97e0b0e12f98d5685bebcc6d396910/multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa", size = 237158, upload-time = "2025-08-11T12:07:23.636Z" },
{ url = "https://files.pythonhosted.org/packages/c4/1c/8a10c1c25b23156e63b12165a929d8eb49a6ed769fdbefb06e6f07c1e50d/multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f", size = 257076, upload-time = "2025-08-11T12:07:25.049Z" },
{ url = "https://files.pythonhosted.org/packages/ad/86/90e20b5771d6805a119e483fd3d1e8393e745a11511aebca41f0da38c3e2/multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0", size = 240694, upload-time = "2025-08-11T12:07:26.458Z" },
{ url = "https://files.pythonhosted.org/packages/e7/49/484d3e6b535bc0555b52a0a26ba86e4d8d03fd5587d4936dc59ba7583221/multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879", size = 266350, upload-time = "2025-08-11T12:07:27.94Z" },
{ url = "https://files.pythonhosted.org/packages/bf/b4/aa4c5c379b11895083d50021e229e90c408d7d875471cb3abf721e4670d6/multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a", size = 267250, upload-time = "2025-08-11T12:07:29.303Z" },
{ url = "https://files.pythonhosted.org/packages/80/e5/5e22c5bf96a64bdd43518b1834c6d95a4922cc2066b7d8e467dae9b6cee6/multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f", size = 254900, upload-time = "2025-08-11T12:07:30.764Z" },
{ url = "https://files.pythonhosted.org/packages/17/38/58b27fed927c07035abc02befacab42491e7388ca105e087e6e0215ead64/multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5", size = 252355, upload-time = "2025-08-11T12:07:32.205Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a1/dad75d23a90c29c02b5d6f3d7c10ab36c3197613be5d07ec49c7791e186c/multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438", size = 250061, upload-time = "2025-08-11T12:07:33.623Z" },
{ url = "https://files.pythonhosted.org/packages/b8/1a/ac2216b61c7f116edab6dc3378cca6c70dc019c9a457ff0d754067c58b20/multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e", size = 249675, upload-time = "2025-08-11T12:07:34.958Z" },
{ url = "https://files.pythonhosted.org/packages/d4/79/1916af833b800d13883e452e8e0977c065c4ee3ab7a26941fbfdebc11895/multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7", size = 261247, upload-time = "2025-08-11T12:07:36.588Z" },
{ url = "https://files.pythonhosted.org/packages/c5/65/d1f84fe08ac44a5fc7391cbc20a7cedc433ea616b266284413fd86062f8c/multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812", size = 257960, upload-time = "2025-08-11T12:07:39.735Z" },
{ url = "https://files.pythonhosted.org/packages/13/b5/29ec78057d377b195ac2c5248c773703a6b602e132a763e20ec0457e7440/multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a", size = 250078, upload-time = "2025-08-11T12:07:41.525Z" },
{ url = "https://files.pythonhosted.org/packages/c4/0e/7e79d38f70a872cae32e29b0d77024bef7834b0afb406ddae6558d9e2414/multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69", size = 41708, upload-time = "2025-08-11T12:07:43.405Z" },
{ url = "https://files.pythonhosted.org/packages/9d/34/746696dffff742e97cd6a23da953e55d0ea51fa601fa2ff387b3edcfaa2c/multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf", size = 45912, upload-time = "2025-08-11T12:07:45.082Z" },
{ url = "https://files.pythonhosted.org/packages/c7/87/3bac136181e271e29170d8d71929cdeddeb77f3e8b6a0c08da3a8e9da114/multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605", size = 43076, upload-time = "2025-08-11T12:07:46.746Z" },
{ url = "https://files.pythonhosted.org/packages/64/94/0a8e63e36c049b571c9ae41ee301ada29c3fee9643d9c2548d7d558a1d99/multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb", size = 82812, upload-time = "2025-08-11T12:07:48.402Z" },
{ url = "https://files.pythonhosted.org/packages/25/1a/be8e369dfcd260d2070a67e65dd3990dd635cbd735b98da31e00ea84cd4e/multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e", size = 48313, upload-time = "2025-08-11T12:07:49.679Z" },
{ url = "https://files.pythonhosted.org/packages/26/5a/dd4ade298674b2f9a7b06a32c94ffbc0497354df8285f27317c66433ce3b/multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f", size = 46777, upload-time = "2025-08-11T12:07:51.318Z" },
{ url = "https://files.pythonhosted.org/packages/89/db/98aa28bc7e071bfba611ac2ae803c24e96dd3a452b4118c587d3d872c64c/multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773", size = 229321, upload-time = "2025-08-11T12:07:52.965Z" },
{ url = "https://files.pythonhosted.org/packages/c7/bc/01ddda2a73dd9d167bd85d0e8ef4293836a8f82b786c63fb1a429bc3e678/multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e", size = 249954, upload-time = "2025-08-11T12:07:54.423Z" },
{ url = "https://files.pythonhosted.org/packages/06/78/6b7c0f020f9aa0acf66d0ab4eb9f08375bac9a50ff5e3edb1c4ccd59eafc/multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0", size = 228612, upload-time = "2025-08-11T12:07:55.914Z" },
{ url = "https://files.pythonhosted.org/packages/00/44/3faa416f89b2d5d76e9d447296a81521e1c832ad6e40b92f990697b43192/multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395", size = 257528, upload-time = "2025-08-11T12:07:57.371Z" },
{ url = "https://files.pythonhosted.org/packages/05/5f/77c03b89af0fcb16f018f668207768191fb9dcfb5e3361a5e706a11db2c9/multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45", size = 256329, upload-time = "2025-08-11T12:07:58.844Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e9/ed750a2a9afb4f8dc6f13dc5b67b514832101b95714f1211cd42e0aafc26/multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb", size = 247928, upload-time = "2025-08-11T12:08:01.037Z" },
{ url = "https://files.pythonhosted.org/packages/1f/b5/e0571bc13cda277db7e6e8a532791d4403dacc9850006cb66d2556e649c0/multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5", size = 245228, upload-time = "2025-08-11T12:08:02.96Z" },
{ url = "https://files.pythonhosted.org/packages/f3/a3/69a84b0eccb9824491f06368f5b86e72e4af54c3067c37c39099b6687109/multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141", size = 235869, upload-time = "2025-08-11T12:08:04.746Z" },
{ url = "https://files.pythonhosted.org/packages/a9/9d/28802e8f9121a6a0804fa009debf4e753d0a59969ea9f70be5f5fdfcb18f/multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d", size = 243446, upload-time = "2025-08-11T12:08:06.332Z" },
{ url = "https://files.pythonhosted.org/packages/38/ea/6c98add069b4878c1d66428a5f5149ddb6d32b1f9836a826ac764b9940be/multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d", size = 252299, upload-time = "2025-08-11T12:08:07.931Z" },
{ url = "https://files.pythonhosted.org/packages/3a/09/8fe02d204473e14c0af3affd50af9078839dfca1742f025cca765435d6b4/multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0", size = 246926, upload-time = "2025-08-11T12:08:09.467Z" },
{ url = "https://files.pythonhosted.org/packages/37/3d/7b1e10d774a6df5175ecd3c92bff069e77bed9ec2a927fdd4ff5fe182f67/multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92", size = 243383, upload-time = "2025-08-11T12:08:10.981Z" },
{ url = "https://files.pythonhosted.org/packages/50/b0/a6fae46071b645ae98786ab738447de1ef53742eaad949f27e960864bb49/multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e", size = 47775, upload-time = "2025-08-11T12:08:12.439Z" },
{ url = "https://files.pythonhosted.org/packages/b2/0a/2436550b1520091af0600dff547913cb2d66fbac27a8c33bc1b1bccd8d98/multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4", size = 53100, upload-time = "2025-08-11T12:08:13.823Z" },
{ url = "https://files.pythonhosted.org/packages/97/ea/43ac51faff934086db9c072a94d327d71b7d8b40cd5dcb47311330929ef0/multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad", size = 45501, upload-time = "2025-08-11T12:08:15.173Z" },
{ url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "propcache"
version = "0.3.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" },
{ url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" },
{ url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" },
{ url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" },
{ url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" },
{ url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" },
{ url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" },
{ url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" },
{ url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" },
{ url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" },
{ url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" },
{ url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" },
{ url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" },
{ url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" },
{ url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" },
{ url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" },
{ url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" },
{ url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" },
{ url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" },
{ url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" },
{ url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" },
{ url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" },
{ url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" },
{ url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" },
{ url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" },
{ url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" },
{ url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" },
{ url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" },
{ url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" },
{ url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "8.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" },
]
[[package]]
name = "pytest-cov"
version = "6.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload-time = "2025-06-12T10:47:47.684Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" },
]
[[package]]
name = "ruff"
version = "0.14.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" },
{ url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" },
{ url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" },
{ url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" },
{ url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" },
{ url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" },
{ url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" },
{ url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" },
{ url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" },
{ url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" },
{ url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" },
{ url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" },
{ url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" },
{ url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" },
{ url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" },
{ url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" },
{ url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" },
]
[[package]]
name = "sentry-sdk"
version = "2.35.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bd/79/0ecb942f3f1ad26c40c27f81ff82392d85c01d26a45e3c72c2b37807e680/sentry_sdk-2.35.2.tar.gz", hash = "sha256:e9e8f3c795044beb59f2c8f4c6b9b0f9779e5e604099882df05eec525e782cc6", size = 343377, upload-time = "2025-09-01T11:00:58.633Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/91/a43308dc82a0e32d80cd0dfdcfca401ecbd0f431ab45f24e48bb97b7800d/sentry_sdk-2.35.2-py2.py3-none-any.whl", hash = "sha256:38c98e3cbb620dd3dd80a8d6e39c753d453dd41f8a9df581b0584c19a52bc926", size = 363975, upload-time = "2025-09-01T11:00:56.574Z" },
]
[[package]]
name = "setuptools"
version = "80.10.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" },
]
[[package]]
name = "task-runner-python"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "websockets" },
]
[package.optional-dependencies]
sentry = [
{ name = "sentry-sdk" },
]
[package.dev-dependencies]
dev = [
{ name = "aiohttp" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "ruff" },
{ name = "setuptools" },
{ name = "ty" },
]
[package.metadata]
requires-dist = [
{ name = "sentry-sdk", marker = "extra == 'sentry'", specifier = ">=2.35.2" },
{ name = "websockets", specifier = ">=15.0.1" },
]
provides-extras = ["sentry"]
[package.metadata.requires-dev]
dev = [
{ name = "aiohttp", specifier = ">=3.10.0" },
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.24.0" },
{ name = "pytest-cov", specifier = ">=5.0.0" },
{ name = "ruff", specifier = ">=0.14.10" },
{ name = "setuptools", specifier = ">=80.10.2" },
{ name = "ty", specifier = ">=0.0.5" },
]
[[package]]
name = "ty"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9e/db/6299d478000f4f1c6f9bf2af749359381610ffc4cbe6713b66e436ecf6e7/ty-0.0.5.tar.gz", hash = "sha256:983da6330773ff71e2b249810a19c689f9a0372f6e21bbf7cde37839d05b4346", size = 4806218, upload-time = "2025-12-20T21:19:17.24Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/98/c1f61ba378b4191e641bb36c07b7fcc70ff844d61be7a4bf2fea7472b4a9/ty-0.0.5-py3-none-linux_armv6l.whl", hash = "sha256:1594cd9bb68015eb2f5a3c68a040860f3c9306dc6667d7a0e5f4df9967b460e2", size = 9785554, upload-time = "2025-12-20T21:19:05.024Z" },
{ url = "https://files.pythonhosted.org/packages/ab/f9/b37b77c03396bd779c1397dae4279b7ad79315e005b3412feed8812a4256/ty-0.0.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7c0140ba980233d28699d9ddfe8f43d0b3535d6a3bbff9935df625a78332a3cf", size = 9603995, upload-time = "2025-12-20T21:19:15.256Z" },
{ url = "https://files.pythonhosted.org/packages/7d/70/4e75c11903b0e986c0203040472627cb61d6a709e1797fb08cdf9d565743/ty-0.0.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:15de414712cde92048ae4b1a77c4dc22920bd23653fe42acaf73028bad88f6b9", size = 9145815, upload-time = "2025-12-20T21:19:36.481Z" },
{ url = "https://files.pythonhosted.org/packages/89/05/93983dfcf871a41dfe58e5511d28e6aa332a1f826cc67333f77ae41a2f8a/ty-0.0.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:438aa51ad6c5fae64191f8d58876266e26f9250cf09f6624b6af47a22fa88618", size = 9619849, upload-time = "2025-12-20T21:19:19.084Z" },
{ url = "https://files.pythonhosted.org/packages/82/b6/896ab3aad59f846823f202e94be6016fb3f72434d999d2ae9bd0f28b3af9/ty-0.0.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b3d373fd96af1564380caf153600481c676f5002ee76ba8a7c3508cdff82ee0", size = 9606611, upload-time = "2025-12-20T21:19:24.583Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ae/098e33fc92330285ed843e2750127e896140c4ebd2d73df7732ea496f588/ty-0.0.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8453692503212ad316cf8b99efbe85a91e5f63769c43be5345e435a1b16cba5a", size = 10029523, upload-time = "2025-12-20T21:19:07.055Z" },
{ url = "https://files.pythonhosted.org/packages/04/5a/f4b4c33758b9295e9aca0de9645deca0f4addd21d38847228723a6e780fc/ty-0.0.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2e4c454139473abbd529767b0df7a795ed828f780aef8d0d4b144558c0dc4446", size = 10870892, upload-time = "2025-12-20T21:19:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c5/4e3e7e88389365aa1e631c99378711cf0c9d35a67478cb4720584314cf44/ty-0.0.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:426d4f3b82475b1ec75f3cc9ee5a667c8a4ae8441a09fcd8e823a53b706d00c7", size = 10599291, upload-time = "2025-12-20T21:19:26.557Z" },
{ url = "https://files.pythonhosted.org/packages/c1/5d/138f859ea87bd95e17b9818e386ae25a910e46521c41d516bf230ed83ffc/ty-0.0.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5710817b67c6b2e4c0224e4f319b7decdff550886e9020f6d46aa1ce8f89a609", size = 10413515, upload-time = "2025-12-20T21:19:11.094Z" },
{ url = "https://files.pythonhosted.org/packages/27/21/1cbcd0d3b1182172f099e88218137943e0970603492fb10c7c9342369d9a/ty-0.0.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23c55ef08882c7c5ced1ccb90b4eeefa97f690aea254f58ac0987896c590f76", size = 10144992, upload-time = "2025-12-20T21:19:13.225Z" },
{ url = "https://files.pythonhosted.org/packages/ad/30/fdac06a5470c09ad2659a0806497b71f338b395d59e92611f71b623d05a0/ty-0.0.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b9e4c1a28a23b14cf8f4f793f4da396939f16c30bfa7323477c8cc234e352ac4", size = 9606408, upload-time = "2025-12-20T21:19:09.212Z" },
{ url = "https://files.pythonhosted.org/packages/09/93/e99dcd7f53295192d03efd9cbcec089a916f49cad4935c0160ea9adbd53d/ty-0.0.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4e9ebb61529b9745af662e37c37a01ad743cdd2c95f0d1421705672874d806cd", size = 9630040, upload-time = "2025-12-20T21:19:38.165Z" },
{ url = "https://files.pythonhosted.org/packages/d7/f8/6d1e87186e4c35eb64f28000c1df8fd5f73167ce126c5e3dd21fd1204a23/ty-0.0.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5eb191a8e332f50f56dfe45391bdd7d43dd4ef6e60884710fd7ce84c5d8c1eb5", size = 9754016, upload-time = "2025-12-20T21:19:32.79Z" },
{ url = "https://files.pythonhosted.org/packages/28/e6/20f989342cb3115852dda404f1d89a10a3ce93f14f42b23f095a3d1a00c9/ty-0.0.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:92ed7451a1e82ee134a2c24ca43b74dd31e946dff2b08e5c34473e6b051de542", size = 10252877, upload-time = "2025-12-20T21:19:20.787Z" },
{ url = "https://files.pythonhosted.org/packages/57/9d/fc66fa557443233dfad9ae197ff3deb70ae0efcfb71d11b30ef62f5cdcc3/ty-0.0.5-py3-none-win32.whl", hash = "sha256:71f6707e4c1c010c158029a688a498220f28bb22fdb6707e5c20e09f11a5e4f2", size = 9212640, upload-time = "2025-12-20T21:19:30.817Z" },
{ url = "https://files.pythonhosted.org/packages/68/b6/05c35f6dea29122e54af0e9f8dfedd0a100c721affc8cc801ebe2bc2ed13/ty-0.0.5-py3-none-win_amd64.whl", hash = "sha256:2b8b754a0d7191e94acdf0c322747fec34371a4d0669f5b4e89549aef28814ae", size = 10034701, upload-time = "2025-12-20T21:19:28.311Z" },
{ url = "https://files.pythonhosted.org/packages/df/ca/4201ed5cb2af73912663d0c6ded927c28c28b3c921c9348aa8d2cfef4853/ty-0.0.5-py3-none-win_arm64.whl", hash = "sha256:83bea5a5296caac20d52b790ded2b830a7ff91c4ed9f36730fe1f393ceed6654", size = 9566474, upload-time = "2025-12-20T21:19:22.518Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "websockets"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]
[[package]]
name = "yarl"
version = "1.20.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" },
{ url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" },
{ url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" },
{ url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" },
{ url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" },
{ url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" },
{ url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" },
{ url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" },
{ url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" },
{ url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" },
{ url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" },
{ url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" },
{ url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" },
{ url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" },
{ url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" },
{ url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" },
{ url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" },
{ url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" },
{ url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" },
{ url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" },
{ url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" },
{ url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" },
{ url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" },
{ url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" },
{ url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" },
{ url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" },
{ url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" },
{ url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" },
{ url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" },
{ url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" },
{ url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" },
{ url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" },
]