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
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:
@@ -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.
|
||||
@@ -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()
|
||||
Generated
+549
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user