diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2cda423 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + name: pytest (${{ matrix.os }}, py${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + # This suite is seconds on every platform. A hang must fail LOUDLY and + # fast, not sit burning runner time until the 6h default -- a job stuck at + # "in_progress" reads as "not done yet" rather than "broken", which is how + # a false green gets merged. + timeout-minutes: 10 + strategy: + # A Windows failure must not cancel the POSIX legs. Those tell us whether + # we regressed the population that works today, which is the more + # expensive outcome by far. + fail-fast: false + matrix: + # requires-python = ">=3.11" -- cover the floor and a current minor. + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Run test suite + # windows-latest is the point of this workflow. This module's entire + # Windows surface is shell resolution -- Git Bash vs WSL vs neither -- + # and every test guarding it is skipped by platform guard on POSIX, so + # before this job those tests ran NOWHERE. Two of them were green on + # Linux for days and failed the first time they actually executed on + # Windows. + # + # Honest limit: GitHub's Windows runner has no WSL, so the `wsl --exec` + # path cannot be exercised here. It DOES have Git Bash, so the + # Git-Bash and no-bash paths are covered. WSL behaviour still needs a + # real machine. + run: uv run pytest -q diff --git a/README.md b/README.md index 1c56abe..0ea5ff0 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,15 @@ Execute a bash command with platform-appropriate shell. - ✅ Heredocs: `cat < int | None: return None +def _descendants_from_pid_ppid_pairs( + root_pid: int, pairs: list[tuple[int, int]] +) -> set[int]: + """Walk a flat list of (pid, ppid) pairs to find all descendants of root_pid.""" + children_by_ppid: dict[int, list[int]] = {} + for pid, ppid in pairs: + children_by_ppid.setdefault(ppid, []).append(pid) + + descendants: set[int] = set() + frontier = [root_pid] + while frontier: + current = frontier.pop() + for child in children_by_ppid.get(current, []): + if child not in descendants: + descendants.add(child) + frontier.append(child) + return descendants + + +def _find_descendant_pids_via_ps(root_pid: int) -> set[int]: + """Fallback descendant walk using `ps` for POSIX systems without /proc + (e.g. macOS, which has no /proc filesystem). + + `ps -A -o pid=,ppid=` is portable across GNU (Linux) and BSD (macOS) ps + implementations: `-A` selects every process, and the trailing `=` after + each column name suppresses the header on both. Returns an empty set on + any failure (missing `ps`, unexpected output, etc.) -- this is a + best-effort fallback, not a hard requirement. + """ + try: + result = subprocess.run( + ["ps", "-A", "-o", "pid=,ppid="], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return set() + + pairs: list[tuple[int, int]] = [] + for line in result.stdout.splitlines(): + fields = line.split() + if len(fields) != 2: + continue + try: + pairs.append((int(fields[0]), int(fields[1]))) + except ValueError: + continue + + return _descendants_from_pid_ppid_pairs(root_pid, pairs) + + def _find_descendant_pids(root_pid: int) -> set[int]: - """Recursively find all descendant PIDs of root_pid by walking /proc. + """Recursively find all descendant PIDs of root_pid. Unlike process-group membership, the PPID chain survives setsid() -- a process that detaches into its own session/process group (directly, or via a wrapper like tmux/incus/docker that manages its own session - lifecycle) keeps its original parent. Walking /proc lets us find and - kill descendants that escaped the process group and that os.killpg() - can no longer reach. + lifecycle) keeps its original parent. Walking the process table lets us + find and kill descendants that escaped the process group and that + os.killpg() can no longer reach. - Linux-only (relies on /proc). Returns an empty set on other platforms - or if /proc is unavailable. + Prefers /proc (Linux) for speed and reliability; falls back to `ps` + (e.g. macOS, which has no /proc) when /proc is unavailable. Returns an + empty set if neither source is usable. """ try: all_pids = [int(name) for name in os.listdir("/proc") if name.isdigit()] except OSError: - return set() + return _find_descendant_pids_via_ps(root_pid) - children_by_ppid: dict[int, list[int]] = {} + pairs: list[tuple[int, int]] = [] for pid in all_pids: ppid = _read_ppid(pid) if ppid is not None: - children_by_ppid.setdefault(ppid, []).append(pid) + pairs.append((pid, ppid)) - descendants: set[int] = set() - frontier = [root_pid] - while frontier: - current = frontier.pop() - for child in children_by_ppid.get(current, []): - if child not in descendants: - descendants.add(child) - frontier.append(child) - return descendants + return _descendants_from_pid_ppid_pairs(root_pid, pairs) def _signal_pids(pids: set[int], sig: int) -> None: @@ -94,6 +141,597 @@ def _signal_pids(pids: set[int], sig: int) -> None: pass +# --- Windows orphan prevention (GAP-024) --------------------------------- +# +# On POSIX, `_run_command`'s existing timeout-cleanup path (`os.killpg` + +# `_find_descendant_pids`) only covers the case where THIS module's own code +# is still running to execute that cleanup -- e.g. the tool-level timeout +# firing, or a normal asyncio.CancelledError propagating through a still- +# alive event loop. It does NOT cover the host `amplifier.exe` process being +# killed outright (crash, `Stop-Process`/`taskkill /F` on just the top PID, +# a supervisor terminating only the parent) -- Windows has no equivalent of +# POSIX's parent-death signal (`prctl(PR_SET_PDEATHSIG)`), so a subprocess +# spawned here has no way to notice its parent is gone and no code of ours +# runs to clean it up. +# +# Confirmed empirically (adversarial Windows re-test, alienware-r13): +# spawning `sleep 60` via this module's WSL-routed path +# (`wsl --exec bash -c ...`), then killing ONLY the top-level `amplifier.exe` +# PID (no /T, no console Ctrl+C -- a plain `Stop-Process -Id `), left +# the resulting `wsl.exe -> wsl.exe -> wslhost.exe` chain running with a +# dead parent for the entire 60+ second observation window. This directly +# contradicts the prior claim that killing only the top-level PID always +# brings the whole tree down within ~2s -- that was true for amplifier's own +# internal python-to-python self-relaunch (which IS covered by an existing +# job-object association), but not for tool-call subprocesses spawned from +# deep inside a running turn, which were never assigned to that job. +# +# Fix: create one Windows Job Object per process, with +# JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE set, and assign every subprocess this +# module spawns (on the foreground/tracked path only -- NOT +# `_run_command_background`, whose whole point is to outlive us) to that +# job. The job's only handle lives in this process; when this process ends +# for ANY reason -- including a forceful kill that runs none of our own +# Python cleanup code -- the OS closes that handle and the kernel itself +# tears down every process still assigned to the job. This does not depend +# on any application code running, so it also covers crashes. +_windows_job_handle = None +# Created at import, not lazily. A `if _lock is None: _lock = Lock()` guard is +# itself unsynchronised -- two OS threads can both see None and build two locks, +# defeating the mutual exclusion it exists to provide. Nothing in this module +# currently calls off the event-loop thread, so this was latent, but a +# module-level construction costs nothing and removes the trap. +_windows_job_lock = threading.Lock() + +# Background descendant-sweep tasks, held so the event loop cannot garbage +# collect them mid-flight. asyncio.create_task returns a task that is only +# weakly referenced by the loop; the docs are explicit that an unreferenced task +# "may get garbage collected at any time, even before it's done". These sweeps +# are the entire GAP-013/GAP-028 protection for WSL descendants, and if one +# vanished the symptom would be indistinguishable from a silent assignment +# failure. +_windows_sweep_tasks: set = set() + +# One-shot flag so a job-object failure is reported loudly ONCE per process +# rather than either spamming every command or (as before) being invisible. +_windows_job_failure_reported = False + + +def _report_windows_job_failure(message: str, *args) -> None: + """Report a job-object failure ONCE per process, at warning level. + + These failures used to be logged at debug only and their return values + discarded by every caller, so the entire orphan-protection mechanism could + be inert with no operator-visible signal at all. + + That matters most in exactly the environments this protection is for. + ``AssignProcessToJobObject`` fails when the process is already inside a job + that disallows the assignment -- the normal state under CI runners (GitHub + Actions wraps every step in a job object), Windows containers, and some + endpoint-security agents. In those environments this ships, every command + still "succeeds", and nothing anywhere indicates the protection never + engaged. A later orphan report would then be wrongly dismissed as + already-fixed. + + Warning rather than error because the tool call itself is unaffected -- + this is defense-in-depth, not a correctness requirement. Once per process + rather than per command because the cause is environmental and constant; + repeating it every command would be noise that trains people to ignore it. + """ + global _windows_job_failure_reported + if _windows_job_failure_reported: + logger.debug("tool-bash: " + message, *args) + return + _windows_job_failure_reported = True + logger.warning( + "tool-bash: Windows orphan protection is NOT active for this process. " + + message + + ". Subprocesses spawned by this tool may survive an abrupt exit. " + "This is expected inside a restrictive parent job object (CI runners, " + "Windows containers); it is reported once per process.", + *args, + ) + + +def _get_windows_job_object(): + """Lazily create (once per process) a Job Object with kill-on-close set. + + Returns the job handle (an int, per ctypes' ``wintypes.HANDLE``) or + ``None`` if creation failed for any reason -- callers must treat that as + "no extra protection available" and continue without raising, since + this is a defense-in-depth addition, not a required dependency for the + tool to function. + """ + global _windows_job_handle, _windows_job_lock + if sys.platform != "win32": + return None + if _windows_job_lock is None: + import threading + + _windows_job_lock = threading.Lock() + with _windows_job_lock: + if _windows_job_handle is not None: + return _windows_job_handle + try: + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + + # Declare signatures rather than letting ctypes default an + # undeclared return to c_int (32-bit signed), which truncates a + # 64-bit HANDLE. Win32 guarantees handle values are 32-bit + # significant, so the untyped form happens to work -- but relying on + # an unstated guarantee is how a silent, platform-specific + # corruption bug gets in. + kernel32.CreateJobObjectW.restype = wintypes.HANDLE + kernel32.CreateJobObjectW.argtypes = [wintypes.LPVOID, wintypes.LPCWSTR] + kernel32.SetInformationJobObject.restype = wintypes.BOOL + kernel32.SetInformationJobObject.argtypes = [ + wintypes.HANDLE, + ctypes.c_int, + wintypes.LPVOID, + wintypes.DWORD, + ] + kernel32.CloseHandle.restype = wintypes.BOOL + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + + job = kernel32.CreateJobObjectW(None, None) + if not job: + _report_windows_job_failure( + "CreateJobObjectW failed (%s)", + ctypes.WinError(ctypes.get_last_error()), + ) + return None + + # JOBOBJECT_BASIC_LIMIT_INFORMATION + JOBOBJECT_EXTENDED_LIMIT_INFORMATION + # layout (winnt.h). We only need to set LimitFlags on the basic + # struct embedded at the start of the extended one. + JobObjectExtendedLimitInformation = 9 + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 + + class IO_COUNTERS(ctypes.Structure): + _fields_ = [ + ("ReadOperationCount", ctypes.c_uint64), + ("WriteOperationCount", ctypes.c_uint64), + ("OtherOperationCount", ctypes.c_uint64), + ("ReadTransferCount", ctypes.c_uint64), + ("WriteTransferCount", ctypes.c_uint64), + ("OtherTransferCount", ctypes.c_uint64), + ] + + class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure): + _fields_ = [ + ("PerProcessUserTimeLimit", ctypes.c_int64), + ("PerJobUserTimeLimit", ctypes.c_int64), + ("LimitFlags", wintypes.DWORD), + ("MinimumWorkingSetSize", ctypes.c_size_t), + ("MaximumWorkingSetSize", ctypes.c_size_t), + ("ActiveProcessLimit", wintypes.DWORD), + ("Affinity", ctypes.c_size_t), + ("PriorityClass", wintypes.DWORD), + ("SchedulingClass", wintypes.DWORD), + ] + + class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure): + _fields_ = [ + ("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION), + ("IoInfo", IO_COUNTERS), + ("ProcessMemoryLimit", ctypes.c_size_t), + ("JobMemoryLimit", ctypes.c_size_t), + ("PeakProcessMemoryUsed", ctypes.c_size_t), + ("PeakJobMemoryUsed", ctypes.c_size_t), + ] + + info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION() + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + + ok = kernel32.SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + ctypes.byref(info), + ctypes.sizeof(info), + ) + if not ok: + _report_windows_job_failure( + "SetInformationJobObject failed (%s)", + ctypes.WinError(ctypes.get_last_error()), + ) + kernel32.CloseHandle(job) + return None + + _windows_job_handle = job + return job + except Exception as e: # pragma: no cover - defense in depth only + logger.debug( + "tool-bash: Windows job-object setup failed (%s); " + "proceeding without orphan protection", + e, + ) + return None + + +def _assign_to_windows_job(pid: int) -> bool: + """Best-effort: assign `pid` to this process's kill-on-close job object. + + Returns whether assignment actually succeeded. Most callers only need + "did I do my best" semantics and can ignore the return value; the + descendant-walker below (GAP-013/GAP-028) uses it to log clearly. + + Failure is intentionally swallowed as far as the CALLER's control flow + goes (logged at debug only) -- this is defense-in-depth cleanup, not a + correctness requirement for the command itself to run. A process that + can't be assigned (e.g. already exited, or running with different + privileges) just doesn't get the extra protection; it does not fail + the tool call. + """ + if sys.platform != "win32": + return False + job = _get_windows_job_object() + if job is None: + return False + try: + import ctypes + + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.OpenProcess.restype = wintypes.HANDLE + kernel32.OpenProcess.argtypes = [ + wintypes.DWORD, + wintypes.BOOL, + wintypes.DWORD, + ] + kernel32.AssignProcessToJobObject.restype = wintypes.BOOL + kernel32.AssignProcessToJobObject.argtypes = [ + wintypes.HANDLE, + wintypes.HANDLE, + ] + kernel32.CloseHandle.restype = wintypes.BOOL + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + + PROCESS_ALL_ACCESS = 0x1F0FFF + hproc = kernel32.OpenProcess(PROCESS_ALL_ACCESS, False, pid) + if not hproc: + # Debug, not warning: the overwhelmingly common cause is that the + # process already exited between spawn and assignment, which is + # benign and expected for fast commands. Distinct from the + # environmental failures reported once at warning level. + logger.debug( + "tool-bash: OpenProcess(%s) failed (%s); pid not job-protected " + "(usually means it already exited)", + pid, + ctypes.WinError(ctypes.get_last_error()), + ) + return False + try: + if not kernel32.AssignProcessToJobObject(job, hproc): + # This one IS environmental: the dominant cause is that this + # process already sits inside a job object that disallows the + # assignment -- the normal state under CI runners, Windows + # containers, and some endpoint-security agents. Report it + # loudly once, because in that case the protection is inert for + # every command and nothing else would ever say so. + _report_windows_job_failure( + "AssignProcessToJobObject(pid=%s) failed (%s)", + pid, + ctypes.WinError(ctypes.get_last_error()), + ) + return False + return True + finally: + kernel32.CloseHandle(hproc) + except Exception as e: # pragma: no cover - defense in depth only + logger.debug("tool-bash: failed to job-protect pid %s (%s)", pid, e) + return False + + +def _enumerate_child_pids_windows(parent_pid: int) -> set[int]: + """Direct children of `parent_pid` via CreateToolhelp32Snapshot -- a + plain Win32 API walk of the system-wide process snapshot, filtered by + th32ParentProcessID. No WMI/CIM, no PowerShell subprocess. + + Windows-only. Returns an empty set on any failure or on other platforms. + """ + if sys.platform != "win32": + return set() + try: + import ctypes + from ctypes import wintypes + + TH32CS_SNAPPROCESS = 0x00000002 + # CreateToolhelp32Snapshot signals failure by returning + # INVALID_HANDLE_VALUE, i.e. (HANDLE)-1 -- NOT NULL. With restype + # HANDLE (c_void_p), ctypes converts NULL to None and any other + # pointer value, including the -1 bit pattern, to a positive Python + # int (18446744073709551615 on 64-bit). Compare against the real + # sentinel, not the signed literal -1. + _INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value + + class PROCESSENTRY32(ctypes.Structure): + _fields_ = [ + ("dwSize", wintypes.DWORD), + ("cntUsage", wintypes.DWORD), + ("th32ProcessID", wintypes.DWORD), + ("th32DefaultHeapID", ctypes.c_size_t), + ("th32ModuleID", wintypes.DWORD), + ("cntThreads", wintypes.DWORD), + ("th32ParentProcessID", wintypes.DWORD), + ("pcPriClassBase", ctypes.c_long), + ("dwFlags", wintypes.DWORD), + ("szExeFile", ctypes.c_char * 260), + ] + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + # Same reasoning as the job-object signatures above: CreateToolhelp32Snapshot + # returns a HANDLE, and leaving restype undeclared truncates it to a 32-bit + # c_int on 64-bit Windows. Declare all four signatures explicitly. + kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE + kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD] + kernel32.Process32First.restype = wintypes.BOOL + kernel32.Process32First.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(PROCESSENTRY32), + ] + kernel32.Process32Next.restype = wintypes.BOOL + kernel32.Process32Next.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(PROCESSENTRY32), + ] + kernel32.CloseHandle.restype = wintypes.BOOL + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + + snap = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) + if snap is None or snap == _INVALID_HANDLE_VALUE: + return set() + try: + entry = PROCESSENTRY32() + entry.dwSize = ctypes.sizeof(PROCESSENTRY32) + children: set[int] = set() + if not kernel32.Process32First(snap, ctypes.byref(entry)): + return set() + while True: + if entry.th32ParentProcessID == parent_pid: + children.add(entry.th32ProcessID) + if not kernel32.Process32Next(snap, ctypes.byref(entry)): + break + return children + finally: + kernel32.CloseHandle(snap) + except Exception as e: # pragma: no cover - defense in depth only + logger.debug( + "tool-bash: descendant enumeration failed for %s (%s)", parent_pid, e + ) + return set() + + +def _spawn_descendant_sweep(root_pid: int) -> None: + """Start the descendant sweep and KEEP A REFERENCE to the task. + + ``asyncio.create_task`` returns a task the loop holds only weakly. The + stdlib docs are explicit: "Save a reference to the result of this + function... A task that isn't referenced elsewhere may get garbage + collected at any time, even before it's done." + + These sweeps are the whole GAP-013/GAP-028 protection for WSL descendants + (inner ``wsl.exe``, ``wslhost.exe``), which do NOT inherit job membership + from the immediate spawned PID. If one were collected mid-flight, the + symptom would be orphaned processes with nothing in the logs -- outwardly + identical to a silent job-assignment failure, and correspondingly awful to + diagnose. + + In practice the task is always parked on ``asyncio.sleep``, so the loop's + timer structures probably keep it reachable. "Probably" is not a property + worth betting a process-cleanup guarantee on, and a set costs nothing. + """ + task = asyncio.create_task(_protect_windows_descendants(root_pid)) + _windows_sweep_tasks.add(task) + task.add_done_callback(_windows_sweep_tasks.discard) + + +async def _protect_windows_descendants(root_pid: int) -> None: + """Best-effort background task (GAP-013/GAP-028): assign every + descendant of `root_pid` to the same kill-on-close job object, not + just the immediate spawned PID. + + Why this exists: `_assign_to_windows_job(process.pid)` alone was found + NOT to actually protect a WSL-routed command's real descendants. + Verified directly against the deployed code with the Win32 + `IsProcessInJob` query (native Windows, alienware-r13): after spawning + `wsl --exec bash -c ` and calling `_assign_to_windows_job()` on + the immediate PID, that top-level PID *was* a job member -- but the + inner `wsl.exe` and `wslhost.exe` processes underneath it (the ones + that do the actual work, and the ones this module's own comments + elsewhere claim are covered) were NOT. Windows only auto-propagates + job membership to children spawned directly by a job-member process + via CreateProcess; WSL's inner process tree is connected to the outer + `wsl.exe` via an RPC/session channel rather than a plain parent-child + CreateProcess relationship, so it never inherits membership that way. + + An external kill of the top-level process was still observed (same + investigation) to bring the whole WSL tree down in practice -- but via + WSL's own connection-teardown behavior when its client disconnects, + not via the job object. That is a real, currently-working mechanism, + but it is undocumented, owned by WSL rather than by us, and not + something this module actually controls or could adjust if it ever + changed. This function makes the protection deliberate instead of + coincidental: it walks the process tree (root_pid's children, and + their children) with a few short retries -- the WSL tree takes a + moment to fully spawn -- and assigns every PID it finds to the job + too, so the kill-on-close guarantee no longer depends on an external, + unverified assumption about WSL's behavior. + + Fire-and-forget: runs concurrently with the command's own + process.communicate(), never blocks or delays the tool call, and + never raises (every failure path is caught and logged at debug only, + same contract as _assign_to_windows_job itself). + """ + if sys.platform != "win32": + return + try: + seen: set[int] = {root_pid} + for _ in range(8): # poll for up to ~2s while the tree spawns + frontier = list(seen) + new_found = False + for pid in frontier: + for child in _enumerate_child_pids_windows(pid): + if child not in seen: + seen.add(child) + _assign_to_windows_job(child) + new_found = True + if not new_found and len(seen) > 1: + break # tree grew at least once, then stopped changing + await asyncio.sleep(0.25) + except Exception as e: # pragma: no cover - defense in depth only + logger.debug( + "tool-bash: descendant job-protection sweep failed for %s (%s)", + root_pid, + e, + ) + + +# --- Windows shell resolution: Git Bash discoverability + observability -- +# +# Root cause (confirmed on a real Windows 11 box, Git for Windows 2.55.0.3 +# installed a month prior): Git for Windows' *default* install puts +# `Git\cmd` on PATH (git.exe lives there) but NOT `Git\bin` (bash.exe lives +# there). Meanwhile `C:\Windows\System32\bash.exe` -- the WSL launcher +# stub -- is effectively always on PATH. The result: `shutil.which("bash")` +# resolves the WSL launcher every time, and Git Bash is unreachable no +# matter what's installed -- even though a `bash` call against a WSL box +# reaches a different filesystem, HOME, and toolchain (e.g. the WSL Linux +# Python, not the Windows Python the user actually has installed) than a +# `bash` call against Git Bash. +# +# Fix: (1) probe the well-known Git-for-Windows install locations directly +# on the filesystem, independent of PATH, so Git Bash becomes genuinely +# discoverable; (2) make the choice between WSL and Git Bash explicit and +# overridable via `windows_shell` config / the AMPLIFIER_BASH_WINDOWS_SHELL +# env var, defaulting to "auto" -- which preserves today's real-world +# default behavior exactly (PATH-first resolution, so WSL wins when both +# are present, unchanged for existing users) and only engages the new +# filesystem probing as a fallback when PATH resolves nothing at all +# (previously a hard "bash not found" error even with Git Bash installed). + +_WINDOWS_SHELL_PREFERENCE_ENV_VAR = "AMPLIFIER_BASH_WINDOWS_SHELL" +_VALID_WINDOWS_SHELL_PREFERENCES = ("auto", "wsl", "gitbash") + + +def _find_git_bash_executable() -> str | None: + """Probe well-known Git-for-Windows install locations for bash.exe, + independent of PATH (see module-level note above for why PATH alone + can never find it on a box where WSL is also installed). + + Returns the first that exists on disk, or None. + """ + candidates: list[str] = [] + local_app_data = os.environ.get("LOCALAPPDATA") + for env_var in ("ProgramFiles", "ProgramFiles(x86)"): + base = os.environ.get(env_var) + if base: + candidates.append(os.path.join(base, "Git", "bin", "bash.exe")) + if local_app_data: + candidates.append( + os.path.join(local_app_data, "Programs", "Git", "bin", "bash.exe") + ) + # bash.exe under Git\bin is normally a copy of the one under + # Git\usr\bin; some installs (or a damaged/partial one) may only have + # the latter, so check it too before giving up. + for env_var in ("ProgramFiles", "ProgramFiles(x86)"): + base = os.environ.get(env_var) + if base: + candidates.append(os.path.join(base, "Git", "usr", "bin", "bash.exe")) + if local_app_data: + candidates.append( + os.path.join(local_app_data, "Programs", "Git", "usr", "bin", "bash.exe") + ) + + for candidate in candidates: + if os.path.isfile(candidate): + return candidate + return None + + +def _find_wsl_bash_executable() -> str | None: + """Probe the well-known WSL launcher location directly, independent of + PATH. In practice PATH always resolves this one (System32 is on every + Windows PATH by construction) -- this exists mainly for symmetry with + `_find_git_bash_executable` and to serve explicit `windows_shell="wsl"` + requests robustly even in an unusual PATH configuration. + """ + system_root = os.environ.get("SystemRoot", r"C:\Windows") + candidate = os.path.join(system_root, "System32", "bash.exe") + return candidate if os.path.isfile(candidate) else None + + +def _looks_like_wsl_launcher_path(path: str | None) -> bool: + """Cheap, SYNCHRONOUS classification used only to build the tool + description / startup log line at construction time (see + `BashTool._windows_shell_startup_note`) -- NOT used to decide how a + command actually executes. That decision always goes through + `BashTool._is_wsl_bash`'s authoritative `test -d /mnt/wsl` subprocess + check. On a real Windows install, the WSL launcher only ever lives at + `%SystemRoot%\\System32\\bash.exe`, so a path-string check is a safe, + deterministic stand-in for the one place we can't afford to spawn a + process (a synchronous constructor). + """ + return path is not None and "system32" in path.lower() + + +def _arbitrate_windows_shell( + preference: str, + path_bash: str | None, + path_bash_is_wsl: bool, + git_bash_candidate: str | None, + wsl_bash_candidate: str | None, +) -> tuple[str | None, bool]: + """Pure decision: given what PATH resolves and what's discoverable via + the well-known install-location probes, decide which bash executable + wins and whether it's WSL bash. + + Shared by the authoritative async resolution + (`BashTool._resolve_windows_bash`, using a real subprocess check for + `path_bash_is_wsl`) and the synchronous, approximate one used for the + startup log/description (using the path heuristic above) -- so the + *decision* logic lives in exactly one place, even though *how + WSL-ness is determined* legitimately differs between the two callers. + + "auto" (default) preserves today's real-world behavior exactly: if + PATH resolves anything, it wins outright, full stop -- unchanged for + every existing user. Only when PATH resolves NOTHING does auto fall + back to the install-location probes (a strict improvement: previously + a hard error even with Git Bash installed). Explicit "wsl"/"gitbash" + preferences consider both PATH and the probes, so a user can force + Git Bash even where PATH resolves WSL's launcher first (the reported + bug) -- or force WSL even where PATH would resolve Git Bash first. + """ + if preference == "auto" and path_bash: + return path_bash, path_bash_is_wsl + + gitbash_exe = ( + path_bash if (path_bash and not path_bash_is_wsl) else git_bash_candidate + ) + wsl_exe = path_bash if (path_bash and path_bash_is_wsl) else wsl_bash_candidate + + if preference == "gitbash" and gitbash_exe: + return gitbash_exe, False + if preference == "wsl" and wsl_exe: + return wsl_exe, True + + if preference != "auto": + logger.warning( + "tool-bash: windows_shell=%r requested but not available on " + "this machine; falling back to auto-detection", + preference, + ) + + if wsl_exe: + return wsl_exe, True + if gitbash_exe: + return gitbash_exe, False + return None, False + + async def mount(coordinator: ModuleCoordinator, config: dict[str, Any] | None = None): """ Mount the bash tool. @@ -110,6 +748,11 @@ async def mount(coordinator: ModuleCoordinator, config: dict[str, Any] | None = - allowed_commands: Whitelist of allowed commands (default: []) - denied_commands: Additional custom blocklist patterns (default: []) - safety_overrides: Fine-grained safety overrides dict with 'allow' and 'block' lists + - windows_shell: Windows-only. Which bash to prefer: "auto" (default, + PATH-first -- unchanged from prior behavior), "wsl", or "gitbash". + Also settable via the AMPLIFIER_BASH_WINDOWS_SHELL env var + (config takes precedence). See _arbitrate_windows_shell for + the full resolution/fallback rules. Returns: Optional cleanup function @@ -215,6 +858,110 @@ def __init__(self, config: dict[str, Any]): # Cache for WSL bash detection to avoid repeated checks self._wsl_bash_cache: dict[str, bool] = {} + # Windows shell resolution: which bash (WSL vs Git Bash) to use, and + # whether the choice has been explicitly overridden. See the + # module-level note above `_arbitrate_windows_shell` for why PATH + # alone can't be trusted to ever surface Git Bash. + self._windows_shell_preference = self._resolve_windows_shell_preference(config) + # Cache for the AUTHORITATIVE resolution (async, subprocess-verified + # is_wsl check) used to actually execute commands. Resolved once per + # instance, not per command -- shared by both the foreground + # (_run_command) and background (_run_command_background) paths so + # they can never disagree (see _resolve_windows_bash docstring). + self._windows_bash_resolved: tuple[str | None, bool] | None = None + + # Windows only: append a startup note (log + tool description) naming + # the shell we expect to resolve to, so a user/model isn't left to + # discover it only after a confusing failure hundreds of calls in + # (e.g. `python script.py` failing because WSL bash can't see the + # Windows Python). Uses a synchronous, approximate classification + # (`_looks_like_wsl_launcher_path`) since the authoritative, + # subprocess-verified check can't run inside a sync constructor -- + # the real execution routing is unaffected and always uses that + # authoritative check via `_resolve_windows_bash`. + if sys.platform == "win32": + self.description = self.description + self._windows_shell_startup_note() + + @staticmethod + def _resolve_windows_shell_preference(config: dict[str, Any]) -> str: + """Resolve the explicit Windows shell preference: `windows_shell` + config key, then the AMPLIFIER_BASH_WINDOWS_SHELL env var, then + "auto" (today's default: PATH-first, unchanged). + + This module has no existing config-resolution system to plug into + (checked: no env vars, no config layer beyond plain config.get(...) + calls) -- a plain env var + config key, in the spirit of the + existing code, is the whole mechanism. + """ + value = config.get("windows_shell") or os.environ.get( + _WINDOWS_SHELL_PREFERENCE_ENV_VAR + ) + if not value: + return "auto" + value = value.strip().lower() + if value not in _VALID_WINDOWS_SHELL_PREFERENCES: + logger.warning( + "tool-bash: unknown windows_shell=%r (expected one of %s); " + "using 'auto'", + value, + _VALID_WINDOWS_SHELL_PREFERENCES, + ) + return "auto" + return value + + def _windows_shell_startup_note(self) -> str: + """Build the Windows-only description/log note naming the shell we + expect to resolve to and its path conventions -- the model needs + this BEFORE its first command (WSL mounts Windows drives at + /mnt/c/..., Git Bash at /c/...; guessing wrong -- not a hard + error -- was found to be the dominant failure mode across + comparable CLI agents). Approximate (see + `_looks_like_wsl_launcher_path`); the actual command routing + always uses the authoritative, subprocess-verified check in + `_resolve_windows_bash`. + """ + path_bash = shutil.which("bash") + path_bash_is_wsl = _looks_like_wsl_launcher_path(path_bash) + exe, is_wsl = _arbitrate_windows_shell( + self._windows_shell_preference, + path_bash, + path_bash_is_wsl, + _find_git_bash_executable(), + _find_wsl_bash_executable(), + ) + + logger.info( + "tool-bash: Windows shell (approx; confirmed on first command) -> %s (%s)", + exe or "NOT FOUND", + "wsl" if is_wsl else ("gitbash" if exe else "none"), + ) + + override_hint = ( + "\n(Override: set windows_shell config or " + f"{_WINDOWS_SHELL_PREFERENCE_ENV_VAR} env var to 'wsl' or " + "'gitbash'.)" + ) + if exe is None: + return ( + "\n\nWINDOWS SHELL: no bash found (WSL or Git Bash). Every " + "command will fail with an actionable error naming how to " + "install one." + ) + if is_wsl: + return ( + f"\n\nWINDOWS SHELL: WSL bash ({exe}). Windows drives are " + "mounted at /mnt/c/..., not /c/...; $HOME is the WSL " + "Linux home, not the Windows user profile; the toolchain " + "(e.g. python) is whatever is installed INSIDE that Linux " + "distro, not on Windows." + override_hint + ) + return ( + f"\n\nWINDOWS SHELL: Git Bash ({exe}). Windows drives are " + "mounted at /c/..., not /mnt/c/...; $HOME is the Windows user " + "profile; the toolchain (e.g. python) is whatever is " + "installed on Windows itself." + override_hint + ) + @property def input_schema(self) -> dict: """Return JSON schema for tool parameters.""" @@ -528,6 +1275,45 @@ async def _is_wsl_bash(self, bash_exe: str) -> bool: self._wsl_bash_cache[bash_exe] = False return False + async def _resolve_windows_bash(self) -> tuple[str | None, bool]: + """Authoritative Windows shell resolution: which bash executable to + use, and whether it's WSL bash. Resolved ONCE per instance and + cached -- both `_run_command` and `_run_command_background` call + this (instead of each rolling their own PATH lookup / cache read) + so the two paths can never disagree about which shell is active. + + Previously `_run_command_background` read + `self._wsl_bash_cache.get(bash_exe, False)` directly, defaulting to + False -- so a background command issued before any foreground + command routed WSL's bash.exe down the Git-Bash direct-exec + branch, bypassing the `wsl --exec` wrapper that exists + specifically to prevent premature variable expansion. Since this + method is itself async, both call sites can simply await it + instead. + """ + if self._windows_bash_resolved is not None: + return self._windows_bash_resolved + + path_bash = shutil.which("bash") + path_bash_is_wsl = await self._is_wsl_bash(path_bash) if path_bash else False + + resolved = _arbitrate_windows_shell( + self._windows_shell_preference, + path_bash, + path_bash_is_wsl, + _find_git_bash_executable(), + _find_wsl_bash_executable(), + ) + self._windows_bash_resolved = resolved + + exe, is_wsl = resolved + logger.info( + "tool-bash: Windows shell resolved -> %s (%s)", + exe or "NOT FOUND", + "wsl" if is_wsl else ("gitbash" if exe else "none"), + ) + return resolved + async def _run_command_background(self, command: str) -> dict[str, Any]: """Run command in background, returning immediately with PID. @@ -547,14 +1333,14 @@ async def _run_command_background(self, command: str) -> dict[str, Any]: devnull = subprocess.DEVNULL if is_windows: - # Windows background execution - bash_exe = shutil.which("bash") + # Windows background execution. Resolution is authoritative and + # shared with the foreground path via `_resolve_windows_bash` + # (this method is itself async, so it can simply await it) -- + # see that method's docstring for why the previous + # `self._wsl_bash_cache.get(bash_exe, False)` read here could + # silently disagree with the foreground path. + bash_exe, is_wsl = await self._resolve_windows_bash() if bash_exe: - # Determine if WSL bash (requires special invocation) - # Note: We need to run detection synchronously here since Popen is sync - # Use cached result if available, otherwise assume not WSL for background - is_wsl = self._wsl_bash_cache.get(bash_exe, False) - if is_wsl: # WSL bash: Use 'wsl --exec bash -c' to prevent premature variable expansion process = subprocess.Popen( @@ -614,7 +1400,10 @@ async def _run_command( On Unix-like systems (Linux, macOS, WSL), uses bash for full shell features. On Windows, attempts to find bash (Git Bash or WSL bash). - Falls back to cmd.exe with limitations if bash is not found. + If bash is not found, every command fails with an actionable error + naming the cause and how to install bash (Git for Windows or WSL) -- + this tool's contract is POSIX shell semantics, so there is no + partial/degraded fallback (e.g. cmd.exe) for "simple" commands. Uses process groups for proper cleanup on timeout - kills entire process tree. """ @@ -624,15 +1413,14 @@ async def _run_command( pgid = None if is_windows: - # Try to find bash (Git Bash or WSL bash) - bash_exe = shutil.which("bash") + # Resolve which bash to use (Git Bash or WSL bash) -- shared, + # cached-once resolution; see `_resolve_windows_bash` docstring. + bash_exe, is_wsl = await self._resolve_windows_bash() if bash_exe: # Bash found on Windows - use create_subprocess_exec to handle # paths with spaces (e.g., "C:\Program Files\Git\bin\bash.exe") # and properly handle WSL bash variable expansion - is_wsl = await self._is_wsl_bash(bash_exe) - if is_wsl: # WSL bash: Use 'wsl --exec bash -c' to prevent premature # variable expansion by the WSL launcher @@ -647,6 +1435,17 @@ async def _run_command( stdin=asyncio.subprocess.DEVNULL, # Never hand the child our stdin cwd=self.working_dir, ) + # GAP-024: assign to a kill-on-close job object so this + # (and its wslhost.exe descendants) can't outlive an + # amplifier.exe that gets killed outright rather than + # cancelled through our own code. See helper docstring. + _assign_to_windows_job(process.pid) + # GAP-013/GAP-028: the immediate wsl.exe PID being a job + # member does NOT mean its real descendants (inner + # wsl.exe, wslhost.exe) are -- verified directly with + # IsProcessInJob. Sweep for them in the background; see + # _protect_windows_descendants docstring for why. + _spawn_descendant_sweep(process.pid) else: # Git Bash or other: Direct exec with [bash, -c, command] process = await asyncio.create_subprocess_exec( @@ -658,39 +1457,45 @@ async def _run_command( stdin=asyncio.subprocess.DEVNULL, # Never hand the child our stdin cwd=self.working_dir, ) + _assign_to_windows_job(process.pid) # GAP-024, see above + _spawn_descendant_sweep(process.pid) else: - # No bash found - fall back to limited cmd.exe behavior - # Check for shell features that won't work in cmd.exe - shell_features = ["|", "&&", "||", "~", ">", "<", "2>&1", "$(", "`"] - if any(feature in command for feature in shell_features): - return { - "stdout": "", - "stderr": ( - "Bash not found in PATH.\n" - "\n" - "Shell features like |, &&, ||, ~, redirects require bash.\n" - "\n" - "Install Git for Windows (includes Git Bash):\n" - " https://git-scm.com/download/win\n" - "\n" - "Or install WSL:\n" - " https://learn.microsoft.com/en-us/windows/wsl/install" - ), - "returncode": 1, - } - - # Windows: Use direct execution (no shell) for simple commands - try: - args = shlex.split(command) - except ValueError as e: - raise ValueError(f"Invalid command syntax: {e}") - - process = await asyncio.create_subprocess_exec( - *args, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=self.working_dir, - ) + # No bash found on Windows. This tool's entire contract is + # POSIX shell semantics (quoting, tilde expansion, &&/||/|, + # redirects, command substitution) -- there is no cmd.exe + # fallback, and there never should be a *partial* one. + # Previously, only commands containing an obvious shell + # metacharacter got this actionable error; anything else + # (`echo hello`, `ls`, `dir`, cmd.exe builtins like `cd`, + # `type`, `set`, `copy`, ...) fell through to + # shlex.split() + exec-with-no-shell-at-all and failed with + # a bare `[WinError 2] The system cannot find the file + # specified` -- naming neither the cause nor the fix. A + # tool named `bash` silently running some commands with no + # shell (or, worse, through cmd.exe) is a degraded state + # pretending to be a working one: the user's mental model + # breaks the moment quoting or a builtin behaves + # differently, with no signal why. Fail loud, unconditionally, + # for every command, with the real cause and the fix. + return { + "stdout": "", + "stderr": ( + "Bash not found in PATH.\n" + "\n" + "This tool requires bash for POSIX shell semantics " + "(quoting, tilde expansion, pipes, redirects, " + "command substitution). Without it, even simple " + "commands cannot be run with correct, predictable " + "behavior.\n" + "\n" + "Install Git for Windows (includes Git Bash):\n" + " https://git-scm.com/download/win\n" + "\n" + "Or install WSL:\n" + " https://learn.microsoft.com/en-us/windows/wsl/install" + ), + "returncode": 1, + } else: # Unix-like (Linux, macOS, WSL): Use real bash shell # This enables: diff --git a/pyproject.toml b/pyproject.toml index 7c71d40..0556526 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,3 +36,9 @@ testpaths = ["tests"] addopts = "--import-mode=importlib" asyncio_mode = "strict" +[dependency-groups] +dev = [ + "amplifier-core", + "pytest>=8", + "pytest-asyncio>=0.24", +] diff --git a/tests/test_foreground_stdin_isolation.py b/tests/test_foreground_stdin_isolation.py index 6fbf919..3ca11c9 100644 --- a/tests/test_foreground_stdin_isolation.py +++ b/tests/test_foreground_stdin_isolation.py @@ -38,6 +38,21 @@ is not covered by this pty-based test). """ +import sys + +import pytest + +# `pty` is POSIX-only and is imported at module scope, so on Windows this +# file fails at COLLECTION -- a hard ERROR, not a skip. The pytestmark +# below is evaluated only AFTER the module body has already executed, so +# it cannot prevent the import from raising. An ImportError during +# collection is indistinguishable in CI output from a real breakage. +if sys.platform == "win32": + pytest.skip( + "POSIX-only: requires pty, which has no Windows equivalent", + allow_module_level=True, + ) + import asyncio import contextlib import os diff --git a/tests/test_gap013_windows_job_object_orphans.py b/tests/test_gap013_windows_job_object_orphans.py new file mode 100644 index 0000000..f36f558 --- /dev/null +++ b/tests/test_gap013_windows_job_object_orphans.py @@ -0,0 +1,326 @@ +"""Regression test: GAP-013 / GAP-024 -- a tool-call subprocess (and its own +descendants) must not outlive the host process, even when the host is +killed outright rather than shut down through this module's own code. + +## Why this test exists + +GAP-013 ("orphaned process trees left after amplifier exits") was first +retracted as a harness artifact -- the two experiments behind that +retraction never actually had a tool-call subprocess in flight at the +moment of the kill. An adversarial re-test built that missing state (a +live ``bash`` tool subprocess, then ``Stop-Process`` on *only* the +top-level PID -- no ``/T``, no console Ctrl+C) and found a real orphan: +Windows has no parent-death signal (no ``PR_SET_PDEATHSIG`` equivalent), +so a subprocess this module spawns has no way to notice its parent died, +and none of this module's own cleanup code runs to catch it. + +The fix (GAP-024) is a lazily-created Windows Job Object per process, +with ``JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`` set, and every foreground +subprocess this module spawns is assigned to it via +``_assign_to_windows_job``. The job's only handle lives in the host +process; when that process ends for ANY reason -- including a forceful +kill that runs none of this module's own Python -- the OS itself closes +the handle and the kernel tears down every process still assigned to the +job. This does not depend on any of our code running, so it covers +crashes too, not just graceful shutdown. + +Nothing previously locked this in as an automated regression -- the only +proof was a one-shot manual test against a specific process tree on a +specific box. If the job-object wiring regresses (the assignment call is +dropped from a new/changed subprocess-spawn site, ``AssignProcessToJobObject`` +silently starts failing, or ``JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`` is +accidentally cleared), this test is what catches it. + +## Design + +This exercises the REAL ``_get_windows_job_object`` / ``_assign_to_windows_job`` +functions from ``amplifier_module_tool_bash`` -- not a reimplementation -- +against a small, deterministic two-level process tree, rather than +depending on WSL/bash being installed on whatever box runs this suite +(the real GAP-013 scenario happened to go through a WSL-routed ``bash`` +tool call, but the fix and the guarantee it protects are generic to every +foreground subprocess this module spawns). + +* A *harness* process stands in for the host (``amplifier.exe``): it + spawns a *grandchild* process, assigns the grandchild to a Job Object + using this module's own real functions, and writes PID markers for + both. +* **Case (a) -- normal completion:** the grandchild is short-lived, the + harness waits for it and then exits on its own. Asserts nothing is left + running -- the job-object wiring must not itself cause anything to + linger under the ordinary, successful-completion path. +* **Case (b) -- abnormal external kill:** the grandchild is long-lived, + and once both are confirmed alive, the test kills ONLY the harness PID + (``taskkill /F /PID`` -- deliberately no ``/T``, mirroring + ``Stop-Process -Id`` with no children flag, the exact case the original + false retraction never tested). Asserts the grandchild dies anyway, + without anything explicitly killing it -- proving the OS-level + kill-on-close mechanism, not just a Python cleanup path that happens to + run. +""" + +from __future__ import annotations + +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +import amplifier_module_tool_bash as tool_bash_pkg + +pytestmark = pytest.mark.skipif( + sys.platform != "win32", + reason="GAP-013/024 orphan-prevention is a Windows-only mechanism (Job Objects)", +) + + +_GRANDCHILD_SCRIPT = """ +import sys +import os +import time +from pathlib import Path + +marker_dir = Path(sys.argv[1]) +sleep_s = float(sys.argv[2]) + +(marker_dir / "grandchild_started.pid").write_text(str(os.getpid())) +time.sleep(sleep_s) +(marker_dir / "grandchild_finished.marker").write_text("done") +""" + +# The harness stands in for "amplifier.exe": it spawns a grandchild and +# assigns it to a kill-on-close Job Object using the module's REAL +# functions (imported, not reimplemented), then either waits for a short +# grandchild to finish on its own (case a) or sleeps indefinitely, +# simulating a live process to be killed externally (case b). +_HARNESS_SCRIPT = """ +import sys +import os +import subprocess +import time +from pathlib import Path + +mode = sys.argv[1] +marker_dir = Path(sys.argv[2]) +grandchild_script = sys.argv[3] +grandchild_sleep_s = sys.argv[4] + +from amplifier_module_tool_bash import _assign_to_windows_job + +grandchild = subprocess.Popen( + [sys.executable, grandchild_script, str(marker_dir), grandchild_sleep_s] +) +_assign_to_windows_job(grandchild.pid) + +(marker_dir / "harness.pid").write_text(str(os.getpid())) +(marker_dir / "grandchild.pid").write_text(str(grandchild.pid)) + +if mode == "normal": + grandchild.wait() + # Falls off the end and exits cleanly right after its own child does -- + # this is the ordinary "tool call completed" path. +elif mode == "abnormal": + # Simulates a live amplifier.exe mid-turn. Deliberately never exits on + # its own -- the test kills it externally to exercise the job-object + # kill-on-close path, not a graceful shutdown. + time.sleep(120) +""" + + +def _pid_alive(pid: int) -> bool: + """Best-effort Windows liveness check via ``tasklist`` (no admin needed).""" + result = subprocess.run( + ["tasklist", "/FI", f"PID eq {pid}"], + capture_output=True, + text=True, + check=False, + ) + return str(pid) in result.stdout + + +def _wait_for_marker(path: Path, timeout_s: float = 10.0) -> str: + """Poll for a marker file to appear with non-empty content.""" + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if path.exists(): + text = path.read_text().strip() + if text: + return text + time.sleep(0.1) + raise TimeoutError(f"marker file {path} never appeared with content") + + +def _force_kill(pid: int) -> None: + """Best-effort cleanup so a failing test never leaks a stand-in process.""" + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(pid)], + capture_output=True, + check=False, + ) + + +def _write_scripts(tmp_path: Path) -> tuple[Path, Path]: + harness_script = tmp_path / "gap013_harness.py" + grandchild_script = tmp_path / "gap013_grandchild.py" + harness_script.write_text(_HARNESS_SCRIPT) + grandchild_script.write_text(_GRANDCHILD_SCRIPT) + return harness_script, grandchild_script + + +class TestGap013NormalCompletionLeavesNoOrphans: + """Case (a): ordinary, successful completion -- nothing left behind.""" + + def test_normal_completion_no_surviving_descendants(self, tmp_path: Path) -> None: + harness_script, grandchild_script = _write_scripts(tmp_path) + + harness = subprocess.Popen( + [ + sys.executable, + str(harness_script), + "normal", + str(tmp_path), + str(grandchild_script), + "2", # short-lived grandchild: 2s + ] + ) + + harness_pid: int | None = None + grandchild_pid: int | None = None + try: + grandchild_pid = int(_wait_for_marker(tmp_path / "grandchild.pid")) + harness_pid = int(_wait_for_marker(tmp_path / "harness.pid")) + + # Bounded wait for the harness to exit on its own. Budget: + # ~2s grandchild sleep + interpreter startup/shutdown overhead + # on both processes. 20s gives generous headroom without + # masking a real hang. + deadline = time.monotonic() + 20 + while time.monotonic() < deadline and harness.poll() is None: + time.sleep(0.2) + + assert harness.poll() is not None, ( + "harness process never exited on its own within 20s during " + "normal completion -- unrelated to GAP-013, but a real " + "regression in the stand-in harness or the module import" + ) + + # Give the OS a brief moment to fully reap/update process state. + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and _pid_alive(grandchild_pid): + time.sleep(0.2) + + assert not _pid_alive(grandchild_pid), ( + f"grandchild process (pid {grandchild_pid}) was still alive " + "after the harness completed normally -- normal-completion " + "cleanup regressed" + ) + assert not _pid_alive(harness_pid), ( + f"harness process (pid {harness_pid}) reported exited but " + "is still visible in the process table" + ) + finally: + if harness.poll() is None: + harness.kill() + if harness_pid is not None: + _force_kill(harness_pid) + if grandchild_pid is not None: + _force_kill(grandchild_pid) + + +class TestGap013ExternalKillStillReapsDescendantViaJobObject: + """Case (b): the exact state the original false retraction never + tested -- external kill of ONLY the top-level PID, no /T, no console + Ctrl+C.""" + + def test_kill_top_level_pid_only_still_kills_grandchild( + self, tmp_path: Path + ) -> None: + harness_script, grandchild_script = _write_scripts(tmp_path) + + harness = subprocess.Popen( + [ + sys.executable, + str(harness_script), + "abnormal", + str(tmp_path), + str(grandchild_script), + "120", # long-lived grandchild: would run for 2 minutes + # unassisted -- only the job-object kill-on-close should + # end it early. + ] + ) + + harness_pid: int | None = None + grandchild_pid: int | None = None + try: + grandchild_pid = int(_wait_for_marker(tmp_path / "grandchild.pid")) + harness_pid = int(_wait_for_marker(tmp_path / "harness.pid")) + _wait_for_marker(tmp_path / "grandchild_started.pid") + + assert _pid_alive(harness_pid), "harness never actually started" + assert _pid_alive(grandchild_pid), ( + "grandchild never actually started -- can't test whether " + "killing the harness reaps it" + ) + + # Kill ONLY the top-level harness PID. Deliberately no /T (no + # process-tree flag) and no console Ctrl+C -- this is the exact + # state ("Stop-Process -Id ", no children flag) that the + # original GAP-013 investigation's false retraction never + # exercised. + kill_result = subprocess.run( + ["taskkill", "/F", "/PID", str(harness_pid)], + capture_output=True, + text=True, + check=False, + ) + assert kill_result.returncode == 0, ( + f"failed to kill harness pid {harness_pid} for the test " + f"itself: {kill_result.stderr}" + ) + + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and _pid_alive(harness_pid): + time.sleep(0.2) + assert not _pid_alive(harness_pid), ( + "harness process survived being killed -- broken test " + "setup, not a GAP-013 finding" + ) + + # The grandchild has NOTHING killing it directly -- only the + # Job Object's kill-on-close semantics, triggered by Windows + # closing the harness's job handle when that process ends, + # should bring it down. Bound: this is an OS-level handle-close + # notification, not anything involving network/auth variance, + # so it should be near-instant -- but 15s gives real headroom + # above what a loaded CI box might need for the kernel to + # process the handle closure and for tasklist to reflect it. + deadline = time.monotonic() + 15 + while time.monotonic() < deadline and _pid_alive(grandchild_pid): + time.sleep(0.2) + + assert not _pid_alive(grandchild_pid), ( + f"grandchild process (pid {grandchild_pid}) survived the " + "external kill of ONLY the top-level harness PID -- this " + "is GAP-013's exact orphan signature. The Job Object " + "kill-on-close wiring (_assign_to_windows_job / GAP-024) " + "regressed." + ) + finally: + if harness.poll() is None: + harness.kill() + if harness_pid is not None: + _force_kill(harness_pid) + if grandchild_pid is not None: + _force_kill(grandchild_pid) + + +def test_module_exposes_the_functions_this_test_depends_on() -> None: + """Sanity check: fail loudly and clearly if a refactor renames/removes + the real functions this test imports inside the harness subprocess, + rather than surfacing as a confusing harness-side ImportError deep in + a subprocess with no visible traceback.""" + assert hasattr(tool_bash_pkg, "_assign_to_windows_job") + assert hasattr(tool_bash_pkg, "_get_windows_job_object") diff --git a/tests/test_gap_bash_missing_actionable_error.py b/tests/test_gap_bash_missing_actionable_error.py new file mode 100644 index 0000000..98f1317 --- /dev/null +++ b/tests/test_gap_bash_missing_actionable_error.py @@ -0,0 +1,135 @@ +"""Regression test: bash-not-found on Windows must give an actionable error +for EVERY command, not a bare OS error for "simple" ones. + +## Why this test exists + +Confirmed on Windows with no bash on PATH (patching ``shutil.which`` to +return ``None`` and driving the real installed ``BashTool.execute()``): + +| Command | Result | +|------------------------------|--------------------------------------------| +| ``ls -la \\| head -3`` (shell) | actionable error naming bash + install URLs | +| ``echo hello`` | bare ``[WinError 2] ...`` | +| ``ls`` | bare ``[WinError 2] ...`` | +| ``dir`` | bare ``[WinError 2] ...`` | + +Root cause: the no-bash-on-Windows branch only returned the actionable +error when the command contained an obvious shell metacharacter (``|``, +``&&``, etc). Anything else fell through to ``shlex.split(command)`` + +``create_subprocess_exec`` with **no shell at all**, so every cmd.exe +builtin (echo, dir, cd, type, set, copy) failed with a bare OS error naming +neither the cause nor the fix. + +The fix makes the actionable error unconditional: if bash is not found on +Windows, EVERY command gets the actionable message. This tool's entire +contract is POSIX shell semantics; there is no partial/degraded fallback. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest +from amplifier_module_tool_bash import BashTool + + +@pytest.mark.asyncio +async def test_simple_command_gets_actionable_error_when_bash_missing() -> None: + """`echo hello` (no shell metacharacters) must still get the actionable + "bash not found" error when bash is missing on Windows -- not a bare + OS error from trying to exec a non-existent program with no shell. + """ + tool = BashTool({}) + + # A spy on create_subprocess_exec: if this is ever called, the fix + # regressed back to the no-shell fallback path this test exists to + # forbid. + exec_spy = AsyncMock() + + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch("amplifier_module_tool_bash.shutil.which", return_value=None), + # Neutralising PATH alone is NOT "no bash on this machine" any more. + # Git Bash discovery probes well-known install locations directly on + # the filesystem, precisely because Git for Windows never puts + # Git\bin on PATH. On a real Windows box with Git installed, this + # test passed on Linux (where it is skipped) and failed on Windows: + # bash WAS found, the actionable error never fired, and execution + # reached create_subprocess_exec. + patch("amplifier_module_tool_bash._find_git_bash_executable", return_value=None), + patch("amplifier_module_tool_bash._find_wsl_bash_executable", return_value=None), + patch("amplifier_module_tool_bash.asyncio.create_subprocess_exec", exec_spy), + ): + result = await tool._run_command("echo hello", timeout=5) + + assert not exec_spy.called, ( + "no-bash-on-Windows fell through to create_subprocess_exec (no " + "shell at all) for a command with no shell metacharacters -- this " + "is the exact regression the fix removes: a plain command like " + "`echo hello` must never be executed with no shell, it must get " + "the actionable bash-missing error like every other command" + ) + assert result["returncode"] != 0 + assert "WinError" not in result["stderr"], ( + f"got a bare OS error instead of the actionable message: {result['stderr']!r}" + ) + assert "bash" in result["stderr"].lower() + assert ( + "git-scm.com" in result["stderr"] + or "git for windows" in result["stderr"].lower() + ) + assert "wsl" in result["stderr"].lower() + + +@pytest.mark.asyncio +async def test_shell_metacharacter_command_still_gets_actionable_error() -> None: + """The pre-existing good case (a command with shell features) must keep + working -- this fix removes the *conditional*, it doesn't touch the + message itself. + """ + tool = BashTool({}) + exec_spy = AsyncMock() + + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch("amplifier_module_tool_bash.shutil.which", return_value=None), + # See the note in the test above: PATH is no longer the only way bash + # is found on Windows, so a "no bash" simulation has to neutralise the + # filesystem probes too. + patch("amplifier_module_tool_bash._find_git_bash_executable", return_value=None), + patch("amplifier_module_tool_bash._find_wsl_bash_executable", return_value=None), + patch("amplifier_module_tool_bash.asyncio.create_subprocess_exec", exec_spy), + ): + result = await tool._run_command("ls -la | head -3", timeout=5) + + assert not exec_spy.called + assert result["returncode"] != 0 + assert "bash" in result["stderr"].lower() + + +@pytest.mark.asyncio +async def test_posix_is_unaffected_when_bash_is_missing() -> None: + """This fix only changes the Windows no-bash branch. On POSIX, bash + missing is a wholly different (and pre-existing, out of scope) code + path -- verify it isn't touched by asserting the Windows-only spy + (create_subprocess_exec) is never reached via that branch when the + platform is POSIX. + """ + tool = BashTool({}) + exec_spy = AsyncMock() + + with ( + patch("amplifier_module_tool_bash.sys.platform", "linux"), + patch("amplifier_module_tool_bash.shutil.which", return_value=None), + patch("amplifier_module_tool_bash.asyncio.create_subprocess_exec", exec_spy), + patch( + "amplifier_module_tool_bash.asyncio.create_subprocess_shell", + AsyncMock(side_effect=RuntimeError("posix path reached shell exec")), + ),pytest.raises(RuntimeError, match="posix path reached shell exec") + ): + await tool._run_command("echo hello", timeout=5) + + assert not exec_spy.called, ( + "the Windows no-shell fallback (create_subprocess_exec) ran on " + "POSIX -- the platform guard leaks" + ) diff --git a/tests/test_timeout_process_cleanup.py b/tests/test_timeout_process_cleanup.py index cb956cf..4721cb5 100644 --- a/tests/test_timeout_process_cleanup.py +++ b/tests/test_timeout_process_cleanup.py @@ -18,6 +18,7 @@ import asyncio import os +import shlex import signal import sys @@ -55,15 +56,44 @@ class TestTimeoutKillsSetsidDetachedDescendants: @pytest.mark.asyncio async def test_setsid_detached_child_is_killed_on_timeout(self, tmp_path): - """A `setsid`-detached grandchild must not survive timeout cleanup.""" + """A setsid-detached grandchild must not survive timeout cleanup. + + Detachment is performed via `os.setsid()` inside an inline Python + process rather than shelling out to the `setsid(1)` binary: `setsid` + is a util-linux program that Linux ships but macOS does not (no + `/usr/bin/setsid` on macOS -- confirmed via + https://stackoverflow.com/questions/36590905, and the existence of + third-party "ersatz setsid" replacements written specifically to fill + that gap on macOS). Shelling out to `setsid bash -c '...'` made this + test *vacuous* on macOS: the subshell failed instantly with + "command not found", no marker was ever written, and the test failed + at the marker-existence assertion without ever exercising the actual + cleanup hazard. + + Calling `os.setsid()` directly is portable (it's a POSIX syscall + exposed by Python's `os` module on every Unix, including macOS) and + exercises the identical hazard: a descendant that moves itself to a + new session/process group, whose PPID chain back to the timed-out + command is preserved (setsid() never reparents). + """ marker = tmp_path / "detached_child.pid" tool = BashTool({}) - # Spawn a detached grandchild via setsid that records its own PID, - # then sleeps far longer than the tool timeout. The outer `sleep` - # keeps the parent bash alive past the timeout so the tool actually - # times out (rather than exiting cleanly on its own). - command = f"setsid bash -c 'echo $$ > {marker}; sleep 60' & sleep 30" + # Spawn a detached grandchild that calls os.setsid() on itself, + # records its own PID, then sleeps far longer than the tool timeout. + # The outer `sleep` keeps the parent bash alive past the timeout so + # the tool actually times out (rather than exiting cleanly on its + # own). + detach_snippet = ( + "import os\n" + "os.setsid()\n" + f"open({str(marker)!r}, 'w').write(str(os.getpid()))\n" + "import time\n" + "time.sleep(60)\n" + ) + command = ( + f"{shlex.quote(sys.executable)} -c {shlex.quote(detach_snippet)} & sleep 30" + ) detached_pid: int | None = None try: diff --git a/tests/test_windows_ctypes_signatures.py b/tests/test_windows_ctypes_signatures.py new file mode 100644 index 0000000..b7f7d52 --- /dev/null +++ b/tests/test_windows_ctypes_signatures.py @@ -0,0 +1,157 @@ +"""Regression test: every Win32 ctypes call site declares argtypes/restype. + +## Why this test exists + +ctypes silently defaults an undeclared `restype`/`argtypes` to C `int` +(32-bit signed). On 64-bit Windows, a HANDLE or pointer-sized value routed +through that default gets truncated -- producing wrong results or memory +corruption rather than a clean error. This is exactly the kind of bug that +looks fine in review and only misbehaves on a real 64-bit Windows box. + +None of the existing suites exercise this class of bug on non-Windows CI: + +- `test_windows_job_call_sites.py` patches `_assign_to_windows_job` and + `_spawn_descendant_sweep` as whole functions, so the ctypes calls inside + them never run. +- `test_gap013_windows_job_object_orphans.py` is `skipif`'d to + `sys.platform == "win32"`, so it executes nowhere in this repo's CI. + +That leaves the signature declarations for `CreateJobObjectW`, +`SetInformationJobObject`, `OpenProcess`, `AssignProcessToJobObject`, +`CreateToolhelp32Snapshot`, `Process32First`, `Process32Next`, and +`CloseHandle` unverified anywhere except a real Windows machine. + +`ctypes.WinDLL` does not exist as a real attribute on non-Windows platforms +(hence `create=True` below), but `ctypes.wintypes` is plain, portable +`ctypes.Structure`/`c_*` aliasing that imports and behaves identically on +any platform. That means the three Windows-only functions under test can +run for real here -- only the DLL handle itself is a stand-in -- so this +test drives the genuine signature-declaration code, not a paraphrase of it, +and asserts that every Win32 function actually invoked had both `argtypes` +and `restype` explicitly assigned. It runs on Linux, macOS, and CI, today. +""" + +from __future__ import annotations + +import ctypes +from unittest.mock import MagicMock, patch + +import amplifier_module_tool_bash as mod + + +def _assert_signature_declared(kernel32_mock: MagicMock, name: str) -> None: + fn = getattr(kernel32_mock, name) + assert fn.argtypes is not None, ( + f"kernel32.{name} has no argtypes declared -- ctypes will default " + "arguments to C int, truncating a 64-bit pointer/HANDLE on 64-bit " + "Windows" + ) + assert fn.restype is not None, ( + f"kernel32.{name} has no restype declared -- ctypes defaults the " + "return value to C int, truncating a 64-bit HANDLE on 64-bit " + "Windows" + ) + + +class TestWin32CallSignatures: + """Drives the real Windows-only functions with a mocked kernel32 DLL + handle so the signature declarations are exercised on any platform. + """ + + def setup_method(self) -> None: + # Module-level cache; must not leak between tests or across the + # rest of the suite. + self._saved_job_handle = mod._windows_job_handle + mod._windows_job_handle = None + + def teardown_method(self) -> None: + mod._windows_job_handle = self._saved_job_handle + + def test_job_object_creation_declares_signatures(self) -> None: + kernel32 = MagicMock(name="kernel32") + windll_factory = MagicMock(return_value=kernel32) + + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch.object(ctypes, "WinDLL", windll_factory, create=True), + ): + job = mod._get_windows_job_object() + + assert job is not None, ( + "job creation must succeed against a mocked, all-truthy kernel32" + ) + for name in ("CreateJobObjectW", "SetInformationJobObject", "CloseHandle"): + _assert_signature_declared(kernel32, name) + + def test_assign_to_job_declares_signatures(self) -> None: + kernel32 = MagicMock(name="kernel32") + windll_factory = MagicMock(return_value=kernel32) + + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch.object(ctypes, "WinDLL", windll_factory, create=True), + ): + assigned = mod._assign_to_windows_job(4242) + + assert assigned is True, ( + "assignment must succeed against a mocked, all-truthy kernel32" + ) + for name in ("OpenProcess", "AssignProcessToJobObject", "CloseHandle"): + _assert_signature_declared(kernel32, name) + + def test_descendant_enumeration_declares_signatures(self) -> None: + kernel32 = MagicMock(name="kernel32") + # Falsy on the first call ends the process-table walk immediately + # (simulates an empty/failed walk) -- a real MagicMock is truthy + # forever, which would otherwise spin the `while True` loop in + # `_enumerate_child_pids_windows` forever via `Process32Next`. + kernel32.Process32First.return_value = False + windll_factory = MagicMock(return_value=kernel32) + + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch.object(ctypes, "WinDLL", windll_factory, create=True), + ): + children = mod._enumerate_child_pids_windows(1234) + + assert children == set() + # All four signatures are declared unconditionally before the walk + # begins, so this holds even though Process32Next is never reached. + for name in ( + "CreateToolhelp32Snapshot", + "Process32First", + "Process32Next", + "CloseHandle", + ): + _assert_signature_declared(kernel32, name) + + def test_snapshot_invalid_handle_value_is_treated_as_failure(self) -> None: + """CreateToolhelp32Snapshot signals failure via INVALID_HANDLE_VALUE, + i.e. `(HANDLE)-1` -- NOT NULL. With a HANDLE (c_void_p) restype, + ctypes converts that bit pattern to a large *positive* Python int + (18446744073709551615 on 64-bit) -- never to the Python literal -1 + or to 0. A guard written as ``if snap in (-1, 0)`` therefore never + matches this real failure value and falls through to call + Process32First/CloseHandle on an invalid handle. + + This test must fail against that old guard: with it restored, the + invalid-handle value is not caught, so both Process32First and + CloseHandle get called on the invalid handle below (Process32First + is stubbed to return False purely so the walk terminates instead of + spinning forever on an always-truthy MagicMock Process32Next -- + it does NOT make the old guard correct). + """ + kernel32 = MagicMock(name="kernel32") + kernel32.CreateToolhelp32Snapshot.return_value = ctypes.c_void_p(-1).value + kernel32.Process32First.return_value = False + windll_factory = MagicMock(return_value=kernel32) + + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch.object(ctypes, "WinDLL", windll_factory, create=True), + ): + children = mod._enumerate_child_pids_windows(1234) + + assert children == set() + kernel32.Process32First.assert_not_called() + kernel32.CloseHandle.assert_not_called() diff --git a/tests/test_windows_job_call_sites.py b/tests/test_windows_job_call_sites.py new file mode 100644 index 0000000..d640404 --- /dev/null +++ b/tests/test_windows_job_call_sites.py @@ -0,0 +1,153 @@ +"""Contract tests for Windows orphan-protection wiring. + +An independent review found the existing GAP-013 test drives +``_assign_to_windows_job`` **directly**, bypassing ``_run_command`` entirely. +That guards "does the raw ctypes wiring work" -- useful -- but not the contract +the module actually promises: *every foreground subprocess this module spawns +is assigned to the kill-on-close job*. If a refactor dropped one of the three +call sites, or added a fourth path without the call, that suite would still +pass. + +These tests close that gap, and deliberately run on **every** platform. The +Windows-only test file needs a Windows box and there is no Windows CI in this +repo, so its 3 tests execute nowhere automatically. Call-site wiring does not +need a real kernel to verify -- patch ``sys.platform`` and the two helpers, and +assert the real ``_run_command`` reaches them. That runs on Linux, on macOS, in +CI, today. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest +from amplifier_module_tool_bash import BashTool + + +class _FakeProc: + """Minimal stand-in for an asyncio subprocess.""" + + def __init__(self, pid: int = 424242) -> None: + self.pid = pid + self.returncode = 0 + + async def communicate(self, *_: Any, **__: Any) -> tuple[bytes, bytes]: + return (b"ok", b"") + + async def wait(self) -> int: + return 0 + + def kill(self) -> None: # pragma: no cover - not exercised here + pass + + +@pytest.mark.asyncio +async def test_windows_foreground_subprocess_is_job_assigned() -> None: + """The real _run_command must job-assign the PID it spawned. + + Patches only the platform and the two Win32 helpers -- the command-dispatch + logic under test is the genuine article. + """ + tool = BashTool({}) + proc = _FakeProc() + + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch( + "amplifier_module_tool_bash._assign_to_windows_job", MagicMock() + ) as assign, + patch("amplifier_module_tool_bash._spawn_descendant_sweep", MagicMock()), + patch( + "amplifier_module_tool_bash.asyncio.create_subprocess_exec", + AsyncMock(return_value=proc), + ), + patch( + "amplifier_module_tool_bash.asyncio.create_subprocess_shell", + AsyncMock(return_value=proc), + ), + patch("amplifier_module_tool_bash.shutil.which", return_value="C:\\bash.exe"), + ): + await tool._run_command("echo hi", timeout=5) + + assert assign.called, ( + "_run_command spawned a foreground subprocess on Windows without " + "assigning it to the kill-on-close job object. That is the entire " + "orphan protection this module promises." + ) + assert assign.call_args.args[0] == proc.pid, ( + f"job assignment used {assign.call_args.args[0]!r}, not the spawned " + f"pid {proc.pid!r}" + ) + + +@pytest.mark.asyncio +async def test_posix_never_touches_the_windows_helpers() -> None: + """POSIX must take a byte-identical path to what shipped before. + + The guard that matters most in this whole change: Linux, macOS and WSL + users currently work, and a Windows fix that reaches them is a net loss. + """ + tool = BashTool({}) + proc = _FakeProc() + + with ( + patch("amplifier_module_tool_bash.sys.platform", "linux"), + patch( + "amplifier_module_tool_bash._assign_to_windows_job", MagicMock() + ) as assign, + patch( + "amplifier_module_tool_bash._spawn_descendant_sweep", MagicMock() + ) as sweep, + patch( + "amplifier_module_tool_bash.asyncio.create_subprocess_exec", + AsyncMock(return_value=proc), + ), + patch( + "amplifier_module_tool_bash.asyncio.create_subprocess_shell", + AsyncMock(return_value=proc), + ), + ): + await tool._run_command("echo hi", timeout=5) + + assert not assign.called, ( + "a Windows job-object helper ran on POSIX -- the platform guard leaks" + ) + assert not sweep.called, ( + "the Windows descendant sweep ran on POSIX -- the platform guard leaks" + ) + + +def test_job_failure_is_reported_loudly_once_not_silently() -> None: + """An environmental job failure must be visible, and must not spam. + + Before this, every failure was ``logger.debug`` and every caller discarded + the return value, so the protection could be entirely inert under a + restrictive parent job object -- the normal state on CI runners and in + Windows containers -- with no operator-visible signal whatsoever. + """ + import amplifier_module_tool_bash as mod + + original = mod._windows_job_failure_reported + try: + mod._windows_job_failure_reported = False + with patch.object(mod, "logger") as log: + mod._report_windows_job_failure("first failure (%s)", "boom") + mod._report_windows_job_failure("second failure (%s)", "boom") + + assert log.warning.call_count == 1, ( + f"expected exactly one warning, got {log.warning.call_count}. " + "Zero means the failure is invisible; more than one means every " + "command spams and people learn to ignore it." + ) + assert log.debug.call_count == 1, ( + "subsequent failures should still be recorded at debug level" + ) + warned = log.warning.call_args.args[0] + assert "NOT active" in warned, ( + f"warning does not state the protection is inactive: {warned!r}" + ) + finally: + mod._windows_job_failure_reported = original diff --git a/tests/test_windows_shell_resolution.py b/tests/test_windows_shell_resolution.py new file mode 100644 index 0000000..975f367 --- /dev/null +++ b/tests/test_windows_shell_resolution.py @@ -0,0 +1,513 @@ +"""Regression tests: Windows shell resolution (Git Bash discoverability, +resolved-shell observability, foreground/background agreement). + +## Why these tests exist + +Confirmed on a real Windows 11 box with Git for Windows 2.55.0.3 installed +a month prior to testing: + + shutil.which('bash') -> 'C:\\Windows\\system32\\bash.EXE' # WSL launcher + + persisted registry PATH: + MACHINE: ... C:\\Windows\\system32 (position 4) ... + C:\\Program Files\\Git\\cmd (position ~24) + USER: (no Git entries) + +Root cause: Git for Windows' *default* install puts `Git\\cmd` on PATH +(git.exe lives there) but NOT `Git\\bin` (bash.exe lives there). Meanwhile +the WSL launcher stub at `C:\\Windows\\System32\\bash.exe` is effectively +always on PATH. So `shutil.which("bash")` resolves the WSL launcher every +time, and Git Bash is unreachable no matter what's installed -- even +though a `bash` call against a WSL box reaches a different filesystem, +$HOME, and toolchain than a `bash` call against Git Bash: + + WSL bash Git Bash + git --version git version 2.43.0 git version 2.55.0.windows.3 + which python NO_PYTHON /c/Python314/python + uname -s Linux MINGW64_NT-10.0-26200 + $HOME /home/brkrabac /c/Users/brkrabac + +These tests run on any platform (they patch `sys.platform`, `shutil.which`, +`os.environ`, `os.path.isfile`, and the subprocess-spawning calls) and +assert the resolution OUTCOME, not the real OS state. +""" + +from __future__ import annotations + +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import amplifier_module_tool_bash as mod +import pytest +from amplifier_module_tool_bash import ( + BashTool, + _arbitrate_windows_shell, + _find_git_bash_executable, + _find_wsl_bash_executable, +) + +# Built with os.path.join (not hardcoded backslash strings) so these match +# exactly what the production code computes regardless of which platform +# the test SUITE happens to run on -- production always runs on real +# Windows (backslash-joining `ntpath`), but these tests run on Linux/macOS +# CI too, where `os.path.join` joins with "/" instead. Using the same +# join call here as the code under test keeps the comparison meaningful +# without asserting anything about actual separator characters. +PROGRAM_FILES = r"C:\Program Files" +SYSTEM_ROOT = r"C:\Windows" +WSL_LAUNCHER = os.path.join(SYSTEM_ROOT, "System32", "bash.exe") +GIT_BASH = os.path.join(PROGRAM_FILES, "Git", "bin", "bash.exe") + + +class _FakeProc: + """Minimal stand-in for an asyncio subprocess, returncode configurable.""" + + def __init__(self, pid: int = 999, returncode: int = 0) -> None: + self.pid = pid + self.returncode = returncode + + async def communicate(self, *_args, **_kwargs) -> tuple[bytes, bytes]: + return (b"", b"") + + +def _is_wsl_subprocess_mock(returncode: int) -> AsyncMock: + """Build an AsyncMock standing in for asyncio.create_subprocess_exec, + used by `_is_wsl_bash`'s `test -d /mnt/wsl` check. returncode=0 means + "is WSL", nonzero means "is not WSL". + """ + return AsyncMock(return_value=_FakeProc(returncode=returncode)) + + +# --------------------------------------------------------------------------- +# 1. Pure arbitration function -- fast, no async/platform patching needed. +# --------------------------------------------------------------------------- + + +class TestArbitrateWindowsShell: + """Unit tests for the shared, pure decision function.""" + + def test_auto_uses_path_result_outright_when_present(self): + """This is the crux of "don't change the default for existing + users": in auto mode, if PATH resolves ANYTHING, it wins -- + exactly today's behavior -- regardless of what else is + discoverable via the well-known install-location probes. + """ + exe, is_wsl = _arbitrate_windows_shell( + "auto", + path_bash=WSL_LAUNCHER, + path_bash_is_wsl=True, + git_bash_candidate=GIT_BASH, # discoverable, but must NOT win + wsl_bash_candidate=WSL_LAUNCHER, + ) + assert exe == WSL_LAUNCHER + assert is_wsl is True + + def test_auto_falls_back_to_gitbash_when_path_resolves_nothing(self): + """Strict improvement: previously a hard 'bash not found' error + even when Git Bash was actually installed, because PATH alone + never sees it (Git\\bin is not on PATH by default). + """ + exe, is_wsl = _arbitrate_windows_shell( + "auto", + path_bash=None, + path_bash_is_wsl=False, + git_bash_candidate=GIT_BASH, + wsl_bash_candidate=None, + ) + assert exe == GIT_BASH + assert is_wsl is False + + def test_auto_with_nothing_discoverable_returns_none(self): + exe, is_wsl = _arbitrate_windows_shell( + "auto", + path_bash=None, + path_bash_is_wsl=False, + git_bash_candidate=None, + wsl_bash_candidate=None, + ) + assert exe is None + assert is_wsl is False + + def test_explicit_gitbash_preference_wins_even_when_path_resolves_wsl(self): + """The reported bug, fixed: a user can force Git Bash even though + PATH resolves the WSL launcher first. + """ + exe, is_wsl = _arbitrate_windows_shell( + "gitbash", + path_bash=WSL_LAUNCHER, + path_bash_is_wsl=True, + git_bash_candidate=GIT_BASH, + wsl_bash_candidate=WSL_LAUNCHER, + ) + assert exe == GIT_BASH + assert is_wsl is False + + def test_explicit_wsl_preference_wins_even_when_path_resolves_gitbash(self): + exe, is_wsl = _arbitrate_windows_shell( + "wsl", + path_bash=GIT_BASH, + path_bash_is_wsl=False, + git_bash_candidate=GIT_BASH, + wsl_bash_candidate=WSL_LAUNCHER, + ) + assert exe == WSL_LAUNCHER + assert is_wsl is True + + def test_explicit_preference_falls_back_with_warning_when_unavailable(self): + """Asking for gitbash when none is discoverable anywhere falls back + to auto-detection (still gives the user a working shell) rather + than a hard failure -- but must warn, since the request was + explicit and silently ignoring it would be confusing. + """ + with patch.object(mod, "logger") as log: + exe, is_wsl = _arbitrate_windows_shell( + "gitbash", + path_bash=WSL_LAUNCHER, + path_bash_is_wsl=True, + git_bash_candidate=None, + wsl_bash_candidate=WSL_LAUNCHER, + ) + assert exe == WSL_LAUNCHER + assert is_wsl is True + assert log.warning.called + + +# --------------------------------------------------------------------------- +# 2. Filesystem probing helpers +# --------------------------------------------------------------------------- + + +class TestFilesystemProbes: + def test_find_git_bash_executable_probes_known_locations(self): + with ( + patch.dict( + mod.os.environ, + {"ProgramFiles": r"C:\Program Files"}, + clear=True, + ), + patch( + "amplifier_module_tool_bash.os.path.isfile", + side_effect=lambda p: p == GIT_BASH, + ), + ): + assert _find_git_bash_executable() == GIT_BASH + + def test_find_git_bash_executable_returns_none_when_not_installed(self): + with ( + patch.dict(mod.os.environ, {}, clear=True), + patch("amplifier_module_tool_bash.os.path.isfile", return_value=False), + ): + assert _find_git_bash_executable() is None + + def test_find_wsl_bash_executable_probes_system_root(self): + with ( + patch.dict(mod.os.environ, {"SystemRoot": r"C:\Windows"}, clear=True), + patch( + "amplifier_module_tool_bash.os.path.isfile", + side_effect=lambda p: p == WSL_LAUNCHER, + ), + ): + assert _find_wsl_bash_executable() == WSL_LAUNCHER + + +# --------------------------------------------------------------------------- +# 3. BashTool._resolve_windows_bash -- the authoritative, cached resolution +# used to actually execute commands. +# --------------------------------------------------------------------------- + + +class TestResolveWindowsBash: + @pytest.mark.asyncio + async def test_gitbash_selectable_via_config_when_wsl_on_path(self): + """End-to-end version of the reported bug: PATH resolves the WSL + launcher (as it always does when WSL is installed), but the user + has asked (via config) for Git Bash, and Git Bash IS installed at + a well-known location -- it must be discoverable and chosen. + """ + tool = BashTool({"windows_shell": "gitbash"}) + + with ( + patch("amplifier_module_tool_bash.shutil.which", return_value=WSL_LAUNCHER), + patch( + "amplifier_module_tool_bash.asyncio.create_subprocess_exec", + _is_wsl_subprocess_mock(returncode=0), # confirms WSL_LAUNCHER is WSL + ), + patch.dict( + mod.os.environ, {"ProgramFiles": r"C:\Program Files"}, clear=True + ), + patch( + "amplifier_module_tool_bash.os.path.isfile", + side_effect=lambda p: p == GIT_BASH, + ), + ): + exe, is_wsl = await tool._resolve_windows_bash() + + assert exe == GIT_BASH, ( + "requested windows_shell='gitbash' but resolution still picked " + f"{exe!r} -- Git Bash was not made reachable" + ) + assert is_wsl is False + + @pytest.mark.asyncio + async def test_auto_default_unchanged_when_both_present(self): + """Judgement call from the report: auto (default) keeps WSL when + both are installed -- must not silently change behavior for + existing users. + """ + tool = BashTool({}) # no explicit preference -> "auto" + + with ( + patch("amplifier_module_tool_bash.shutil.which", return_value=WSL_LAUNCHER), + patch( + "amplifier_module_tool_bash.asyncio.create_subprocess_exec", + _is_wsl_subprocess_mock(returncode=0), + ), + patch.dict( + mod.os.environ, {"ProgramFiles": r"C:\Program Files"}, clear=True + ), + patch( + "amplifier_module_tool_bash.os.path.isfile", + side_effect=lambda p: p == GIT_BASH, + ), + ): + exe, is_wsl = await tool._resolve_windows_bash() + + assert exe == WSL_LAUNCHER + assert is_wsl is True + + @pytest.mark.asyncio + async def test_auto_fallback_finds_gitbash_when_path_empty(self): + """Previously: shutil.which("bash") returning None meant an + unconditional 'bash not found' error, even with Git Bash actually + installed. Now: auto falls back to the well-known locations. + """ + tool = BashTool({}) + + with ( + patch("amplifier_module_tool_bash.shutil.which", return_value=None), + patch.dict( + mod.os.environ, {"ProgramFiles": r"C:\Program Files"}, clear=True + ), + patch( + "amplifier_module_tool_bash.os.path.isfile", + side_effect=lambda p: p == GIT_BASH, + ), + ): + exe, is_wsl = await tool._resolve_windows_bash() + + assert exe == GIT_BASH + assert is_wsl is False + + @pytest.mark.asyncio + async def test_resolution_is_cached_across_calls(self): + """Resolved once, not per command -- shutil.which must only be + consulted on the first call. + """ + tool = BashTool({}) + which_mock = MagicMock(return_value=WSL_LAUNCHER) + + with ( + patch("amplifier_module_tool_bash.shutil.which", which_mock), + patch( + "amplifier_module_tool_bash.asyncio.create_subprocess_exec", + _is_wsl_subprocess_mock(returncode=0), + ), + ): + first = await tool._resolve_windows_bash() + second = await tool._resolve_windows_bash() + + assert first == second + assert which_mock.call_count == 1, ( + "shutil.which was consulted more than once -- resolution is " + "supposed to be cached per instance, not re-done per command" + ) + + +# --------------------------------------------------------------------------- +# 4. Foreground/background agreement -- the latent cache-default bug. +# --------------------------------------------------------------------------- + + +class TestForegroundBackgroundAgree: + @pytest.mark.asyncio + async def test_background_first_call_still_routes_wsl_through_wrapper(self): + """The bug: `_run_command_background` used to read + `self._wsl_bash_cache.get(bash_exe, False)` directly, defaulting to + False. Issued as the very FIRST command (cold cache, nothing has + populated `_wsl_bash_cache` yet), a WSL bash.exe would be sent down + the Git-Bash direct-exec branch (`[bash_exe, "-c", command]`) + instead of `["wsl", "--exec", "bash", "-c", command]` -- bypassing + the wrapper that exists specifically to prevent the WSL launcher + from prematurely expanding shell variables. + + This must not happen: even as the first call, a WSL-classified + bash.exe must be launched via the wsl wrapper. + """ + tool = BashTool({}) + popen_mock = MagicMock(return_value=MagicMock(pid=4242)) + + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch("amplifier_module_tool_bash.shutil.which", return_value=WSL_LAUNCHER), + patch( + "amplifier_module_tool_bash.asyncio.create_subprocess_exec", + _is_wsl_subprocess_mock(returncode=0), # WSL_LAUNCHER IS wsl + ), + patch("amplifier_module_tool_bash.subprocess.Popen", popen_mock), + # Windows-only constants that don't exist on the real `subprocess` + # module on Linux/macOS -- the test suite runs there, so these + # must be supplied for the (mocked-anyway) Popen call's keyword + # arguments to even evaluate. + patch.object(mod.subprocess, "DETACHED_PROCESS", 0x00000008, create=True), + patch.object( + mod.subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200, create=True + ), + ): + # No foreground call happened first -- cache is genuinely cold. + assert tool._wsl_bash_cache == {} + result = await tool._run_command_background("echo hi") + + assert "pid" in result + assert popen_mock.called, "background execution never spawned a process" + spawned_args = popen_mock.call_args.args[0] + assert spawned_args == ["wsl", "--exec", "bash", "-c", "echo hi"], ( + f"background command used {spawned_args!r} on a WSL bash.exe -- " + "expected the ['wsl', '--exec', 'bash', '-c', ...] wrapper. This " + "is the exact foreground/background disagreement the fix removes." + ) + + @pytest.mark.asyncio + async def test_foreground_and_background_agree_on_same_resolution(self): + """Both paths must resolve to the identical (exe, is_wsl) via the + same shared, cached call -- shutil.which is consulted only once + total across both. + """ + tool = BashTool({}) + which_mock = MagicMock(return_value=WSL_LAUNCHER) + popen_mock = MagicMock(return_value=MagicMock(pid=4242)) + fg_proc = _FakeProc(pid=1234, returncode=0) + + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch("amplifier_module_tool_bash.shutil.which", which_mock), + patch( + "amplifier_module_tool_bash.asyncio.create_subprocess_exec", + AsyncMock(return_value=fg_proc), + ), + patch("amplifier_module_tool_bash.subprocess.Popen", popen_mock), + patch("amplifier_module_tool_bash._assign_to_windows_job", MagicMock()), + patch("amplifier_module_tool_bash._spawn_descendant_sweep", MagicMock()), + patch.object(mod.subprocess, "DETACHED_PROCESS", 0x00000008, create=True), + patch.object( + mod.subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200, create=True + ), + ): + await tool._run_command("echo fg", timeout=5) + await tool._run_command_background("echo bg") + + assert which_mock.call_count == 1, ( + "foreground and background each re-resolved the shell " + "independently instead of sharing one cached resolution" + ) + spawned_args = popen_mock.call_args.args[0] + assert spawned_args[0] == "wsl", ( + "background disagreed with foreground's WSL classification" + ) + + +# --------------------------------------------------------------------------- +# 5. Observability: description names the resolved shell. +# --------------------------------------------------------------------------- + + +class TestWindowsShellDescription: + def test_description_names_wsl_shell_and_path_convention(self): + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch("amplifier_module_tool_bash.shutil.which", return_value=WSL_LAUNCHER), + ): + tool = BashTool({}) + + assert "WINDOWS SHELL" in tool.description + assert WSL_LAUNCHER in tool.description + assert "/mnt/c/" in tool.description, ( + "model needs the WSL path convention up front, before its " + "first command, to avoid guessing wrong (the dominant " + "failure mode found across comparable CLI agents)" + ) + + def test_description_names_gitbash_shell_and_path_convention(self): + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch("amplifier_module_tool_bash.shutil.which", return_value=GIT_BASH), + ): + tool = BashTool({}) + + assert "WINDOWS SHELL" in tool.description + assert GIT_BASH in tool.description + assert "Git Bash" in tool.description + note = tool.description.split("WINDOWS SHELL")[-1] + assert "mounted at /c/" in note, ( + "model needs the Git Bash path convention up front, before " + "its first command" + ) + assert "WSL Linux home" not in note, ( + "Git Bash note incorrectly describes WSL's $HOME convention" + ) + + def test_description_names_missing_shell(self): + with ( + patch("amplifier_module_tool_bash.sys.platform", "win32"), + patch("amplifier_module_tool_bash.shutil.which", return_value=None), + patch.dict(mod.os.environ, {}, clear=True), + patch("amplifier_module_tool_bash.os.path.isfile", return_value=False), + ): + tool = BashTool({}) + + assert "no bash found" in tool.description.lower() + + def test_posix_description_is_byte_identical_to_base(self): + """POSIX must not gain the Windows note at all -- confirms the + Fix 2 change is confined to `sys.platform == "win32"`. + """ + with patch("amplifier_module_tool_bash.sys.platform", "linux"): + tool = BashTool({}) + + assert tool.description == BashTool.description + assert "WINDOWS SHELL" not in tool.description + + +# --------------------------------------------------------------------------- +# 6. Preference resolution (config vs env var vs default). +# --------------------------------------------------------------------------- + + +class TestWindowsShellPreferenceResolution: + def test_config_key_takes_precedence_over_env_var(self): + with patch.dict( + mod.os.environ, {"AMPLIFIER_BASH_WINDOWS_SHELL": "wsl"}, clear=False + ): + pref = BashTool._resolve_windows_shell_preference( + {"windows_shell": "gitbash"} + ) + assert pref == "gitbash" + + def test_env_var_used_when_no_config_key(self): + with patch.dict( + mod.os.environ, {"AMPLIFIER_BASH_WINDOWS_SHELL": "wsl"}, clear=False + ): + pref = BashTool._resolve_windows_shell_preference({}) + assert pref == "wsl" + + def test_defaults_to_auto(self): + with patch.dict(mod.os.environ, {}, clear=True): + pref = BashTool._resolve_windows_shell_preference({}) + assert pref == "auto" + + def test_unknown_value_warns_and_falls_back_to_auto(self): + with patch.object(mod, "logger") as log: + pref = BashTool._resolve_windows_shell_preference( + {"windows_shell": "powershell"} + ) + assert pref == "auto" + assert log.warning.called diff --git a/uv.lock b/uv.lock index f4ccabf..6fa0b19 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,385 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "amplifier-core" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tomli" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/cd/8b0b520bf0de741ea73e069aaf64aca28c9f4ce91a7b8b9239193a6c4c1b/amplifier_core-1.6.1-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c0f711d8408de78e53e5deddcb38b7240c5c1c497ca51eeaaeff23559b3d3c48", size = 8281633, upload-time = "2026-08-10T02:38:11.98Z" }, + { url = "https://files.pythonhosted.org/packages/14/83/f4fb297d87d35b9d74058da02bb153e12f7891ab62b3aaf7e0857f877798/amplifier_core-1.6.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b08f37e2c0b1611349a0e25d5bf9bfdfae3afcee35488f8e26bba1cdd400503b", size = 7366930, upload-time = "2026-08-10T02:38:14.105Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/5eb9cecf92d8053c5e6d46ad9668c3ed3558d5423845c1dced1f266b2a38/amplifier_core-1.6.1-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ebf7e3993c76ea506e70ac7844b286c3ba2e9127b3bcb350fa4fcd2dcdbd38d", size = 7659512, upload-time = "2026-08-10T02:38:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/121f054e3d079dc33d83f3d8ba9af50fd9f7694c3e2ba3d7d23d7c157d48/amplifier_core-1.6.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c957cd0671d2a003f2c8f7d6a41bd6e808f97d183c57b97e7700bf4c912621d", size = 8678425, upload-time = "2026-08-10T02:38:18.243Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/bfc217f4a9ed2d033995fc59847f1fee2e1b17130632fcb0e0981a1a311b/amplifier_core-1.6.1-cp311-abi3-win_amd64.whl", hash = "sha256:50c80bcfa1f6efe769b19e7af18c925024c7553d4db08880727241709dd44eae", size = 8976601, upload-time = "2026-08-10T02:38:20.505Z" }, + { url = "https://files.pythonhosted.org/packages/a5/14/5f330452c92c6c5d35c51ad5311301949ce5db4d1a1a901456f3ee43eaac/amplifier_core-1.6.1-cp311-abi3-win_arm64.whl", hash = "sha256:cd8b617f132cf5d1ca3e5187d5f831d1f2a508bb40d07b2ab1085961bcb9e1a9", size = 7744837, upload-time = "2026-08-10T02:38:22.562Z" }, +] + [[package]] name = "amplifier-module-tool-bash" version = "1.0.0" source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "amplifier-core" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "amplifier-core" }, + { name = "pytest", specifier = ">=8" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[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 = "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 = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[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 = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[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.1.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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[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 = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/bc/4eae18cd40c65798a16267572ba346c11f599d44b01603dbd843342042bc/typing_inspection-0.4.3.tar.gz", hash = "sha256:c5f9ec1530b5c1e2c9bc34a84d9a3466ed1b2f3f2fa9f901368d9c5596210e4d", size = 76711, upload-time = "2026-08-10T09:39:18.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/f7/7a3935abdebd5cf18705a5f0335dd6a3a18bef3baa7cb9edc3b6b9922cc8/typing_inspection-0.4.3-py3-none-any.whl", hash = "sha256:5f42b23858a91e0b4ef521f5418f03a0da3c9216fd2995ef5e73463100e676cd", size = 14693, upload-time = "2026-08-10T09:39:16.693Z" }, +]