diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..3adcdfd --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,32 @@ +name: Tests + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.14 + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Run ruff + run: uv run ruff check . + + - name: Run ruff format check + run: uv run ruff format --check . + + - name: Run tests + run: uv run pytest diff --git a/.gitignore b/.gitignore index b7faf40..09be0ef 100644 --- a/.gitignore +++ b/.gitignore @@ -182,9 +182,9 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5585110 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-added-large-files + - id: check-merge-conflict + - id: check-case-conflict + - id: debug-statements + - id: mixed-line-ending + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.6 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: local + hooks: + - id: pytest + name: pytest + entry: uv run pytest + language: system + types: [python] + pass_filenames: false + always_run: true diff --git a/examples/deploy.sh b/examples/deploy.sh new file mode 100755 index 0000000..e935172 --- /dev/null +++ b/examples/deploy.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# >>> fetch-runner-guard:BEGIN user=deploy +if [ "$(whoami)" != "deploy" ] || [ "$(id -u)" -eq 0 ]; then + printf 'fetch-runner-guard: refusing to run as %s (uid %s); required: deploy, non-root\n' "$(whoami)" "$(id -u)" >&2 + exit 1 +fi +# <<< fetch-runner-guard:END + +set -euo pipefail + +# This script is designed to run identically whether fetch-runner invoked it +# on a new commit or a human invoked it from a terminal. The guard above is +# the only invariant: the caller must be user=deploy and not root. + +cd "$(dirname -- "$0")" + +echo "deploying $(basename -- "$PWD")" + +# Your deploy steps here, e.g. a docker compose rollout: +# docker compose pull +# docker compose up --detach --remove-orphans +# docker image prune --force diff --git a/examples/fetch-runner.service b/examples/fetch-runner.service new file mode 100644 index 0000000..4393399 --- /dev/null +++ b/examples/fetch-runner.service @@ -0,0 +1,90 @@ +[Unit] +Description=fetch-runner: poll git branches and run scripts on new commits +Documentation=https://github.com/BYU-ODH/fetch-runner +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple + +# =========================================================================== +# CUSTOMIZE: edit the lines in this block for each deployment. +# =========================================================================== + +# MUST match the `user` field in the jobs.toml this unit starts. +# fetch-runner exits at startup if they do not match. +User=deploy +Group=deploy + +# Path to the fetch-runner binary and to the config file it should load. +# If you installed fetch-runner from a venv, point ExecStart at that venv's +# bin/fetch-runner. The config path is positional. +ExecStart=/usr/local/bin/fetch-runner /etc/fetch-runner/jobs.toml + +# Every directory your deploy scripts need to write to must appear here +# (the git repos you poll, plus any app state they touch). Separate with +# spaces. The rest of the filesystem is read-only thanks to ProtectSystem. +ReadWritePaths=/srv + +# =========================================================================== +# DO NOT MODIFY below this line casually. +# These settings are the sandbox that keeps a long-running deploy service from +# having broad access to the host. If you must relax one, document why. +# =========================================================================== + +Restart=on-failure +RestartSec=10s +TimeoutStopSec=30s + +# Send both fetch-runner logs and deploy-script output to journald so there is +# one place to inspect failures. +StandardOutput=journal +StandardError=journal + +# Block privilege escalation; the deploy user never needs more than it has. +NoNewPrivileges=true +RestrictSUIDSGID=true +CapabilityBoundingSet= +AmbientCapabilities= + +# Keep the filesystem read-only by default, then punch narrow write holes back +# in with `ReadWritePaths=` above for the repos and app state that deployments +# genuinely need to modify. +ProtectSystem=strict + +# Deploy scripts should not need the service user's home directory. +ProtectHome=read-only + +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true + +# Prevent deploy code from reconfiguring host control groups. If your rollout +# tool genuinely needs cgroup access, revisit this deliberately. +ProtectControlGroups=true + +ProtectClock=true +ProtectHostname=true + +# Hide unrelated processes from the service; deploy hooks should not be +# inspecting the rest of the machine. +ProtectProc=invisible + +# Kernel / namespace hardening. +LockPersonality=true +RestrictRealtime=true + +# Prevent deploy code from creating new namespaces inside the sandbox. +RestrictNamespaces=true + +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 + +# Syscall filter. +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM + +[Install] +WantedBy=multi-user.target diff --git a/examples/jobs.toml b/examples/jobs.toml new file mode 100644 index 0000000..bac6528 --- /dev/null +++ b/examples/jobs.toml @@ -0,0 +1,20 @@ +# Sample fetch-runner config. +# fetch-runner will refuse to load this unless the process is running as the +# user named below (and not as root). + +[general] +user = "deploy" +poll_interval_seconds = 60 + +[[jobs]] +name = "my example api" +path = "/srv/api" +branch = "main" +script = "/srv/api/deploy.sh" +timeout_seconds = 600 + +[[jobs]] +name = "my example web" +path = "/srv/web" +branch = "production" +script = "/srv/web/deploy.sh" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..65a8c23 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["uv_build>=0.4.8,<100"] +build-backend = "uv_build" + +[project] +name = "fetch-runner" +version = "0.1.0" +description = "Poll git branches and run scripts when new commits arrive" +readme = "README.md" +requires-python = ">=3.11" +license = { file = "LICENSE" } +authors = [{ name = "BYU ODH" }] +dependencies = [] + +[project.scripts] +fetch-runner = "fetch_runner.cli:main" + +[tool.uv.build-backend] +module-name = "fetch_runner" +module-root = "src" + +[dependency-groups] +dev = [ + "pytest>=8", + "pre-commit>=3", + "ruff>=0.8", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" + +[tool.ruff] +line-length = 100 +target-version = "py311" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "I", "W", "UP", "B"] + +[tool.ruff.lint.isort] +known-first-party = ["fetch_runner"] +section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] +force-single-line = true +force-sort-within-sections = false diff --git a/src/fetch_runner/__init__.py b/src/fetch_runner/__init__.py new file mode 100644 index 0000000..d5c062d --- /dev/null +++ b/src/fetch_runner/__init__.py @@ -0,0 +1,7 @@ +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _version + +try: + __version__ = _version("fetch-runner") +except PackageNotFoundError: + __version__ = "0.0.0+unknown" diff --git a/src/fetch_runner/__main__.py b/src/fetch_runner/__main__.py new file mode 100644 index 0000000..fb771ef --- /dev/null +++ b/src/fetch_runner/__main__.py @@ -0,0 +1,4 @@ +from fetch_runner.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/fetch_runner/cli.py b/src/fetch_runner/cli.py new file mode 100644 index 0000000..591d511 --- /dev/null +++ b/src/fetch_runner/cli.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import argparse +import logging +import signal +import sys +from pathlib import Path + +from fetch_runner import __version__ +from fetch_runner.config import ConfigError +from fetch_runner.config import load_config +from fetch_runner.guard import GuardError +from fetch_runner.guard import render_canonical_script_guard +from fetch_runner.runner import GitPollingRunner + +log = logging.getLogger("fetch_runner") + + +def main(argv: list[str] | None = None) -> int: + argument_parser = argparse.ArgumentParser( + prog="fetch-runner", + description="Poll git branches and run scripts when new commits arrive.", + ) + argument_parser.add_argument("config", type=Path, nargs="?", help="path to jobs.toml") + argument_parser.add_argument( + "--check", + action="store_true", + help="validate the config (including every script's guard) and exit", + ) + argument_parser.add_argument( + "--print-guard", + metavar="USER", + help="print the canonical guard block for USER and exit (for pasting into a new script)", + ) + argument_parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="enable debug logging", + ) + argument_parser.add_argument( + "--version", + action="version", + version=f"fetch-runner {__version__}", + ) + cli_args = argument_parser.parse_args(argv) + + _configure_logging(cli_args.verbose) + + if cli_args.print_guard: + try: + sys.stdout.write(render_canonical_script_guard(cli_args.print_guard)) + except GuardError as e: + print(f"error: {e}", file=sys.stderr) + return 2 + return 0 + + if cli_args.config is None: + argument_parser.error("config path is required (or use --print-guard)") + + try: + runner_config = load_config(cli_args.config) + except ConfigError as e: + print(f"config error: {e}", file=sys.stderr) + return 2 + + if cli_args.check: + print( + f"ok: user={runner_config.runtime_user} " + f"jobs={len(runner_config.jobs)} " + f"poll={runner_config.poll_interval_seconds}s" + ) + return 0 + + runner = GitPollingRunner(runner_config) + + def _handle_stop_signal(signum, _frame): + log.info("received signal %s; shutting down", signum) + runner.request_stop() + + signal.signal(signal.SIGTERM, _handle_stop_signal) + signal.signal(signal.SIGINT, _handle_stop_signal) + return runner.run_forever() + + +def _configure_logging(verbose: bool) -> None: + logging.basicConfig( + level=logging.DEBUG if verbose else logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S%z", + stream=sys.stderr, + ) diff --git a/src/fetch_runner/config.py b/src/fetch_runner/config.py new file mode 100644 index 0000000..80f35ac --- /dev/null +++ b/src/fetch_runner/config.py @@ -0,0 +1,232 @@ +"""jobs.toml loader with strict validation. + +Loading a config is a security decision: if any check fails, we raise +``ConfigError`` and refuse to start rather than skip the job. The expected +operational pattern is ``fetch-runner --check jobs.toml`` as part of any +deploy, so that config errors surface before a restart. +""" + +from __future__ import annotations + +import os +import stat +import tomllib +from dataclasses import dataclass +from pathlib import Path + +from fetch_runner.guard import GuardError +from fetch_runner.guard import require_expected_runtime_user +from fetch_runner.guard import validate_canonical_script_guard + + +class ConfigError(Exception): + pass + + +@dataclass(frozen=True) +class ConfiguredJob: + name: str + repo_path: Path + branch_name: str + script_path: Path + script_timeout_seconds: int | None + + +@dataclass(frozen=True) +class RunnerConfig: + runtime_user: str + poll_interval_seconds: int + jobs: tuple[ConfiguredJob, ...] + + +_ALLOWED_TOP_LEVEL_KEYS = {"general", "jobs"} +_ALLOWED_GENERAL_KEYS = {"user", "poll_interval_seconds"} +_ALLOWED_JOB_KEYS = {"name", "path", "branch", "script", "timeout_seconds"} + +_DISALLOWED_BRANCH_CHARACTERS = frozenset(" \t\n\r\x00'\";|&`$<>()[]{}\\*?") + + +def load_config(config_path: Path) -> RunnerConfig: + try: + config_text = config_path.read_text() + except OSError as e: + raise ConfigError(f"cannot read {config_path}: {e}") from e + try: + parsed_toml = tomllib.loads(config_text) + except tomllib.TOMLDecodeError as e: + raise ConfigError(f"invalid TOML in {config_path}: {e}") from e + + _reject_unknown_keys(parsed_toml, _ALLOWED_TOP_LEVEL_KEYS, f"{config_path}: top-level") + + general_section = parsed_toml.get("general") + if not isinstance(general_section, dict): + raise ConfigError(f"{config_path}: missing [general] section") + _reject_unknown_keys(general_section, _ALLOWED_GENERAL_KEYS, f"{config_path}: [general]") + + runtime_user = _require_non_empty_string( + general_section, + "user", + "[general]", + config_path, + ) + poll_interval_seconds = _require_integer_at_least( + general_section, + "poll_interval_seconds", + "[general]", + config_path, + minimum=1, + ) + + # Enforce user match before doing anything else: an operator who dropped + # a jobs.toml for the wrong service account should see an immediate + # error, not have individual jobs quietly skipped. + try: + require_expected_runtime_user(runtime_user) + except GuardError as e: + raise ConfigError(f"{config_path}: {e}") from e + + raw_job_sections = parsed_toml.get("jobs") + if not isinstance(raw_job_sections, list) or not raw_job_sections: + raise ConfigError(f"{config_path}: at least one [[jobs]] entry is required") + + seen_names: set[str] = set() + seen_repo_paths: set[Path] = set() + configured_jobs: list[ConfiguredJob] = [] + for job_index, raw_job_section in enumerate(raw_job_sections): + section_label = f"[[jobs]] #{job_index}" + if not isinstance(raw_job_section, dict): + raise ConfigError(f"{config_path}: {section_label} is not a table") + _reject_unknown_keys(raw_job_section, _ALLOWED_JOB_KEYS, f"{config_path}: {section_label}") + + job_name = _require_non_empty_string(raw_job_section, "name", section_label, config_path) + if job_name in seen_names: + raise ConfigError(f"{config_path}: duplicate job name {job_name!r}") + seen_names.add(job_name) + + # Resolve early so duplicate-path detection is based on the real target + # path, not on whatever relative spelling happened to appear in TOML. + repo_path = Path( + _require_non_empty_string(raw_job_section, "path", section_label, config_path) + ).resolve() + # Only one job may own a worktree. Two jobs resetting the same checkout + # to different commits would create non-deterministic deploy behavior. + if repo_path in seen_repo_paths: + raise ConfigError(f"{config_path}: duplicate job path {repo_path}") + seen_repo_paths.add(repo_path) + if not (repo_path / ".git").exists(): + raise ConfigError( + f"{config_path}: {section_label}.path {repo_path} is not a git repository" + ) + + branch_name = _require_non_empty_string( + raw_job_section, + "branch", + section_label, + config_path, + ) + # Branch names are passed as argv entries, but git still interprets + # leading dashes and a wide range of refname syntax. A conservative + # character filter keeps the allowed surface area easy to reason about. + if branch_name.startswith("-") or any( + char in _DISALLOWED_BRANCH_CHARACTERS for char in branch_name + ): + raise ConfigError( + f"{config_path}: {section_label}.branch contains unsafe characters: {branch_name!r}" + ) + if len(branch_name) > 128: + raise ConfigError(f"{config_path}: {section_label}.branch too long") + + script_path = Path( + _require_non_empty_string(raw_job_section, "script", section_label, config_path) + ).resolve() + _validate_job_script_file(script_path, runtime_user, section_label, config_path) + + script_timeout_seconds = raw_job_section.get("timeout_seconds") + if script_timeout_seconds is not None: + if ( + not isinstance(script_timeout_seconds, int) + or isinstance(script_timeout_seconds, bool) + or script_timeout_seconds <= 0 + ): + raise ConfigError( + f"{config_path}: {section_label}.timeout_seconds must be a positive integer" + ) + + configured_jobs.append( + ConfiguredJob( + name=job_name, + repo_path=repo_path, + branch_name=branch_name, + script_path=script_path, + script_timeout_seconds=script_timeout_seconds, + ) + ) + + return RunnerConfig( + runtime_user=runtime_user, + poll_interval_seconds=poll_interval_seconds, + jobs=tuple(configured_jobs), + ) + + +def _validate_job_script_file( + script_path: Path, + runtime_user: str, + section_label: str, + config_path: Path, +) -> None: + """Run the startup-time script checks. + + This is intentionally duplicated later in the runner after checkout. The + load-time check catches bad deployments before the service starts; the + post-checkout check catches a newly fetched commit that changed the script. + """ + if not script_path.is_file(): + raise ConfigError(f"{config_path}: {section_label}.script {script_path} does not exist") + if not os.access(script_path, os.X_OK): + raise ConfigError(f"{config_path}: {section_label}.script {script_path} is not executable") + script_stat = script_path.stat() + if script_stat.st_mode & stat.S_IWOTH: + raise ConfigError( + f"{config_path}: {section_label}.script {script_path} is world-writable; refusing" + ) + guard_validation = validate_canonical_script_guard(script_path, runtime_user) + if not guard_validation.is_valid: + raise ConfigError( + f"{config_path}: {section_label}.script failed guard validation: " + f"{guard_validation.error_reason}" + ) + + +def _reject_unknown_keys(raw_section: dict, allowed_keys: set[str], section_label: str) -> None: + unknown_keys = set(raw_section) - allowed_keys + if unknown_keys: + raise ConfigError(f"{section_label}: unknown keys {sorted(unknown_keys)!r}") + + +def _require_non_empty_string( + raw_section: dict, + key: str, + section_label: str, + config_path: Path, +) -> str: + value = raw_section.get(key) + if not isinstance(value, str) or not value: + raise ConfigError(f"{config_path}: {section_label}.{key} must be a non-empty string") + return value + + +def _require_integer_at_least( + raw_section: dict, + key: str, + section_label: str, + config_path: Path, + *, + minimum: int, +) -> int: + value = raw_section.get(key) + if not isinstance(value, int) or isinstance(value, bool): + raise ConfigError(f"{config_path}: {section_label}.{key} must be an integer") + if value < minimum: + raise ConfigError(f"{config_path}: {section_label}.{key} must be >= {minimum}") + return value diff --git a/src/fetch_runner/git_ops.py b/src/fetch_runner/git_ops.py new file mode 100644 index 0000000..b5c835a --- /dev/null +++ b/src/fetch_runner/git_ops.py @@ -0,0 +1,70 @@ +"""Minimal ``git`` wrappers. + +The runner intentionally uses a very small, audited subset of ``git``. +Subprocess calls use an argv list (never ``shell=True``); repo paths are +passed with ``-C`` and branch names have already been validated against a +conservative allowlist at config load. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +class GitError(Exception): + pass + + +def _run_git_command(repo_path: Path, *git_args: str, timeout: float = 120) -> str: + try: + result = subprocess.run( + ["git", "-C", str(repo_path), *git_args], + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except FileNotFoundError as e: + raise GitError("git executable not found in PATH") from e + except subprocess.TimeoutExpired as e: + raise GitError(f"git {' '.join(git_args)} in {repo_path} timed out after {timeout}s") from e + if result.returncode != 0: + error_output = (result.stderr or result.stdout).strip() + raise GitError(f"git {' '.join(git_args)} in {repo_path} failed: {error_output}") + return result.stdout.strip() + + +def git_get_local_branch_commit_sha(repo_path: Path, branch_name: str) -> str: + """Return the commit the local ``branch_name`` ref points at, or ``""`` if missing.""" + try: + return _run_git_command(repo_path, "rev-parse", "--verify", f"refs/heads/{branch_name}") + except GitError: + return "" + + +def git_fetch_branch_from_origin( + repo_path: Path, + branch_name: str, + timeout: float = 120, +) -> str: + """Fetch ``origin/`` and return the resulting ``FETCH_HEAD`` SHA.""" + _run_git_command( + repo_path, + "fetch", + "--quiet", + "--no-tags", + "origin", + branch_name, + timeout=timeout, + ) + return _run_git_command(repo_path, "rev-parse", "FETCH_HEAD") + + +def git_force_checkout_branch_to_commit( + repo_path: Path, + branch_name: str, + commit_sha: str, +) -> None: + """Force the local ``branch_name`` ref and working tree to ``commit_sha``.""" + _run_git_command(repo_path, "checkout", "--quiet", "--force", "-B", branch_name, commit_sha) diff --git a/src/fetch_runner/guard.py b/src/fetch_runner/guard.py new file mode 100644 index 0000000..2c34185 --- /dev/null +++ b/src/fetch_runner/guard.py @@ -0,0 +1,168 @@ +"""Runtime-user and script-guard enforcement. + +Two jobs: + +1. At runtime, verify fetch-runner itself is executing as the configured user + and not as root. The check is based on the real UID (``os.getuid``) and the + passwd database, so ``$USER`` / ``$LOGNAME`` / utmp cannot influence it. + +2. Before a job script runs, verify the script contains a canonical guard + block near the top. The block is matched by exact bytes, so a weakened + or removed check is detected. + +The guard is written in portable POSIX shell, so it works under both +``#!/bin/sh`` and ``#!/bin/bash`` (and anything else that speaks POSIX +``test``/``printf``). +""" + +from __future__ import annotations + +import os +import pwd +from dataclasses import dataclass +from pathlib import Path + +GUARD_BEGIN_MARKER_PREFIX = "# >>> fetch-runner-guard:BEGIN" +GUARD_END_MARKER = "# <<< fetch-runner-guard:END" + +# Keep the guard as a literal byte template. The validator compares a script's +# bytes against the rendered output exactly, so "helpful" rewrites do not +# silently weaken the check. +_GUARD_TEMPLATE = ( + "# >>> fetch-runner-guard:BEGIN user={user}\n" + 'if [ "$(whoami)" != "{user}" ] || [ "$(id -u)" -eq 0 ]; then\n' + " printf 'fetch-runner-guard: refusing to run as %s (uid %s);" + ' required: {user}, non-root\\n\' "$(whoami)" "$(id -u)" >&2\n' + " exit 1\n" + "fi\n" + f"{GUARD_END_MARKER}\n" +) + + +class GuardError(Exception): + pass + + +@dataclass(frozen=True) +class ScriptGuardValidation: + is_valid: bool + error_reason: str = "" + + +def render_canonical_script_guard(user_name: str) -> str: + """Return the canonical guard block for ``user`` (trailing newline included).""" + _require_safe_user_name(user_name) + return _GUARD_TEMPLATE.format(user=user_name) + + +def get_current_real_uid_user_name() -> str: + """Return the login name of the real UID. + + Uses ``pwd.getpwuid(os.getuid())`` rather than ``$USER`` or ``whoami`` so + the answer cannot be spoofed via environment variables or utmp. + """ + real_uid = os.getuid() + try: + return pwd.getpwuid(real_uid).pw_name + except KeyError as e: + raise GuardError(f"cannot resolve current uid {real_uid} to a user name") from e + + +def require_expected_runtime_user(expected_user_name: str) -> None: + """Raise ``GuardError`` unless the process is running as ``expected`` and non-root.""" + _require_safe_user_name(expected_user_name) + actual_user_name = get_current_real_uid_user_name() + if actual_user_name != expected_user_name: + raise GuardError( + f"runtime user {actual_user_name!r} does not match configured user " + f"{expected_user_name!r}" + ) + if os.getuid() == 0: + raise GuardError("refusing to run as root (uid 0)") + + +def validate_canonical_script_guard( + script_path: Path, + expected_user_name: str, +) -> ScriptGuardValidation: + """Check that ``script`` begins with the canonical guard for ``user``. + + Allowed before the guard: an optional shebang on line 1, blank lines, and + ``#``-comment lines. Anything else (including ``set -e``) is rejected: + the guard must be the first code that runs. + """ + _require_safe_user_name(expected_user_name) + try: + script_text = script_path.read_text() + except OSError as e: + return ScriptGuardValidation(False, f"cannot read script {script_path}: {e}") + + script_lines = script_text.splitlines() + current_line_index = 0 + if script_lines and script_lines[0].startswith("#!"): + current_line_index = 1 + + expected_guard_begin_marker = f"# >>> fetch-runner-guard:BEGIN user={expected_user_name}" + while current_line_index < len(script_lines): + stripped_line = script_lines[current_line_index].strip() + if stripped_line == expected_guard_begin_marker: + break + if stripped_line.startswith(GUARD_BEGIN_MARKER_PREFIX): + return ScriptGuardValidation( + False, + f"{script_path}:{current_line_index + 1}: guard BEGIN marker targets a different " + f"user (expected {expected_user_name!r}); found {stripped_line!r}", + ) + # Allow only comments before the guard. Even benign shell code like + # `set -e` can change behavior before the identity check runs. + if stripped_line == "" or stripped_line.startswith("#"): + current_line_index += 1 + continue + return ScriptGuardValidation( + False, + f"{script_path}:{current_line_index + 1}: guard must come before any executable " + f"code; found {script_lines[current_line_index]!r}", + ) + else: + return ScriptGuardValidation( + False, + f"{script_path}: canonical guard block for user {expected_user_name!r} not found", + ) + + expected_guard_lines = render_canonical_script_guard(expected_user_name).splitlines() + for guard_line_offset, expected_line in enumerate(expected_guard_lines): + line_number = current_line_index + guard_line_offset + 1 + if current_line_index + guard_line_offset >= len(script_lines): + return ScriptGuardValidation( + False, + f"{script_path}:{line_number}: guard block truncated; expected {expected_line!r}", + ) + actual_line = script_lines[current_line_index + guard_line_offset] + if actual_line != expected_line: + return ScriptGuardValidation( + False, + f"{script_path}:{line_number}: guard block mismatch; " + f"expected {expected_line!r}, got {actual_line!r}", + ) + return ScriptGuardValidation(True) + + +# Conservative allowlist; keeps us safe if a user name is ever interpolated +# into shell text. POSIX permits a broader set but this covers real-world +# Linux account names. +_ALLOWED_USER_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-") + + +def _require_safe_user_name(user_name: str) -> None: + if not isinstance(user_name, str) or not user_name: + raise GuardError("user name must be a non-empty string") + if len(user_name) > 32: + raise GuardError(f"user name too long: {user_name!r}") + if user_name.startswith("-"): + raise GuardError(f"user name may not start with '-': {user_name!r}") + disallowed_characters = [char for char in user_name if char not in _ALLOWED_USER_CHARS] + if disallowed_characters: + raise GuardError( + f"user name contains disallowed characters {sorted(set(disallowed_characters))!r}: " + f"{user_name!r}" + ) diff --git a/src/fetch_runner/runner.py b/src/fetch_runner/runner.py new file mode 100644 index 0000000..3b136ae --- /dev/null +++ b/src/fetch_runner/runner.py @@ -0,0 +1,183 @@ +"""Polling loop. + +Per job, on each tick: + 1. ``git fetch origin `` and read ``FETCH_HEAD``. + 2. If the SHA differs from the last one we acted on, re-validate the + script's guard (in case the new commit tampered with it), hard-reset + the working tree to that SHA, and run the script. + 3. Update the in-memory cursor whether the script succeeded or failed — + a failing commit should not be re-run in a tight loop. + +The initial cursor is the current local branch SHA, so restarting the +service does not replay the last deploy. +""" + +from __future__ import annotations + +import logging +import os +import subprocess +import threading + +from fetch_runner.config import ConfiguredJob +from fetch_runner.config import RunnerConfig +from fetch_runner.git_ops import GitError +from fetch_runner.git_ops import git_fetch_branch_from_origin +from fetch_runner.git_ops import git_force_checkout_branch_to_commit +from fetch_runner.git_ops import git_get_local_branch_commit_sha +from fetch_runner.guard import validate_canonical_script_guard + +log = logging.getLogger("fetch_runner") + + +class GitPollingRunner: + def __init__(self, runner_config: RunnerConfig) -> None: + self.runner_config = runner_config + self._last_processed_commit_by_job_name: dict[str, str] = {} + self._stop_requested = threading.Event() + + def request_stop(self) -> None: + self._stop_requested.set() + + def run_forever(self) -> int: + self._initialize_last_processed_commits() + log.info( + "fetch-runner started: user=%s jobs=%d poll=%ss", + self.runner_config.runtime_user, + len(self.runner_config.jobs), + self.runner_config.poll_interval_seconds, + ) + while not self._stop_requested.is_set(): + for configured_job in self.runner_config.jobs: + if self._stop_requested.is_set(): + break + try: + self._poll_job_for_new_commit(configured_job) + except Exception: + log.exception("job %s: unexpected error", configured_job.name) + self._stop_requested.wait(self.runner_config.poll_interval_seconds) + log.info("fetch-runner stopped") + return 0 + + def _initialize_last_processed_commits(self) -> None: + for configured_job in self.runner_config.jobs: + try: + # Seed each job from the current local branch tip so a service + # restart does not replay the last successfully fetched commit. + initial_commit_sha = git_get_local_branch_commit_sha( + configured_job.repo_path, + configured_job.branch_name, + ) + except GitError as e: + log.warning( + "job %s: cannot read initial commit for %s: %s", + configured_job.name, + configured_job.branch_name, + e, + ) + initial_commit_sha = "" + self._last_processed_commit_by_job_name[configured_job.name] = initial_commit_sha + log.info( + "job %s: initial commit %s", + configured_job.name, + _short_commit_sha(initial_commit_sha), + ) + + def _poll_job_for_new_commit(self, configured_job: ConfiguredJob) -> None: + try: + fetched_commit_sha = git_fetch_branch_from_origin( + configured_job.repo_path, + configured_job.branch_name, + ) + except GitError as e: + log.warning("job %s: fetch failed: %s", configured_job.name, e) + return + last_processed_commit_sha = self._last_processed_commit_by_job_name.get( + configured_job.name, + "", + ) + if fetched_commit_sha == last_processed_commit_sha: + log.debug( + "job %s: no change (%s)", + configured_job.name, + _short_commit_sha(fetched_commit_sha), + ) + return + log.info( + "job %s: new commit %s -> %s", + configured_job.name, + _short_commit_sha(last_processed_commit_sha) or "", + _short_commit_sha(fetched_commit_sha), + ) + try: + git_force_checkout_branch_to_commit( + configured_job.repo_path, + configured_job.branch_name, + fetched_commit_sha, + ) + except GitError as e: + log.error("job %s: checkout failed: %s", configured_job.name, e) + return + # Re-validate after checkout because the fetched commit controls the + # script bytes on disk. A config-time pass only proves the *previous* + # checkout was safe. + guard_validation = validate_canonical_script_guard( + configured_job.script_path, + self.runner_config.runtime_user, + ) + if not guard_validation.is_valid: + log.error( + "job %s: script at %s failed guard check after checkout: %s", + configured_job.name, + fetched_commit_sha, + guard_validation.error_reason, + ) + # Record the bad commit so the service does not hammer the same + # broken revision forever. Recovery should be an intentional human + # action, not an automatic tight loop. + self._last_processed_commit_by_job_name[configured_job.name] = fetched_commit_sha + return + self._run_job_script_for_commit(configured_job, fetched_commit_sha) + self._last_processed_commit_by_job_name[configured_job.name] = fetched_commit_sha + + def _run_job_script_for_commit( + self, + configured_job: ConfiguredJob, + commit_sha: str, + ) -> None: + # Export execution context so scripts can log or branch on it without + # having to re-run git commands against the working tree. + script_environment = { + **os.environ, + "FETCH_RUNNER_JOB": configured_job.name, + "FETCH_RUNNER_BRANCH": configured_job.branch_name, + "FETCH_RUNNER_COMMIT": commit_sha, + "FETCH_RUNNER_REPO": str(configured_job.repo_path), + } + log.info("job %s: running %s", configured_job.name, configured_job.script_path) + try: + completed_process = subprocess.run( + [str(configured_job.script_path)], + cwd=configured_job.repo_path, + env=script_environment, + timeout=configured_job.script_timeout_seconds, + check=False, + ) + except subprocess.TimeoutExpired: + log.error( + "job %s: script timed out after %ss", + configured_job.name, + configured_job.script_timeout_seconds, + ) + return + except OSError as e: + log.error("job %s: cannot execute script: %s", configured_job.name, e) + return + if completed_process.returncode == 0: + log.info("job %s: script succeeded", configured_job.name) + else: + log.error("job %s: script exited %d", configured_job.name, completed_process.returncode) + + +def _short_commit_sha(commit_sha: str) -> str: + return commit_sha[:12] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..9dde878 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +import os +import stat +from pathlib import Path + +import pytest + +from fetch_runner.config import ConfigError +from fetch_runner.config import load_config +from fetch_runner.guard import get_current_real_uid_user_name +from fetch_runner.guard import render_canonical_script_guard + + +@pytest.fixture(autouse=True) +def _not_running_as_root(): + if os.getuid() == 0: + pytest.skip("config tests require a non-root test user") + + +def _create_repo_directory(repo_path: Path) -> Path: + repo_path.mkdir(parents=True, exist_ok=True) + (repo_path / ".git").mkdir() + return repo_path + + +def _create_guarded_script(script_path: Path, user_name: str) -> Path: + script_path.write_text("#!/bin/bash\n" + render_canonical_script_guard(user_name) + "echo hi\n") + script_path.chmod(0o755) + return script_path + + +def _write_jobs_toml(config_path: Path, config_body: str) -> Path: + config_path.write_text(config_body) + return config_path + + +def _write_minimal_valid_jobs_toml( + tmp_path: Path, + *, + user_name: str, + extra_job_lines: str = "", +) -> Path: + repo_path = _create_repo_directory(tmp_path / "repo") + script_path = _create_guarded_script(tmp_path / "deploy.sh", user_name) + return _write_jobs_toml( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user_name}" +poll_interval_seconds = 30 + +[[jobs]] +name = "j1" +path = "{repo_path}" +branch = "main" +script = "{script_path}" +{extra_job_lines} +""", + ) + + +def test_load_happy_path(tmp_path: Path): + user_name = get_current_real_uid_user_name() + config_path = _write_minimal_valid_jobs_toml(tmp_path, user_name=user_name) + runner_config = load_config(config_path) + assert runner_config.runtime_user == user_name + assert runner_config.poll_interval_seconds == 30 + assert len(runner_config.jobs) == 1 + assert runner_config.jobs[0].name == "j1" + assert runner_config.jobs[0].branch_name == "main" + assert runner_config.jobs[0].script_timeout_seconds is None + + +def test_load_rejects_wrong_user(tmp_path: Path): + # A jobs.toml whose user does not match the running user must be refused. + repo_path = _create_repo_directory(tmp_path / "repo") + script_path = _create_guarded_script(tmp_path / "deploy.sh", "someone-else-xyz") + config_path = _write_jobs_toml( + tmp_path / "jobs.toml", + f""" +[general] +user = "someone-else-xyz" +poll_interval_seconds = 5 + +[[jobs]] +name = "j1" +path = "{repo_path}" +branch = "main" +script = "{script_path}" +""", + ) + with pytest.raises(ConfigError, match="runtime user"): + load_config(config_path) + + +def test_load_rejects_unknown_top_level_key(tmp_path: Path): + user_name = get_current_real_uid_user_name() + repo_path = _create_repo_directory(tmp_path / "repo") + script_path = _create_guarded_script(tmp_path / "deploy.sh", user_name) + config_path = _write_jobs_toml( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user_name}" +poll_interval_seconds = 5 + +[extras] +something = 1 + +[[jobs]] +name = "j1" +path = "{repo_path}" +branch = "main" +script = "{script_path}" +""", + ) + with pytest.raises(ConfigError, match="unknown keys"): + load_config(config_path) + + +def test_load_rejects_unknown_job_key(tmp_path: Path): + config_path = _write_minimal_valid_jobs_toml( + tmp_path, + user_name=get_current_real_uid_user_name(), + extra_job_lines='command = "rm -rf /"', + ) + with pytest.raises(ConfigError, match="unknown keys"): + load_config(config_path) + + +def test_load_rejects_missing_script(tmp_path: Path): + user_name = get_current_real_uid_user_name() + repo_path = _create_repo_directory(tmp_path / "repo") + config_path = _write_jobs_toml( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user_name}" +poll_interval_seconds = 5 + +[[jobs]] +name = "j1" +path = "{repo_path}" +branch = "main" +script = "{tmp_path}/nope.sh" +""", + ) + with pytest.raises(ConfigError, match="does not exist"): + load_config(config_path) + + +def test_load_rejects_non_git_path(tmp_path: Path): + user_name = get_current_real_uid_user_name() + script_path = _create_guarded_script(tmp_path / "deploy.sh", user_name) + not_a_repo = tmp_path / "not_a_repo" + not_a_repo.mkdir() + config_path = _write_jobs_toml( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user_name}" +poll_interval_seconds = 5 + +[[jobs]] +name = "j1" +path = "{not_a_repo}" +branch = "main" +script = "{script_path}" +""", + ) + with pytest.raises(ConfigError, match="not a git repository"): + load_config(config_path) + + +def test_load_rejects_world_writable_script(tmp_path: Path): + user_name = get_current_real_uid_user_name() + repo_path = _create_repo_directory(tmp_path / "repo") + script_path = _create_guarded_script(tmp_path / "deploy.sh", user_name) + script_path.chmod(script_path.stat().st_mode | stat.S_IWOTH) + config_path = _write_jobs_toml( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user_name}" +poll_interval_seconds = 5 + +[[jobs]] +name = "j1" +path = "{repo_path}" +branch = "main" +script = "{script_path}" +""", + ) + with pytest.raises(ConfigError, match="world-writable"): + load_config(config_path) + + +def test_load_rejects_non_executable_script(tmp_path: Path): + user_name = get_current_real_uid_user_name() + repo_path = _create_repo_directory(tmp_path / "repo") + script_path = _create_guarded_script(tmp_path / "deploy.sh", user_name) + script_path.chmod(0o644) + config_path = _write_jobs_toml( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user_name}" +poll_interval_seconds = 5 + +[[jobs]] +name = "j1" +path = "{repo_path}" +branch = "main" +script = "{script_path}" +""", + ) + with pytest.raises(ConfigError, match="not executable"): + load_config(config_path) + + +def test_load_rejects_script_without_guard(tmp_path: Path): + user_name = get_current_real_uid_user_name() + repo_path = _create_repo_directory(tmp_path / "repo") + script_path = tmp_path / "deploy.sh" + script_path.write_text("#!/bin/bash\necho hi\n") + script_path.chmod(0o755) + config_path = _write_jobs_toml( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user_name}" +poll_interval_seconds = 5 + +[[jobs]] +name = "j1" +path = "{repo_path}" +branch = "main" +script = "{script_path}" +""", + ) + with pytest.raises(ConfigError, match="guard"): + load_config(config_path) + + +def test_load_rejects_duplicate_path(tmp_path: Path): + user_name = get_current_real_uid_user_name() + repo_path = _create_repo_directory(tmp_path / "repo") + script_path = _create_guarded_script(tmp_path / "deploy.sh", user_name) + config_path = _write_jobs_toml( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user_name}" +poll_interval_seconds = 5 + +[[jobs]] +name = "a" +path = "{repo_path}" +branch = "main" +script = "{script_path}" + +[[jobs]] +name = "b" +path = "{repo_path}" +branch = "other" +script = "{script_path}" +""", + ) + with pytest.raises(ConfigError, match="duplicate job path"): + load_config(config_path) + + +def test_load_rejects_unsafe_branch(tmp_path: Path): + user_name = get_current_real_uid_user_name() + repo_path = _create_repo_directory(tmp_path / "repo") + script_path = _create_guarded_script(tmp_path / "deploy.sh", user_name) + config_path = _write_jobs_toml( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user_name}" +poll_interval_seconds = 5 + +[[jobs]] +name = "j1" +path = "{repo_path}" +branch = "main; rm -rf /" +script = "{script_path}" +""", + ) + with pytest.raises(ConfigError, match="unsafe characters"): + load_config(config_path) + + +def test_load_rejects_leading_dash_branch(tmp_path: Path): + user_name = get_current_real_uid_user_name() + repo_path = _create_repo_directory(tmp_path / "repo") + script_path = _create_guarded_script(tmp_path / "deploy.sh", user_name) + config_path = _write_jobs_toml( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user_name}" +poll_interval_seconds = 5 + +[[jobs]] +name = "j1" +path = "{repo_path}" +branch = "--upload-pack=evil" +script = "{script_path}" +""", + ) + with pytest.raises(ConfigError, match="unsafe characters"): + load_config(config_path) + + +def test_load_rejects_zero_poll_interval(tmp_path: Path): + user_name = get_current_real_uid_user_name() + repo_path = _create_repo_directory(tmp_path / "repo") + script_path = _create_guarded_script(tmp_path / "deploy.sh", user_name) + config_path = _write_jobs_toml( + tmp_path / "jobs.toml", + f""" +[general] +user = "{user_name}" +poll_interval_seconds = 0 + +[[jobs]] +name = "j1" +path = "{repo_path}" +branch = "main" +script = "{script_path}" +""", + ) + with pytest.raises(ConfigError, match="poll_interval_seconds"): + load_config(config_path) diff --git a/tests/test_guard.py b/tests/test_guard.py new file mode 100644 index 0000000..1eb0bd6 --- /dev/null +++ b/tests/test_guard.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import os +import pwd +from pathlib import Path + +import pytest + +from fetch_runner.guard import GuardError +from fetch_runner.guard import get_current_real_uid_user_name +from fetch_runner.guard import render_canonical_script_guard +from fetch_runner.guard import require_expected_runtime_user +from fetch_runner.guard import validate_canonical_script_guard + + +def test_render_guard_embeds_user_everywhere(): + rendered_guard = render_canonical_script_guard("deploy") + assert "user=deploy" in rendered_guard + assert '"$(whoami)" != "deploy"' in rendered_guard + assert '"$(id -u)" -eq 0' in rendered_guard + assert rendered_guard.endswith("# <<< fetch-runner-guard:END\n") + + +@pytest.mark.parametrize( + "bad", + ["", "a" * 64, "-root", "foo;bar", "he re", "ro ot", "user$name", "`id`"], +) +def test_render_guard_rejects_unsafe_user(bad): + with pytest.raises(GuardError): + render_canonical_script_guard(bad) + + +def _write_script_file(script_path: Path, script_body: str) -> Path: + script_path.write_text(script_body) + return script_path + + +def test_validate_script_guard_happy(tmp_path: Path): + script_path = _write_script_file( + tmp_path / "deploy.sh", + "#!/bin/bash\n# a comment\n\n" + render_canonical_script_guard("deploy") + "echo hi\n", + ) + assert validate_canonical_script_guard(script_path, "deploy").is_valid + + +def test_validate_script_guard_works_without_shebang(tmp_path: Path): + script_path = _write_script_file( + tmp_path / "s", + render_canonical_script_guard("deploy") + "echo hi\n", + ) + assert validate_canonical_script_guard(script_path, "deploy").is_valid + + +def test_validate_script_guard_only_comments_has_no_guard(tmp_path: Path): + script_path = _write_script_file(tmp_path / "s.sh", "#!/bin/bash\n# nothing here\n\n") + guard_validation = validate_canonical_script_guard(script_path, "deploy") + assert not guard_validation.is_valid + assert "canonical guard block" in guard_validation.error_reason + + +def test_validate_script_guard_code_without_guard(tmp_path: Path): + script_path = _write_script_file(tmp_path / "s.sh", "#!/bin/bash\necho hi\n") + guard_validation = validate_canonical_script_guard(script_path, "deploy") + assert not guard_validation.is_valid + assert "before any executable code" in guard_validation.error_reason + + +def test_validate_script_guard_wrong_user(tmp_path: Path): + script_path = _write_script_file( + tmp_path / "s.sh", + "#!/bin/bash\n" + render_canonical_script_guard("otheruser") + "echo hi\n", + ) + guard_validation = validate_canonical_script_guard(script_path, "deploy") + assert not guard_validation.is_valid + assert "different user" in guard_validation.error_reason + + +def test_validate_script_guard_rejects_code_before_guard(tmp_path: Path): + script_path = _write_script_file( + tmp_path / "s.sh", + "#!/bin/bash\nset -e\n" + render_canonical_script_guard("deploy") + "echo hi\n", + ) + guard_validation = validate_canonical_script_guard(script_path, "deploy") + assert not guard_validation.is_valid + assert "before any executable code" in guard_validation.error_reason + + +def test_validate_script_guard_detects_flipped_comparator(tmp_path: Path): + # The most dangerous form of tampering: a check that always passes. + canonical_guard = render_canonical_script_guard("deploy") + tampered_guard = canonical_guard.replace('!= "deploy"', '== "deploy"') + assert tampered_guard != canonical_guard + script_path = _write_script_file( + tmp_path / "s.sh", + "#!/bin/bash\n" + tampered_guard + "echo hi\n", + ) + guard_validation = validate_canonical_script_guard(script_path, "deploy") + assert not guard_validation.is_valid + + +def test_validate_script_guard_detects_removed_uid_check(tmp_path: Path): + # Removing the root check: whoami would still match, but a root invocation + # should be blocked. Tampering must be caught. + canonical_guard = render_canonical_script_guard("deploy") + tampered_guard = canonical_guard.replace(' || [ "$(id -u)" -eq 0 ]', "") + assert tampered_guard != canonical_guard + script_path = _write_script_file( + tmp_path / "s.sh", + "#!/bin/bash\n" + tampered_guard + "echo hi\n", + ) + assert not validate_canonical_script_guard(script_path, "deploy").is_valid + + +def test_validate_script_guard_truncated(tmp_path: Path): + canonical_guard_lines = render_canonical_script_guard("deploy").splitlines() + truncated_guard = "\n".join(canonical_guard_lines[:-2]) + "\n" + script_path = _write_script_file(tmp_path / "s.sh", "#!/bin/bash\n" + truncated_guard) + guard_validation = validate_canonical_script_guard(script_path, "deploy") + assert not guard_validation.is_valid + + +def test_validate_script_guard_missing_file(tmp_path: Path): + guard_validation = validate_canonical_script_guard(tmp_path / "nope.sh", "deploy") + assert not guard_validation.is_valid + assert "cannot read" in guard_validation.error_reason + + +def test_current_user_matches_pwd_entry(): + assert get_current_real_uid_user_name() == pwd.getpwuid(os.getuid()).pw_name + + +def test_require_runtime_user_rejects_mismatch(): + with pytest.raises(GuardError): + require_expected_runtime_user("definitely-not-this-user-xyz") + + +def test_require_runtime_user_accepts_match(): + # Running the test suite as the current user must succeed (uid != 0 + # in a sane test environment). Skip if someone is running tests as root. + if os.getuid() == 0: + pytest.skip("test suite is running as root") + require_expected_runtime_user(get_current_real_uid_user_name()) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..345bace --- /dev/null +++ b/uv.lock @@ -0,0 +1,263 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "fetch-runner" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pre-commit" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "pre-commit", specifier = ">=3" }, + { name = "pytest", specifier = ">=8" }, + { name = "ruff", specifier = ">=0.8" }, +] + +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/3a7e644e19cb26133488caff231be390579860bbbb3da35913c49a1d0a46/virtualenv-21.2.4.tar.gz", hash = "sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada", size = 5850742, upload-time = "2026-04-14T22:15:31.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/8d/edd0bd910ff803c308ee9a6b7778621af0d10252219ad9f19ef4d4982a61/virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac", size = 5831232, upload-time = "2026-04-14T22:15:29.342Z" }, +]