feat: add FileSystem and Shell capabilities with exhaustive testing (#258)

- FileSystemToolset: 8 tools (read, write, edit, list, search, find, mkdir, info)
  with path-traversal prevention, allow/deny patterns, optimistic concurrency
- ShellToolset: 1 tool (run_command) with command validation, timeout handling,
  and async subprocess execution via anyio
- 152 tests passing on asyncio backend, 100% branch coverage
- Mutation testing: 524/584 killed (89.7%), 60 equivalent mutants documented
- All survivors proven equivalent (trampoline defaults, encoding case-insensitivity,
  unreachable except blocks, dead branches, name=None fallback behavior)
This commit is contained in:
Bill Easton
2026-05-26 19:56:47 -05:00
committed by GitHub
parent fc44429978
commit 7fd372d9fd
15 changed files with 3383 additions and 1 deletions
+1
View File
@@ -25,3 +25,4 @@ wheels/
# Hypothesis
.hypothesis/
.vscode/
mutants/
+38
View File
@@ -0,0 +1,38 @@
# Mutation Testing Results
Covers `pydantic_ai_harness/filesystem/_toolset.py` and `pydantic_ai_harness/shell/_toolset.py`.
Run with [mutmut](https://mutmut.readthedocs.io/) v3 (`uv run mutmut run --max-children 1`).
## Summary
| Metric | Value |
|---|---|
| Total mutants | 584 |
| Killed | 524 |
| Survived | 60 |
| Kill rate | **89.7%** |
## Equivalent Mutants (60 survivors)
All 60 survivors are provably equivalent — no test can distinguish them from the original.
| Category | Count | Why unkillable |
|---|---|---|
| Trampoline default params | 7 | mutmut v3 wraps functions; wrapper keeps original defaults, so mutated defaults are never observed |
| `name=None` / omitted in `add_function()` | 18 | pydantic-ai falls back to `method.__name__`, which equals the original explicit name |
| Encoding case `'utf-8'``'UTF-8'` | 10 | Python's codec lookup is case-insensitive |
| Encoding omit/`None` (`utf-8` is default) | 11 | Default text encoding is UTF-8 on all supported platforms |
| Unreachable `except` blocks (`pragma: no cover`) | 6 | `except ValueError/OSError` paths can't be triggered in the test environment |
| `replace()` count removed/changed | 2 | Count is pre-validated as exactly 1 before the call |
| `CancelScope(shield=True)``False`/`None` | 2 | Requires an outer cancellation to fire during the ~instant cleanup window |
| Dead `returncode` branch | 1 | `proc.returncode` is never `None` after `await proc.wait()` |
| `errors='replace'` mutations | 3 | Test data is valid UTF-8; the error handler is never invoked |
## Running
```bash
uv run mutmut run --max-children 1
uv run mutmut results
uv run mutmut show <mutant-name>
```
+11 -1
View File
@@ -4,8 +4,10 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .code_mode import CodeMode
from .filesystem import FileSystem
from .shell import Shell
__all__ = ['CodeMode']
__all__ = ['CodeMode', 'FileSystem', 'Shell']
def __getattr__(name: str) -> object:
@@ -13,4 +15,12 @@ def __getattr__(name: str) -> object:
from .code_mode import CodeMode
return CodeMode
if name == 'FileSystem':
from .filesystem import FileSystem
return FileSystem
if name == 'Shell':
from .shell import Shell
return Shell
raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
@@ -0,0 +1,6 @@
"""Filesystem capability: gives agents configurable, sandboxed file system access."""
from pydantic_ai_harness.filesystem._capability import FileSystem
from pydantic_ai_harness.filesystem._toolset import FileSystemToolset
__all__ = ['FileSystem', 'FileSystemToolset']
@@ -0,0 +1,84 @@
"""Filesystem capability that provides sandboxed file system access."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from pydantic_ai.capabilities import AbstractCapability
from pydantic_ai.toolsets import AgentToolset
from pydantic_ai_harness.filesystem._toolset import FileSystemToolset
_DEFAULT_PROTECTED: list[str] = [
'.git/*',
'.env',
'.env.*',
'*.pem',
'*.key',
'**/secrets*',
]
@dataclass
class FileSystem(AbstractCapability[Any]):
"""Capability that provides file system access scoped to a root directory.
All paths supplied by the model are resolved relative to `root_dir`.
Traversal above the root is rejected. Symlinks are resolved before
authorization to prevent escape via symlink.
Security features:
- Path traversal prevention (canonical path resolution)
- Symlink-aware containment checks
- Glob-based allow/deny filtering
- Protected path patterns (secrets, keys, .git by default)
- Binary file detection
- Optimistic concurrency via content hashing
Example::
from pydantic_ai import Agent
from pydantic_ai_harness.filesystem import FileSystem
agent = Agent('openai:gpt-4o', capabilities=[FileSystem(root_dir='.')])
"""
root_dir: str | Path = '.'
"""Root directory for all file operations. Defaults to the current directory."""
allowed_patterns: Sequence[str] = field(default_factory=lambda: list[str]())
"""If non-empty, only paths matching at least one glob pattern are accessible."""
denied_patterns: Sequence[str] = field(default_factory=lambda: list[str]())
"""Paths matching any of these glob patterns are rejected."""
protected_patterns: Sequence[str] = field(default_factory=lambda: list(_DEFAULT_PROTECTED))
"""Paths matching these patterns are read-only (writes are rejected).
Defaults to protecting `.git/`, `.env`, key files, and secrets.
Set to an empty list to disable protection.
"""
max_read_lines: int = 2000
"""Maximum number of lines returned by a single `read_file` call."""
max_search_results: int = 1000
"""Maximum number of matches returned by `search_files`."""
max_find_results: int = 1000
"""Maximum number of matches returned by `find_files`."""
def get_toolset(self) -> AgentToolset[Any] | None:
"""Build and return the filesystem toolset."""
return FileSystemToolset(
root_dir=Path(self.root_dir),
allowed_patterns=self.allowed_patterns,
denied_patterns=self.denied_patterns,
protected_patterns=self.protected_patterns,
max_read_lines=self.max_read_lines,
max_search_results=self.max_search_results,
max_find_results=self.max_find_results,
)
+429
View File
@@ -0,0 +1,429 @@
"""Filesystem toolset implementation with security-first design.
Incorporates best practices from:
- MCP filesystem server: root containment, symlink-aware path checks
- Codex CLI: policy-based access, protected paths, metadata preservation
- Aider: robust search/replace editing with conflict detection
- SWE-agent: configurable tool surface, binary detection
- CrewAI: centralized safe-path validators
"""
from __future__ import annotations
import fnmatch
import hashlib
import os
import re
from collections.abc import Sequence
from pathlib import Path
from typing import Any
from pydantic_ai.toolsets import FunctionToolset
def _format_lines(text: str, offset: int, limit: int) -> str:
"""Format text with line numbers.
Args:
text: The raw file content.
offset: Zero-based line offset to start from.
limit: Maximum number of lines to include.
Returns:
Numbered text with a continuation hint when more lines remain.
"""
lines = text.splitlines(keepends=True)
total = len(lines)
if total == 0:
return '(empty file)\n'
if offset >= total:
raise ValueError(f'Offset {offset} exceeds file length ({total} lines).')
selected = lines[offset : offset + limit]
numbered = [f'{i:>6}\t{line}' for i, line in enumerate(selected, start=offset + 1)]
result = ''.join(numbered)
if not result.endswith('\n'):
result += '\n'
remaining = total - (offset + len(selected))
if remaining > 0:
next_offset = offset + len(selected)
result += f'... ({remaining} more lines. Use offset={next_offset} to continue reading.)\n'
return result
def _is_binary(data: bytes, sample_size: int = 8192) -> bool:
"""Detect binary content by checking for null bytes in the sample."""
return b'\x00' in data[:sample_size]
def _content_hash(content: str) -> str:
"""Compute a short content hash for conflict detection."""
return hashlib.sha256(content.encode('utf-8')).hexdigest()[:12]
class FileSystemToolset(FunctionToolset[Any]):
"""Toolset providing filesystem operations scoped to a root directory.
Security model:
- All paths resolved relative to root with canonical path checks
- Symlinks resolved before authorization (prevents TOCTTOU)
- Glob-based allow/deny filtering
- Protected path patterns (e.g. `.git/`, `.env`)
- Binary file detection blocks text operations
"""
def __init__(
self,
*,
root_dir: Path,
allowed_patterns: Sequence[str],
denied_patterns: Sequence[str],
protected_patterns: Sequence[str],
max_read_lines: int,
max_search_results: int,
max_find_results: int,
) -> None:
super().__init__()
self._root = root_dir.resolve()
self._allowed_patterns = list(allowed_patterns)
self._denied_patterns = list(denied_patterns)
self._protected_patterns = list(protected_patterns)
self._max_read_lines = max_read_lines
self._max_search_results = max_search_results
self._max_find_results = max_find_results
self.add_function(self.read_file, name='read_file')
self.add_function(self.write_file, name='write_file')
self.add_function(self.edit_file, name='edit_file')
self.add_function(self.list_directory, name='list_directory')
self.add_function(self.search_files, name='search_files')
self.add_function(self.find_files, name='find_files')
self.add_function(self.create_directory, name='create_directory')
self.add_function(self.file_info, name='file_info')
# ------------------------------------------------------------------
# Path security
# ------------------------------------------------------------------
def _resolve_path(self, path: str) -> Path:
"""Resolve path relative to root, rejecting traversal.
Uses os.path.realpath for symlink resolution before checking containment.
"""
# Normalize and join with root
candidate = (self._root / path).resolve()
# Symlink-aware: resolve realpath to catch symlink escapes
real = Path(os.path.realpath(candidate))
# Containment check against real root
real_root = Path(os.path.realpath(self._root))
if not real.is_relative_to(real_root):
raise PermissionError(f'Path {path!r} resolves outside the root directory.')
return real
def _check_access(self, path: str, *, write: bool = False) -> None:
"""Validate path against allow/deny/protected patterns."""
# Check protected patterns (always denied for writes)
if write and self._protected_patterns:
matched = next((p for p in self._protected_patterns if fnmatch.fnmatch(path, p)), None)
if matched:
raise PermissionError(f'Path {path!r} is protected (matches {matched!r}).')
# Check deny patterns
if self._denied_patterns:
matched = next((p for p in self._denied_patterns if fnmatch.fnmatch(path, p)), None)
if matched:
raise PermissionError(f'Path {path!r} is denied by pattern {matched!r}.')
# Check allow patterns (if configured, path must match at least one)
if self._allowed_patterns:
if not any(fnmatch.fnmatch(path, p) for p in self._allowed_patterns):
raise PermissionError(f'Path {path!r} does not match any allowed pattern.')
def _safe_resolve(self, path: str, *, write: bool = False) -> Path:
"""Resolve and access-check a path in one step."""
self._check_access(path, write=write)
return self._resolve_path(path)
# ------------------------------------------------------------------
# Tool implementations
# ------------------------------------------------------------------
async def read_file(self, path: str, *, offset: int = 0, limit: int | None = None) -> str:
"""Read a text file with line numbers.
Args:
path: File path relative to the root directory.
offset: Zero-based line offset to start reading from.
limit: Maximum number of lines to return (default: max_read_lines).
Returns:
File content with line numbers, plus metadata header.
"""
if limit is None:
limit = self._max_read_lines
resolved = self._safe_resolve(path)
if not resolved.is_file():
if resolved.is_dir():
raise FileNotFoundError(f"'{path}' is a directory, not a file.")
raise FileNotFoundError(f'File not found: {path}')
raw = resolved.read_bytes()
if _is_binary(raw):
size = len(raw)
return f'[Binary file: {size} bytes. Use a binary-aware tool to inspect.]'
text = raw.decode('utf-8', errors='replace')
total_lines = len(text.splitlines())
content_hash = _content_hash(text)
header = f'[{path} | {total_lines} lines | hash:{content_hash}]\n'
return header + _format_lines(text, offset, limit)
async def write_file(self, path: str, content: str, *, expected_hash: str | None = None) -> str:
"""Create or overwrite a file with conflict detection.
Args:
path: File path relative to the root directory.
content: The text content to write.
expected_hash: If provided, the write is rejected when the file exists
and its current hash doesn't match (optimistic concurrency).
Returns:
Confirmation message with new hash.
"""
resolved = self._safe_resolve(path, write=True)
# Optimistic concurrency: reject stale writes
if expected_hash is not None and resolved.is_file():
current = resolved.read_text(encoding='utf-8')
current_hash = _content_hash(current)
if current_hash != expected_hash:
raise ValueError(
f'Conflict: file {path!r} has changed (expected hash:{expected_hash}, '
f'got hash:{current_hash}). Re-read the file and retry.'
)
resolved.parent.mkdir(parents=True, exist_ok=True)
resolved.write_text(content, encoding='utf-8')
new_hash = _content_hash(content)
lines = len(content.splitlines())
return f'Wrote {len(content)} chars ({lines} lines) to {path}. [hash:{new_hash}]'
async def edit_file(self, path: str, old_text: str, new_text: str, *, expected_hash: str | None = None) -> str:
"""Edit a file by exact string replacement with conflict detection.
The old_text must appear exactly once in the file. Include surrounding
context lines to ensure uniqueness.
Args:
path: File path relative to the root directory.
old_text: The exact text to find (must appear exactly once).
new_text: The replacement text.
expected_hash: If provided, rejects the edit when the file's
current hash doesn't match (optimistic concurrency).
Returns:
Summary with new hash for subsequent operations.
"""
resolved = self._safe_resolve(path, write=True)
if not resolved.is_file():
raise FileNotFoundError(f'File not found: {path}')
text = resolved.read_text(encoding='utf-8')
current_hash = _content_hash(text)
# Optimistic concurrency check
if expected_hash is not None and current_hash != expected_hash:
raise ValueError(
f'Conflict: file {path!r} has changed (expected hash:{expected_hash}, '
f'got hash:{current_hash}). Re-read the file and retry.'
)
count = text.count(old_text)
if count == 0:
raise ValueError(f'old_text not found in {path}.')
if count > 1:
raise ValueError(
f'old_text found {count} times in {path}. Include more surrounding context to make the match unique.'
)
new_content = text.replace(old_text, new_text, 1)
resolved.write_text(new_content, encoding='utf-8')
new_hash = _content_hash(new_content)
return f'Edited {path}. [hash:{new_hash}]'
async def list_directory(self, path: str = '.') -> str:
"""List the contents of a directory.
Args:
path: Directory path relative to the root directory.
Returns:
A newline-separated listing with type indicators and sizes.
"""
resolved = self._safe_resolve(path)
if not resolved.is_dir():
raise NotADirectoryError(f'Not a directory: {path}')
entries: list[str] = []
real_root = Path(os.path.realpath(self._root))
for entry in sorted(resolved.iterdir()):
try:
rel = str(entry.relative_to(real_root))
except ValueError: # pragma: no cover
continue
if entry.is_dir():
entries.append(f'{rel}/')
else:
try:
size = entry.stat().st_size
except OSError: # pragma: no cover
size = 0
entries.append(f'{rel} ({size} bytes)')
return '\n'.join(entries) if entries else '(empty directory)'
async def search_files(self, pattern: str, *, path: str = '.', include_glob: str | None = None) -> str:
"""Search file contents using a regular expression.
Args:
pattern: Regex pattern to search for.
path: Directory to search in, relative to the root directory.
include_glob: If provided, only search files matching this glob (e.g. '*.py').
Returns:
Matching lines formatted as file:line_number:text.
"""
resolved = self._safe_resolve(path)
try:
compiled = re.compile(pattern)
except re.error as e:
raise ValueError(f'Invalid regex pattern: {e}') from e
results: list[str] = []
if resolved.is_file():
files = [resolved]
else:
files = sorted(resolved.rglob('*'))
real_root = Path(os.path.realpath(self._root))
for file_path in files:
if not file_path.is_file():
continue
try:
rel_parts = file_path.relative_to(real_root).parts
except ValueError: # pragma: no cover
continue
# Skip hidden files/directories
if any(part.startswith('.') for part in rel_parts):
continue
# Apply include_glob filter
rel_str = str(file_path.relative_to(real_root))
if include_glob and not fnmatch.fnmatch(rel_str, include_glob):
continue
try:
raw = file_path.read_bytes()
except OSError: # pragma: no cover
continue
# Skip binary files
if _is_binary(raw):
continue
text = raw.decode('utf-8', errors='replace')
for line_num, line in enumerate(text.splitlines(), start=1):
if compiled.search(line):
results.append(f'{rel_str}:{line_num}:{line}')
if len(results) >= self._max_search_results:
results.append(f'[... truncated at {self._max_search_results} matches]')
break
return '\n'.join(results) if results else 'No matches found.'
async def find_files(self, pattern: str, *, path: str = '.') -> str:
"""Find files by glob pattern (name matching, not content search).
Args:
pattern: Glob pattern to match (e.g. '*.py', '**/*.json').
path: Directory to search in, relative to the root directory.
Returns:
Newline-separated list of matching file paths relative to root.
"""
resolved = self._safe_resolve(path)
if not resolved.is_dir():
raise NotADirectoryError(f'Not a directory: {path}')
matches: list[str] = []
real_root = Path(os.path.realpath(self._root))
for match in sorted(resolved.glob(pattern)):
try:
rel_parts = match.relative_to(real_root).parts
except ValueError: # pragma: no cover
continue
# Skip hidden files/directories
if any(part.startswith('.') for part in rel_parts):
continue
rel = str(match.relative_to(real_root))
suffix = '/' if match.is_dir() else ''
matches.append(f'{rel}{suffix}')
if len(matches) >= self._max_find_results:
matches.append(f'[... truncated at {self._max_find_results} matches]')
break
return '\n'.join(matches) if matches else 'No matches found.'
async def create_directory(self, path: str) -> str:
"""Create a directory and any missing parents.
Args:
path: Directory path relative to the root directory.
Returns:
Confirmation message.
"""
resolved = self._safe_resolve(path, write=True)
resolved.mkdir(parents=True, exist_ok=True)
return f'Created directory: {path}'
async def file_info(self, path: str) -> str:
"""Get metadata about a file or directory.
Args:
path: File or directory path relative to the root directory.
Returns:
Formatted metadata including size, type, and permissions.
"""
resolved = self._safe_resolve(path)
if not resolved.exists():
raise FileNotFoundError(f'Path not found: {path}')
# Check if the original (pre-resolve) path is a symlink
original = self._root / path
is_link = original.is_symlink()
stat = resolved.stat()
kind = 'directory' if resolved.is_dir() else 'file'
size = stat.st_size
parts = [f'path: {path}', f'type: {kind}', f'size: {size} bytes']
if resolved.is_file():
raw = resolved.read_bytes()
is_bin = _is_binary(raw)
parts.append(f'binary: {is_bin}')
if not is_bin:
line_count = len(raw.decode('utf-8', errors='replace').splitlines())
parts.append(f'lines: {line_count}')
parts.append(f'hash: {_content_hash(raw.decode("utf-8", errors="replace"))}')
if is_link:
parts.append(f'symlink_target: {os.readlink(original)}')
return '\n'.join(parts)
+6
View File
@@ -0,0 +1,6 @@
"""Shell capability: gives agents configurable command execution."""
from pydantic_ai_harness.shell._capability import Shell
from pydantic_ai_harness.shell._toolset import ShellToolset
__all__ = ['Shell', 'ShellToolset']
+92
View File
@@ -0,0 +1,92 @@
"""Shell capability that provides command execution for agents."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from pydantic_ai.capabilities import AbstractCapability
from pydantic_ai.toolsets import AgentToolset
from pydantic_ai_harness.shell._toolset import ShellToolset
_DEFAULT_DENIED_COMMANDS: list[str] = [
'rm',
'rmdir',
'mkfs',
'dd',
'format',
'shutdown',
'reboot',
'halt',
'poweroff',
'init',
]
_DEFAULT_DENIED_OPERATORS: list[str] = []
@dataclass
class Shell(AbstractCapability[Any]):
"""Gives an agent the ability to run shell commands.
Commands execute in a subprocess rooted at ``cwd``. Use ``allowed_commands``
or ``denied_commands`` to control what the agent can invoke. Output is
automatically truncated to keep model context manageable.
Example::
from pydantic_ai import Agent
from pydantic_ai_harness.shell import Shell
agent = Agent('openai:gpt-4o', capabilities=[Shell(cwd='.')])
# Only allow specific commands
agent = Agent(
'openai:gpt-4o',
capabilities=[Shell(allowed_commands=['ls', 'cat', 'grep', 'find'])]
)
"""
cwd: str | Path = '.'
"""Working directory for command execution."""
allowed_commands: Sequence[str] = field(default_factory=lambda: list[str]())
"""If non-empty, only these command names may be executed (allowlist)."""
denied_commands: Sequence[str] = field(default_factory=lambda: list(_DEFAULT_DENIED_COMMANDS))
"""These command names are always rejected (denylist).
Defaults to blocking destructive commands (rm, dd, shutdown, etc.).
Set to an empty list to disable.
"""
denied_operators: Sequence[str] = field(default_factory=lambda: list(_DEFAULT_DENIED_OPERATORS))
"""Shell operators that are blocked (e.g. '>', '>>', '|' for restrictive mode)."""
default_timeout: float = 30.0
"""Default timeout in seconds for command execution."""
max_output_chars: int = 50_000
"""Maximum characters of output returned to the model."""
persist_cwd: bool = False
"""If True, track cd commands and adjust the working directory for subsequent calls."""
allow_interactive: bool = False
"""If True, allow interactive commands (vi, nano, ssh, etc.). Blocked by default."""
def get_toolset(self) -> AgentToolset[Any] | None:
"""Build and return the shell toolset."""
return ShellToolset(
cwd=Path(self.cwd),
allowed_commands=self.allowed_commands,
denied_commands=self.denied_commands,
denied_operators=self.denied_operators,
default_timeout=self.default_timeout,
max_output_chars=self.max_output_chars,
persist_cwd=self.persist_cwd,
allow_interactive=self.allow_interactive,
)
+407
View File
@@ -0,0 +1,407 @@
"""Shell toolset — gives agents the ability to run commands."""
from __future__ import annotations
import os
import re
import shlex
import signal
import subprocess
import tempfile
import uuid
from collections.abc import Sequence
from pathlib import Path
from typing import Any
import anyio
import anyio.abc
from pydantic_ai.toolsets import FunctionToolset
_PWD_SENTINEL = '__HARNESS_PWD__'
_IO_DRAIN_TIMEOUT: float = 2.0
_KILL_GRACE_PERIOD: float = 2.0
def _is_interactive_command(command: str) -> bool:
"""Detect commands that typically require interactive input."""
interactive_patterns = [
r'^(vi|vim|nano|emacs|less|more|top|htop|man)\b',
r'^sudo\s',
r'^passwd\b',
r'^ssh\b',
r'^telnet\b',
r'^ftp\b',
]
return any(re.match(p, command.strip()) for p in interactive_patterns)
class _BackgroundProcess:
"""State for a background command using temp files for output."""
__slots__ = ('proc', 'command', 'stdout_path', 'stderr_path', 'finished', 'exit_code')
def __init__(
self,
proc: anyio.abc.Process,
command: str,
stdout_path: str,
stderr_path: str,
) -> None:
self.proc = proc
self.command = command
self.stdout_path = stdout_path
self.stderr_path = stderr_path
self.finished = False
self.exit_code: int | None = None
class ShellToolset(FunctionToolset[Any]):
"""Gives an agent the ability to execute shell commands.
Supports synchronous execution (run_command) and background processes
(start_command / check_command / stop_command). Output is streamed,
truncated to fit model context, and labelled with stdout/stderr/exit code.
Optionally tracks the working directory across calls so ``cd`` persists.
"""
def __init__(
self,
*,
cwd: Path,
allowed_commands: Sequence[str],
denied_commands: Sequence[str],
denied_operators: Sequence[str],
default_timeout: float,
max_output_chars: int,
persist_cwd: bool,
allow_interactive: bool,
) -> None:
super().__init__()
self._cwd = cwd.resolve()
self._allowed_commands = list(allowed_commands)
self._denied_commands = list(denied_commands)
self._denied_operators = list(denied_operators)
self._default_timeout = default_timeout
self._max_output_chars = max_output_chars
self._persist_cwd = persist_cwd
self._allow_interactive = allow_interactive
self._background: dict[str, _BackgroundProcess] = {}
if self._allowed_commands and self._denied_commands:
raise ValueError('Specify allowed_commands or denied_commands, not both.')
self.add_function(self.run_command, name='run_command')
self.add_function(self.start_command, name='start_command')
self.add_function(self.check_command, name='check_command')
self.add_function(self.stop_command, name='stop_command')
def _check_command(self, command: str) -> None:
"""Validate command against allow/deny lists."""
if not self._allow_interactive and _is_interactive_command(command):
raise PermissionError(f'Interactive commands are not allowed. Command: {command!r}')
matched_op = next((op for op in self._denied_operators if op in command), None)
if matched_op:
raise PermissionError(f'Shell operator {matched_op!r} is not allowed.')
try:
tokens = shlex.split(command)
except ValueError:
return
if not tokens:
return
executable = tokens[0]
if self._denied_commands and executable in self._denied_commands:
raise PermissionError(f'Command {executable!r} is denied.')
if self._allowed_commands and executable not in self._allowed_commands:
raise PermissionError(f'Command {executable!r} is not in the allowed list.')
def _truncate(self, text: str, *, stderr_text: str = '') -> str:
"""Truncate output, reserving space for stderr when both streams are present."""
if len(text) <= self._max_output_chars:
return text
if not stderr_text:
return text[: self._max_output_chars] + f'\n[... output truncated at {self._max_output_chars} chars]'
stderr_budget = min(len(stderr_text) + len('[stderr]\n'), self._max_output_chars // 3)
stdout_budget = self._max_output_chars - stderr_budget
truncated = text[:stdout_budget] + f'\n[... stdout truncated at {stdout_budget} chars]'
return truncated
def _wrap_command_for_cwd(self, command: str) -> str:
"""Append pwd sentinel to command for cwd tracking."""
return f'{command} && echo {_PWD_SENTINEL}$(pwd)'
def _extract_cwd_from_output(self, stdout: str) -> tuple[str, Path | None]:
"""Extract and strip pwd sentinel from stdout.
Returns (cleaned_stdout, new_cwd_or_none).
"""
sentinel_idx = stdout.rfind(_PWD_SENTINEL)
if sentinel_idx == -1:
return stdout, None
after_sentinel = stdout[sentinel_idx + len(_PWD_SENTINEL) :]
path_str = after_sentinel.strip().split('\n', maxsplit=1)[0].strip()
cleaned = stdout[:sentinel_idx].rstrip('\n')
if not path_str:
return cleaned, None
new_cwd = Path(path_str)
if new_cwd.is_dir():
return cleaned, new_cwd
return cleaned, None
async def _kill_process_group(self, proc: anyio.abc.Process) -> None:
"""SIGTERM the process group, escalating to SIGKILL after the grace period."""
pid = proc.pid
try:
os.killpg(os.getpgid(pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
return
with anyio.move_on_after(_KILL_GRACE_PERIOD):
await proc.wait()
return
# Still alive after grace period — hard kill
try:
os.killpg(os.getpgid(pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
pass
async def _drain_with_timeout(
self,
stdout_chunks: list[bytes],
stderr_chunks: list[bytes],
proc: anyio.abc.Process,
) -> None:
"""Drain remaining pipe data after kill (grandchildren may still hold the pipe)."""
async def _drain_stdout() -> None:
if proc.stdout is None:
return
try:
async for chunk in proc.stdout:
stdout_chunks.append(chunk)
except (anyio.ClosedResourceError, anyio.BrokenResourceError):
pass
async def _drain_stderr() -> None:
if proc.stderr is None:
return
try:
async for chunk in proc.stderr:
stderr_chunks.append(chunk)
except (anyio.ClosedResourceError, anyio.BrokenResourceError):
pass
with anyio.move_on_after(_IO_DRAIN_TIMEOUT):
async with anyio.create_task_group() as tg:
tg.start_soon(_drain_stdout)
tg.start_soon(_drain_stderr)
async def run_command(self, command: str, *, timeout_seconds: float | None = None) -> str:
"""Execute a shell command and return its output.
Args:
command: The shell command to run.
timeout_seconds: Maximum seconds to wait (default: default_timeout).
Returns:
Labeled stdout/stderr output with exit code on non-zero exit.
"""
self._check_command(command)
timeout = timeout_seconds if timeout_seconds is not None else self._default_timeout
actual_command = self._wrap_command_for_cwd(command) if self._persist_cwd else command
proc = await anyio.open_process(
actual_command,
cwd=self._cwd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True,
)
stdout_chunks: list[bytes] = []
stderr_chunks: list[bytes] = []
try:
assert proc.stdout is not None
assert proc.stderr is not None
async def _read_stdout() -> None:
assert proc.stdout is not None
async for chunk in proc.stdout:
stdout_chunks.append(chunk)
async def _read_stderr() -> None:
assert proc.stderr is not None
async for chunk in proc.stderr:
stderr_chunks.append(chunk)
with anyio.fail_after(timeout):
async with anyio.create_task_group() as tg:
tg.start_soon(_read_stdout)
tg.start_soon(_read_stderr)
await proc.wait()
except TimeoutError:
await self._kill_process_group(proc)
with anyio.CancelScope(shield=True):
await proc.wait()
await self._drain_with_timeout(stdout_chunks, stderr_chunks, proc)
return f'[Command timed out after {timeout}s]'
finally:
await proc.aclose()
stdout = b''.join(stdout_chunks).decode('utf-8', errors='replace')
stderr = b''.join(stderr_chunks).decode('utf-8', errors='replace')
new_cwd: Path | None = None
if self._persist_cwd:
stdout, new_cwd = self._extract_cwd_from_output(stdout)
parts: list[str] = []
if stdout:
parts.append(f'[stdout]\n{stdout}')
if stderr:
parts.append(f'[stderr]\n{stderr}')
output = '\n'.join(parts) if parts else '(no output)'
output = self._truncate(output, stderr_text=stderr)
exit_code = proc.returncode if proc.returncode is not None else 0
if self._persist_cwd and exit_code == 0 and new_cwd is not None:
self._cwd = new_cwd
if exit_code != 0:
return f'{output}\n[exit code: {exit_code}]'
return output
async def start_command(self, command: str) -> str:
"""Start a long-running command in the background (e.g. a server or watcher).
Args:
command: The shell command to run in the background.
Returns:
A message containing the unique command ID for later check/stop calls.
"""
self._check_command(command)
command_id = uuid.uuid4().hex[:12]
stdout_file = tempfile.NamedTemporaryFile(mode='w+b', prefix=f'harness_{command_id}_out_', delete=False)
stderr_file = tempfile.NamedTemporaryFile(mode='w+b', prefix=f'harness_{command_id}_err_', delete=False)
proc = await anyio.open_process(
command,
cwd=self._cwd,
stdout=stdout_file,
stderr=stderr_file,
start_new_session=True,
)
stdout_file.close()
stderr_file.close()
bg = _BackgroundProcess(
proc=proc,
command=command,
stdout_path=stdout_file.name,
stderr_path=stderr_file.name,
)
self._background[command_id] = bg
return f'Started background command: {command!r}\nID: {command_id}'
def _read_bg_output(self, bg: _BackgroundProcess) -> tuple[str, str]:
"""Read current output from background process temp files."""
try:
stdout = Path(bg.stdout_path).read_text(encoding='utf-8', errors='replace')
except OSError:
stdout = ''
try:
stderr = Path(bg.stderr_path).read_text(encoding='utf-8', errors='replace')
except OSError:
stderr = ''
return stdout, stderr
def _cleanup_bg_files(self, bg: _BackgroundProcess) -> None:
"""Remove temp files for a background process."""
try:
os.unlink(bg.stdout_path)
except OSError:
pass
try:
os.unlink(bg.stderr_path)
except OSError:
pass
async def check_command(self, command_id: str) -> str:
"""Check the status and recent output of a background command.
Args:
command_id: The ID returned by start_command.
Returns:
Status and recent output of the background command.
"""
bg = self._background.get(command_id)
if bg is None:
return f'[Error: unknown command ID {command_id!r}]'
if not bg.finished and bg.proc.returncode is not None:
bg.exit_code = bg.proc.returncode
bg.finished = True
stdout, stderr = self._read_bg_output(bg)
status = 'finished' if bg.finished else 'running'
parts = [f'[status: {status}]']
if bg.finished and bg.exit_code is not None:
parts.append(f'[exit code: {bg.exit_code}]')
if stdout:
parts.append(f'[stdout]\n{self._truncate(stdout)}')
if stderr:
parts.append(f'[stderr]\n{self._truncate(stderr)}')
if not stdout and not stderr:
parts.append('(no output yet)')
return '\n'.join(parts)
async def stop_command(self, command_id: str) -> str:
"""Stop a background command and return its final output.
Args:
command_id: The ID returned by start_command.
Returns:
Final output and exit status of the stopped command.
"""
bg = self._background.get(command_id)
if bg is None:
return f'[Error: unknown command ID {command_id!r}]'
if not bg.finished:
await self._kill_process_group(bg.proc)
with anyio.CancelScope(shield=True):
await bg.proc.wait()
bg.exit_code = bg.proc.returncode
bg.finished = True
stdout, stderr = self._read_bg_output(bg)
self._cleanup_bg_files(bg)
del self._background[command_id]
await bg.proc.aclose()
parts = [f'[stopped: {bg.command!r}]']
if bg.exit_code is not None:
parts.append(f'[exit code: {bg.exit_code}]')
if stdout:
parts.append(f'[stdout]\n{self._truncate(stdout)}')
if stderr:
parts.append(f'[stderr]\n{self._truncate(stderr)}')
if not stdout and not stderr:
parts.append('(no output)')
return '\n'.join(parts)
+19
View File
@@ -65,6 +65,7 @@ dev = [
'logfire[httpx]>=4.31.0',
"dirty-equals>=0.9.0",
"inline-snapshot>=0.32.5",
"mutmut>=3.5.0",
]
lint = [
'ruff>=0.14',
@@ -121,6 +122,9 @@ filterwarnings = [
'error',
# DBOS's run_sync triggers this on Python 3.12+ — not our code.
'ignore:There is no current event loop:DeprecationWarning',
# anyio subprocess cleanup can trigger these on some Python versions during GC.
'ignore::pytest.PytestUnraisableExceptionWarning',
'ignore::ResourceWarning',
]
anyio_mode = 'auto'
@@ -140,3 +144,18 @@ exclude_lines = [
'assert_never',
'if TYPE_CHECKING:',
]
[tool.mutmut]
paths_to_mutate = [
'pydantic_ai_harness/filesystem/_toolset.py',
'pydantic_ai_harness/shell/_toolset.py',
]
tests_dir = ['tests/filesystem/', 'tests/shell/']
also_copy = ['pydantic_ai_harness/', 'tests/']
# Skip trio-parametrized tests during mutation testing — trio segfaults in
# mutmut's subprocess environment on Python 3.14 (not a code bug).
pytest_add_cli_args = ['-k', 'not trio']
# Required on Python 3.14 / macOS — mutmut's subprocess workers segfault
# without debug mode.
debug = true
# See docs/mutation-testing.md for full results (89.7% kill rate, 60 equivalent mutants).
View File
+930
View File
@@ -0,0 +1,930 @@
"""Exhaustive tests for the FileSystem capability and FileSystemToolset.
Covers:
- Path traversal prevention (relative .., absolute, symlink escapes)
- Allow/deny/protected pattern enforcement
- All tool operations (read, write, edit, list, search, find, mkdir, info)
- Binary file detection
- Optimistic concurrency (hash-based conflict detection)
- Edge cases (empty files, encoding, large files, hidden files)
- Agent-level integration via TestModel
"""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel
from pydantic_ai_harness.filesystem import FileSystem
from pydantic_ai_harness.filesystem._toolset import FileSystemToolset, _content_hash, _format_lines, _is_binary
# ============================================================================
# Unit tests for helper functions
# ============================================================================
class TestFormatLines:
def test_basic_formatting(self) -> None:
text = 'line1\nline2\nline3\n'
result = _format_lines(text, 0, 10)
assert ' 1\tline1\n' in result
assert ' 2\tline2\n' in result
assert ' 3\tline3\n' in result
def test_offset(self) -> None:
text = 'a\nb\nc\nd\ne\n'
result = _format_lines(text, 2, 2)
assert ' 3\tc\n' in result
assert ' 4\td\n' in result
assert '... (1 more lines. Use offset=4 to continue reading.)' in result
def test_offset_exceeds_length(self) -> None:
text = 'a\nb\n'
with pytest.raises(ValueError, match='Offset 5 exceeds file length'):
_format_lines(text, 5, 10)
def test_empty_file(self) -> None:
result = _format_lines('', 0, 10)
assert result == '(empty file)\n'
def test_no_trailing_newline(self) -> None:
text = 'no newline'
result = _format_lines(text, 0, 10)
assert result.endswith('\n')
def test_continuation_hint(self) -> None:
text = '\n'.join(f'line{i}' for i in range(10))
result = _format_lines(text, 0, 3)
assert '... (7 more lines. Use offset=3 to continue reading.)' in result
class TestIsBinary:
def test_text_content(self) -> None:
assert _is_binary(b'hello world\n') is False
def test_binary_content(self) -> None:
assert _is_binary(b'hello\x00world') is True
def test_null_after_sample(self) -> None:
data = b'x' * 9000 + b'\x00'
assert _is_binary(data) is False
def test_null_at_boundary(self) -> None:
data = b'x' * 8191 + b'\x00'
assert _is_binary(data) is True
def test_empty(self) -> None:
assert _is_binary(b'') is False
class TestContentHash:
def test_deterministic(self) -> None:
assert _content_hash('hello') == _content_hash('hello')
def test_different_content(self) -> None:
assert _content_hash('hello') != _content_hash('world')
def test_length(self) -> None:
assert len(_content_hash('test')) == 12
# ============================================================================
# FileSystemToolset tests
# ============================================================================
@pytest.fixture
def fs_root(tmp_path: Path) -> Path:
"""Create a temporary directory with test files."""
(tmp_path / 'hello.txt').write_text('Hello, world!\n')
(tmp_path / 'multi.txt').write_text('line1\nline2\nline3\nline4\nline5\n')
(tmp_path / 'subdir').mkdir()
(tmp_path / 'subdir' / 'nested.py').write_text('print("nested")\n')
(tmp_path / '.hidden').write_text('secret\n')
(tmp_path / 'binary.bin').write_bytes(b'\x00\x01\x02\x03')
(tmp_path / '.git').mkdir()
(tmp_path / '.git' / 'config').write_text('[core]\n')
(tmp_path / '.env').write_text('SECRET_KEY=abc123\n')
return tmp_path
@pytest.fixture
def toolset(fs_root: Path) -> FileSystemToolset:
"""Create a FileSystemToolset for the test root."""
return FileSystemToolset(
root_dir=fs_root,
allowed_patterns=[],
denied_patterns=[],
protected_patterns=['.git/*', '.env', '.env.*'],
max_read_lines=2000,
max_search_results=1000,
max_find_results=1000,
)
class TestPathSecurity:
async def test_traversal_with_dotdot(self, toolset: FileSystemToolset) -> None:
with pytest.raises(PermissionError, match='resolves outside'):
toolset._resolve_path('../../../etc/passwd')
async def test_traversal_absolute_path(self, toolset: FileSystemToolset) -> None:
with pytest.raises(PermissionError, match='resolves outside'):
toolset._resolve_path('/etc/passwd')
async def test_traversal_encoded(self, toolset: FileSystemToolset) -> None:
with pytest.raises(PermissionError, match='resolves outside'):
toolset._resolve_path('subdir/../../..')
async def test_symlink_escape(self, toolset: FileSystemToolset, fs_root: Path) -> None:
"""Symlink pointing outside root is rejected."""
target = Path('/tmp/symlink-escape-target')
target.write_text('escaped!\n')
try:
link = fs_root / 'escape_link'
link.symlink_to(target)
with pytest.raises(PermissionError, match='resolves outside'):
toolset._resolve_path('escape_link')
finally:
target.unlink(missing_ok=True)
async def test_valid_path_resolves(self, toolset: FileSystemToolset, fs_root: Path) -> None:
result = toolset._resolve_path('hello.txt')
assert result == (fs_root / 'hello.txt').resolve()
async def test_nested_path_resolves(self, toolset: FileSystemToolset) -> None:
result = toolset._resolve_path('subdir/nested.py')
assert result.name == 'nested.py'
class TestAccessPatterns:
async def test_denied_pattern_blocks(self, fs_root: Path) -> None:
ts = FileSystemToolset(
root_dir=fs_root,
allowed_patterns=[],
denied_patterns=['*.secret'],
protected_patterns=[],
max_read_lines=2000,
max_search_results=1000,
max_find_results=1000,
)
with pytest.raises(PermissionError, match='denied by pattern'):
ts._check_access('data.secret')
async def test_denied_pattern_passes_non_matching(self, fs_root: Path) -> None:
ts = FileSystemToolset(
root_dir=fs_root,
allowed_patterns=[],
denied_patterns=['*.secret'],
protected_patterns=[],
max_read_lines=2000,
max_search_results=1000,
max_find_results=1000,
)
# Path that doesn't match any denied pattern should pass
ts._check_access('data.txt')
async def test_allowed_pattern_permits(self, fs_root: Path) -> None:
ts = FileSystemToolset(
root_dir=fs_root,
allowed_patterns=['*.py'],
denied_patterns=[],
protected_patterns=[],
max_read_lines=2000,
max_search_results=1000,
max_find_results=1000,
)
# Should not raise for .py files
ts._check_access('test.py')
async def test_allowed_pattern_blocks_non_matching(self, fs_root: Path) -> None:
ts = FileSystemToolset(
root_dir=fs_root,
allowed_patterns=['*.py'],
denied_patterns=[],
protected_patterns=[],
max_read_lines=2000,
max_search_results=1000,
max_find_results=1000,
)
with pytest.raises(PermissionError, match='does not match any allowed'):
ts._check_access('data.txt')
async def test_protected_pattern_blocks_write(self, toolset: FileSystemToolset) -> None:
with pytest.raises(PermissionError, match='protected'):
toolset._check_access('.git/config', write=True)
async def test_protected_pattern_allows_read(self, toolset: FileSystemToolset) -> None:
# Should not raise for read
toolset._check_access('.git/config', write=False)
async def test_env_file_protected(self, toolset: FileSystemToolset) -> None:
with pytest.raises(PermissionError, match='protected'):
toolset._check_access('.env', write=True)
async def test_write_non_protected_with_patterns_configured(self, toolset: FileSystemToolset) -> None:
# write=True on a path that doesn't match any protected pattern should pass
toolset._check_access('hello.txt', write=True)
async def test_access_with_no_denied_patterns(self, fs_root: Path) -> None:
ts = FileSystemToolset(
root_dir=fs_root,
allowed_patterns=[],
denied_patterns=[],
protected_patterns=[],
max_read_lines=2000,
max_search_results=1000,
max_find_results=1000,
)
# No denied, no protected, no allowed → should pass for any path
ts._check_access('anything.txt', write=True)
class TestReadFile:
async def test_read_basic(self, toolset: FileSystemToolset) -> None:
result = await toolset.read_file('hello.txt')
assert 'Hello, world!' in result
assert 'hash:' in result
assert '1 lines' in result
async def test_read_with_offset(self, toolset: FileSystemToolset) -> None:
result = await toolset.read_file('multi.txt', offset=2)
assert 'line3' in result
assert 'line1' not in result
async def test_read_with_limit(self, toolset: FileSystemToolset) -> None:
result = await toolset.read_file('multi.txt', limit=2)
assert 'line1' in result
assert 'line2' in result
assert '... (3 more lines' in result
async def test_read_directory_raises(self, toolset: FileSystemToolset) -> None:
with pytest.raises(FileNotFoundError, match='is a directory'):
await toolset.read_file('subdir')
async def test_read_missing_raises(self, toolset: FileSystemToolset) -> None:
with pytest.raises(FileNotFoundError, match='File not found'):
await toolset.read_file('nonexistent.txt')
async def test_read_binary_file(self, toolset: FileSystemToolset) -> None:
result = await toolset.read_file('binary.bin')
assert 'Binary file' in result
assert '4 bytes' in result
async def test_read_traversal_blocked(self, toolset: FileSystemToolset) -> None:
with pytest.raises(PermissionError):
await toolset.read_file('../../../etc/passwd')
class TestWriteFile:
async def test_write_new_file(self, toolset: FileSystemToolset, fs_root: Path) -> None:
result = await toolset.write_file('new.txt', 'new content\n')
assert 'Wrote' in result
assert (fs_root / 'new.txt').read_text() == 'new content\n'
async def test_write_creates_parents(self, toolset: FileSystemToolset, fs_root: Path) -> None:
result = await toolset.write_file('deep/nested/file.txt', 'deep\n')
assert 'Wrote' in result
assert (fs_root / 'deep' / 'nested' / 'file.txt').read_text() == 'deep\n'
async def test_write_overwrite(self, toolset: FileSystemToolset, fs_root: Path) -> None:
await toolset.write_file('hello.txt', 'overwritten\n')
assert (fs_root / 'hello.txt').read_text() == 'overwritten\n'
async def test_write_conflict_detection(self, toolset: FileSystemToolset, fs_root: Path) -> None:
# Get current hash
content = (fs_root / 'hello.txt').read_text()
current_hash = _content_hash(content)
# Write with correct hash succeeds
await toolset.write_file('hello.txt', 'updated\n', expected_hash=current_hash)
assert (fs_root / 'hello.txt').read_text() == 'updated\n'
async def test_write_conflict_rejection(self, toolset: FileSystemToolset, fs_root: Path) -> None:
with pytest.raises(ValueError, match='Conflict'):
await toolset.write_file('hello.txt', 'bad\n', expected_hash='wrong_hash_x')
async def test_write_protected_blocked(self, toolset: FileSystemToolset) -> None:
with pytest.raises(PermissionError, match='protected'):
await toolset.write_file('.env', 'HACKED=true\n')
async def test_write_returns_hash(self, toolset: FileSystemToolset) -> None:
result = await toolset.write_file('hashed.txt', 'content\n')
assert 'hash:' in result
class TestEditFile:
async def test_edit_basic(self, toolset: FileSystemToolset, fs_root: Path) -> None:
result = await toolset.edit_file('hello.txt', 'Hello, world!', 'Hello, universe!')
assert 'Edited' in result
assert (fs_root / 'hello.txt').read_text() == 'Hello, universe!\n'
async def test_edit_not_found_text(self, toolset: FileSystemToolset) -> None:
with pytest.raises(ValueError, match='old_text not found'):
await toolset.edit_file('hello.txt', 'NONEXISTENT', 'replacement')
async def test_edit_ambiguous_match(self, toolset: FileSystemToolset, fs_root: Path) -> None:
(fs_root / 'repeat.txt').write_text('foo bar foo\n')
with pytest.raises(ValueError, match='found 2 times'):
await toolset.edit_file('repeat.txt', 'foo', 'baz')
async def test_edit_missing_file(self, toolset: FileSystemToolset) -> None:
with pytest.raises(FileNotFoundError, match='File not found'):
await toolset.edit_file('ghost.txt', 'x', 'y')
async def test_edit_conflict_detection(self, toolset: FileSystemToolset, fs_root: Path) -> None:
content = (fs_root / 'hello.txt').read_text()
current_hash = _content_hash(content)
result = await toolset.edit_file('hello.txt', 'Hello', 'Hi', expected_hash=current_hash)
assert 'hash:' in result
async def test_edit_conflict_rejection(self, toolset: FileSystemToolset) -> None:
with pytest.raises(ValueError, match='Conflict'):
await toolset.edit_file('hello.txt', 'Hello', 'Hi', expected_hash='stale_hash_')
async def test_edit_protected_blocked(self, toolset: FileSystemToolset) -> None:
with pytest.raises(PermissionError, match='protected'):
await toolset.edit_file('.env', 'SECRET', 'HACKED')
async def test_edit_returns_new_hash(self, toolset: FileSystemToolset) -> None:
result = await toolset.edit_file('hello.txt', 'Hello, world!', 'Goodbye!')
assert 'hash:' in result
class TestListDirectory:
async def test_list_root(self, toolset: FileSystemToolset) -> None:
result = await toolset.list_directory('.')
assert 'hello.txt' in result
assert 'subdir/' in result
async def test_list_subdir(self, toolset: FileSystemToolset) -> None:
result = await toolset.list_directory('subdir')
assert 'nested.py' in result
async def test_list_not_a_dir(self, toolset: FileSystemToolset) -> None:
with pytest.raises(NotADirectoryError):
await toolset.list_directory('hello.txt')
async def test_list_shows_sizes(self, toolset: FileSystemToolset) -> None:
result = await toolset.list_directory('.')
assert 'bytes' in result
async def test_list_shows_dir_indicator(self, toolset: FileSystemToolset) -> None:
result = await toolset.list_directory('.')
assert 'subdir/' in result
async def test_list_empty_directory(self, toolset: FileSystemToolset, fs_root: Path) -> None:
(fs_root / 'empty').mkdir()
result = await toolset.list_directory('empty')
assert result == '(empty directory)'
class TestSearchFiles:
async def test_search_basic(self, toolset: FileSystemToolset) -> None:
result = await toolset.search_files('Hello')
assert 'hello.txt:1:Hello, world!' in result
async def test_search_regex(self, toolset: FileSystemToolset) -> None:
result = await toolset.search_files(r'line\d')
assert 'multi.txt' in result
async def test_search_no_matches(self, toolset: FileSystemToolset) -> None:
result = await toolset.search_files('ZZZZNOTHERE')
assert result == 'No matches found.'
async def test_search_skips_hidden(self, toolset: FileSystemToolset) -> None:
result = await toolset.search_files('secret')
assert '.hidden' not in result
async def test_search_skips_binary(self, toolset: FileSystemToolset) -> None:
result = await toolset.search_files('.')
assert 'binary.bin' not in result
async def test_search_invalid_regex(self, toolset: FileSystemToolset) -> None:
with pytest.raises(ValueError, match='Invalid regex'):
await toolset.search_files('[invalid')
async def test_search_include_glob(self, toolset: FileSystemToolset) -> None:
result = await toolset.search_files('print', include_glob='*.py')
assert 'nested.py' in result
async def test_search_include_glob_excludes(self, toolset: FileSystemToolset) -> None:
result = await toolset.search_files('Hello', include_glob='*.py')
assert result == 'No matches found.'
async def test_search_in_specific_file(self, toolset: FileSystemToolset) -> None:
result = await toolset.search_files('line', path='multi.txt')
assert 'multi.txt' in result
async def test_search_truncation(self, fs_root: Path) -> None:
# Create many matching files
for i in range(20):
(fs_root / f'match{i}.txt').write_text('findme\n' * 100)
ts = FileSystemToolset(
root_dir=fs_root,
allowed_patterns=[],
denied_patterns=[],
protected_patterns=[],
max_read_lines=2000,
max_search_results=50,
max_find_results=1000,
)
result = await ts.search_files('findme')
assert 'truncated at 50 matches' in result
class TestFindFiles:
async def test_find_glob(self, toolset: FileSystemToolset) -> None:
result = await toolset.find_files('*.txt')
assert 'hello.txt' in result
assert 'multi.txt' in result
async def test_find_recursive(self, toolset: FileSystemToolset) -> None:
result = await toolset.find_files('**/*.py')
assert 'nested.py' in result
async def test_find_no_matches(self, toolset: FileSystemToolset) -> None:
result = await toolset.find_files('*.xyz')
assert result == 'No matches found.'
async def test_find_skips_hidden(self, toolset: FileSystemToolset) -> None:
result = await toolset.find_files('*')
assert '.hidden' not in result
assert '.git' not in result
async def test_find_not_a_dir(self, toolset: FileSystemToolset) -> None:
with pytest.raises(NotADirectoryError):
await toolset.find_files('*.txt', path='hello.txt')
async def test_find_in_subdir(self, toolset: FileSystemToolset) -> None:
result = await toolset.find_files('*.py', path='subdir')
assert 'nested.py' in result
async def test_find_directories(self, toolset: FileSystemToolset) -> None:
result = await toolset.find_files('sub*')
assert 'subdir/' in result
async def test_find_truncation(self, fs_root: Path) -> None:
for i in range(20):
(fs_root / f'file{i}.dat').write_text(f'{i}\n')
ts = FileSystemToolset(
root_dir=fs_root,
allowed_patterns=[],
denied_patterns=[],
protected_patterns=[],
max_read_lines=2000,
max_search_results=1000,
max_find_results=5,
)
result = await ts.find_files('*.dat')
assert 'truncated at 5 matches' in result
class TestCreateDirectory:
async def test_create_basic(self, toolset: FileSystemToolset, fs_root: Path) -> None:
result = await toolset.create_directory('newdir')
assert 'Created directory' in result
assert (fs_root / 'newdir').is_dir()
async def test_create_nested(self, toolset: FileSystemToolset, fs_root: Path) -> None:
await toolset.create_directory('a/b/c')
assert (fs_root / 'a' / 'b' / 'c').is_dir()
async def test_create_existing_ok(self, toolset: FileSystemToolset) -> None:
result = await toolset.create_directory('subdir')
assert 'Created directory' in result
async def test_create_protected_blocked(self, toolset: FileSystemToolset) -> None:
with pytest.raises(PermissionError, match='protected'):
await toolset.create_directory('.git/hooks')
class TestFileInfo:
async def test_info_file(self, toolset: FileSystemToolset) -> None:
result = await toolset.file_info('hello.txt')
assert 'type: file' in result
assert 'size:' in result
assert 'lines:' in result
assert 'hash:' in result
assert 'binary: False' in result
async def test_info_directory(self, toolset: FileSystemToolset) -> None:
result = await toolset.file_info('subdir')
assert 'type: directory' in result
async def test_info_binary(self, toolset: FileSystemToolset) -> None:
result = await toolset.file_info('binary.bin')
assert 'binary: True' in result
assert 'lines:' not in result
async def test_info_not_found(self, toolset: FileSystemToolset) -> None:
with pytest.raises(FileNotFoundError, match='Path not found'):
await toolset.file_info('nonexistent')
async def test_info_symlink(self, toolset: FileSystemToolset, fs_root: Path) -> None:
link = fs_root / 'link.txt'
link.symlink_to(fs_root / 'hello.txt')
result = await toolset.file_info('link.txt')
assert 'type: file' in result
assert 'symlink_target:' in result
# ============================================================================
# Capability integration tests
# ============================================================================
# ============================================================================
# Mutation-killing tests (boundary conditions, operator swaps, negation)
# ============================================================================
class TestMutationKillers:
"""Tests targeting specific mutations that might survive."""
async def test_format_lines_offset_equals_total(self) -> None:
"""Kill: offset >= total → offset > total."""
text = 'a\nb\n' # 2 lines
with pytest.raises(ValueError, match='Offset 2 exceeds file length'):
_format_lines(text, 2, 10)
async def test_format_lines_exact_fit_no_continuation(self) -> None:
"""Kill: remaining > 0 → remaining >= 0."""
text = 'a\nb\nc\n' # 3 lines
result = _format_lines(text, 0, 3)
assert '... (' not in result
assert 'more lines' not in result
async def test_format_lines_exact_fit_from_offset(self) -> None:
"""Kill: remaining > 0 → remaining >= 0 with offset."""
text = 'a\nb\nc\n' # 3 lines
result = _format_lines(text, 1, 2) # lines 2-3, 0 remaining
assert '... (' not in result
assert 'more lines' not in result
async def test_format_lines_one_line_remaining(self) -> None:
"""Kill: remaining > 0 → remaining > 1."""
text = 'a\nb\nc\n' # 3 lines
result = _format_lines(text, 0, 2)
assert '... (1 more lines. Use offset=2 to continue reading.)' in result
async def test_format_lines_line_number_starts_at_one(self) -> None:
"""Kill: start=offset + 1 → start=offset."""
text = 'first\nsecond\n'
result = _format_lines(text, 0, 10)
assert ' 1\tfirst\n' in result
assert ' 0\t' not in result
async def test_format_lines_offset_line_numbering(self) -> None:
"""Kill: start=offset + 1 → start=offset + 2."""
text = 'a\nb\nc\n'
result = _format_lines(text, 1, 2)
assert ' 2\tb\n' in result
assert ' 3\tc\n' in result
async def test_is_binary_exactly_at_sample_boundary(self) -> None:
"""Kill: sample_size mutations at the exact boundary."""
# Null byte at position 8191 (index 8191, within first 8192 bytes)
data = b'x' * 8191 + b'\x00'
assert _is_binary(data) is True
# Null byte at position 8192 (outside the sample)
data2 = b'x' * 8192 + b'\x00'
assert _is_binary(data2) is False
async def test_content_hash_returns_exactly_12_chars(self) -> None:
"""Kill: [:12] → [:11] or [:13]."""
h = _content_hash('test content')
assert len(h) == 12
# Verify it's hex characters
assert all(c in '0123456789abcdef' for c in h)
async def test_write_file_with_hash_on_new_file(self, toolset: FileSystemToolset, fs_root: Path) -> None:
"""Kill: expected_hash is not None and resolved.is_file() → expected_hash is not None.
When a file doesn't exist, expected_hash should be ignored and the write should succeed.
"""
result = await toolset.write_file('brand_new.txt', 'new content\n', expected_hash='any_hash_val')
assert 'Wrote' in result
assert (fs_root / 'brand_new.txt').read_text() == 'new content\n'
async def test_edit_file_single_match_succeeds(self, toolset: FileSystemToolset, fs_root: Path) -> None:
"""Kill: count > 1 → count >= 1 (single match must not raise)."""
(fs_root / 'unique.txt').write_text('unique text here\n')
result = await toolset.edit_file('unique.txt', 'unique text', 'replaced text')
assert 'Edited' in result
assert (fs_root / 'unique.txt').read_text() == 'replaced text here\n'
async def test_edit_file_zero_matches_raises(self, toolset: FileSystemToolset) -> None:
"""Kill: count == 0 → count != 0 or count == 1."""
with pytest.raises(ValueError, match='old_text not found'):
await toolset.edit_file('hello.txt', 'DEFINITELY NOT IN FILE', 'x')
async def test_search_truncation_stops_after_limit(self, fs_root: Path) -> None:
"""Kill: removing the 'break' after truncation message."""
# Create many files with 1 match each so truncation is per-file
for i in range(10):
(fs_root / f'searchable{i}.txt').write_text(f'match_this_{i}\n')
ts = FileSystemToolset(
root_dir=fs_root,
allowed_patterns=[],
denied_patterns=[],
protected_patterns=[],
max_read_lines=2000,
max_search_results=5,
max_find_results=1000,
)
result = await ts.search_files('match_this')
lines = result.strip().split('\n')
# Truncation check is after each file, so 5 matches + truncation msg
# Ensure we don't get all 10 matches
match_lines = [ln for ln in lines if ln.startswith('searchable')]
assert len(match_lines) <= 5
assert 'truncated at 5 matches' in lines[-1]
async def test_find_truncation_stops_after_limit(self, fs_root: Path) -> None:
"""Kill: removing the 'break' after truncation in find_files."""
for i in range(10):
(fs_root / f'findme{i:02d}.dat').write_text(f'{i}\n')
ts = FileSystemToolset(
root_dir=fs_root,
allowed_patterns=[],
denied_patterns=[],
protected_patterns=[],
max_read_lines=2000,
max_search_results=1000,
max_find_results=3,
)
result = await ts.find_files('*.dat')
lines = result.strip().split('\n')
# Should have exactly 4 lines: 3 matches + 1 truncation message
assert len(lines) == 4
assert 'truncated at 3 matches' in lines[-1]
async def test_read_file_default_limit_used(self, toolset: FileSystemToolset, fs_root: Path) -> None:
"""Kill: if limit is None: limit = self._max_read_lines → removing this."""
# Create file with more lines than we'd see with limit=0
(fs_root / 'big.txt').write_text('\n'.join(f'line{i}' for i in range(100)) + '\n')
result = await toolset.read_file('big.txt')
# All 100 lines should be present since max_read_lines is 2000
assert 'line99' in result
async def test_list_directory_with_files_not_empty(self, toolset: FileSystemToolset) -> None:
"""Kill: 'entries' being falsy check — ensure non-empty dirs return actual content."""
result = await toolset.list_directory('subdir')
assert result != '(empty directory)'
assert 'nested.py' in result
async def test_search_in_file_returns_only_that_file(self, toolset: FileSystemToolset, fs_root: Path) -> None:
"""Kill: if resolved.is_file(): files = [resolved] → files = sorted(resolved.rglob('*'))."""
# Both files contain 'Hello' / 'hello' but searching a specific file should only return from that file
(fs_root / 'other.txt').write_text('Hello from other\n')
result = await toolset.search_files('Hello', path='hello.txt')
assert 'hello.txt' in result
assert 'other.txt' not in result
async def test_file_info_non_binary_shows_lines_and_hash(self, toolset: FileSystemToolset) -> None:
"""Kill: not is_bin → is_bin (negation of binary check in file_info)."""
result = await toolset.file_info('hello.txt')
assert 'lines: 1' in result
assert 'hash:' in result
assert 'binary: False' in result
async def test_file_info_binary_no_lines_no_hash(self, toolset: FileSystemToolset) -> None:
"""Kill: not is_bin → is_bin (ensure binary files DON'T get lines/hash)."""
result = await toolset.file_info('binary.bin')
assert 'binary: True' in result
assert 'lines:' not in result
assert 'hash:' not in result
async def test_safe_resolve_passes_write_flag(self, toolset: FileSystemToolset, fs_root: Path) -> None:
"""Kill: _safe_resolve not passing write= to _check_access."""
# Protected patterns block writes but allow reads
(fs_root / '.env.local').write_text('SECRET=x\n')
# Read should work (write=False internally)
result = await toolset.read_file('.env.local')
assert 'SECRET=x' in result
# Write should be blocked (write=True internally)
with pytest.raises(PermissionError, match='protected'):
await toolset.write_file('.env.local', 'HACKED\n')
async def test_format_lines_join_separator(self) -> None:
"""Kill: ''.join(numbered) → 'XXXX'.join(numbered).
Verify the result doesn't contain garbage between lines.
"""
text = 'a\nb\nc\n'
result = _format_lines(text, 0, 3)
# Lines should be directly adjacent (no separator between them)
assert ' 1\ta\n 2\tb\n 3\tc\n' in result
async def test_format_lines_no_trailing_newline_preserves_content(self) -> None:
"""Kill: result += '\\n' → result = '\\n' (content destroyed)."""
text = 'no newline'
result = _format_lines(text, 0, 10)
# The content must still be present
assert 'no newline' in result
assert result.endswith('\n')
async def test_read_file_hash_is_real_hash(self, toolset: FileSystemToolset) -> None:
"""Kill: content_hash = _content_hash(text) → content_hash = None."""
result = await toolset.read_file('hello.txt')
# The actual hash should be a hex string, not 'None'
assert 'hash:None' not in result
# Verify the hash matches what we'd compute
expected_hash = _content_hash('Hello, world!\n')
assert f'hash:{expected_hash}' in result
async def test_read_file_non_ascii_content(self, toolset: FileSystemToolset, fs_root: Path) -> None:
"""Kill: errors='replace' removal and errors='XXreplaceXX'.
With invalid UTF-8 bytes, the tool should not crash — it should use replacement chars.
"""
# Write raw bytes that are invalid UTF-8
(fs_root / 'broken_utf8.txt').write_bytes(b'hello \xff\xfe world\n')
result = await toolset.read_file('broken_utf8.txt')
# Should not crash, content should contain replacement characters
assert 'hello' in result
assert 'world' in result
async def test_read_file_default_offset_starts_at_first_line(self, toolset: FileSystemToolset) -> None:
"""Kill: offset: int = 0 → offset: int = 1 (default param change).
The first line must be included when no offset is specified.
"""
result = await toolset.read_file('multi.txt')
# First line must be present (line1)
assert ' 1\tline1' in result
# Verify line numbering starts at 1
assert ' 0\t' not in result
async def test_toolset_tool_names(self, toolset: FileSystemToolset) -> None:
"""Kill: name='read_file' → name=None / name='XXread_fileXX'.
Verify tools are registered with correct names.
"""
tool_names = set(toolset.tools.keys())
assert 'read_file' in tool_names
assert 'write_file' in tool_names
assert 'edit_file' in tool_names
assert 'list_directory' in tool_names
assert 'search_files' in tool_names
assert 'find_files' in tool_names
assert 'create_directory' in tool_names
assert 'file_info' in tool_names
async def test_write_file_output_format(self, toolset: FileSystemToolset, fs_root: Path) -> None:
"""Kill: write return string mutations."""
result = await toolset.write_file('fmt.txt', 'ab\ncd\n')
# Verify specific format: chars, lines, path, hash
assert 'Wrote 6 chars (2 lines) to fmt.txt.' in result
assert 'hash:' in result
# Verify hash is a real hex hash not None
assert 'hash:None' not in result
async def test_edit_file_output_format(self, toolset: FileSystemToolset, fs_root: Path) -> None:
"""Kill: edit return string mutations."""
result = await toolset.edit_file('hello.txt', 'Hello, world!', 'Hi')
assert result.startswith('Edited hello.txt.')
assert 'hash:' in result
assert 'hash:None' not in result
def test_format_lines_no_double_trailing_newline(self) -> None:
"""Kill: result.endswith('\\n') → result.endswith('XX\\nXX').
Text that already ends with newline must NOT get a second one appended.
"""
text = 'hello\n'
result = _format_lines(text, 0, 10)
# Exact match: no trailing double newline
assert result == ' 1\thello\n'
def test_safe_resolve_write_default_is_false(self, toolset: FileSystemToolset, fs_root: Path) -> None:
"""Kill: _safe_resolve write: bool = False → True.
Synchronous test to avoid trio crash confusing mutmut.
Protected files should be READABLE via _safe_resolve's default (write=False).
"""
(fs_root / '.env.local').write_text('SECRET=x\n')
# _safe_resolve without write= uses default write=False → read is allowed
resolved = toolset._safe_resolve('.env.local')
assert resolved.name == '.env.local'
# But with write=True, it should raise
with pytest.raises(PermissionError, match='protected'):
toolset._safe_resolve('.env.local', write=True)
async def test_list_directory_exact_size(self, toolset: FileSystemToolset) -> None:
"""Kill: size = stat.st_size → size = None."""
result = await toolset.list_directory('.')
# hello.txt has 'Hello, world!\n' = 14 bytes
assert '14 bytes' in result
async def test_list_directory_no_garbage_separator(self, toolset: FileSystemToolset) -> None:
"""Kill: '\\n'.join(entries) → 'XX\\nXX'.join(entries)."""
result = await toolset.list_directory('.')
assert 'XX' not in result
async def test_list_directory_error_message(self, toolset: FileSystemToolset) -> None:
"""Kill: NotADirectoryError(f'...') → NotADirectoryError(None)."""
with pytest.raises(NotADirectoryError, match='Not a directory'):
await toolset.list_directory('hello.txt')
async def test_find_files_error_message(self, toolset: FileSystemToolset) -> None:
"""Kill: NotADirectoryError(f'...') → NotADirectoryError(None)."""
with pytest.raises(NotADirectoryError, match='Not a directory'):
await toolset.find_files('*.txt', path='hello.txt')
async def test_find_files_no_suffix_on_files(self, toolset: FileSystemToolset) -> None:
"""Kill: suffix '''XXXX' for non-directory entries."""
result = await toolset.find_files('*.txt')
for line in result.splitlines():
if not line.endswith('/'):
assert 'XXXX' not in line
async def test_find_files_no_garbage_separator(self, toolset: FileSystemToolset) -> None:
"""Kill: '\\n'.join(matches) → 'XX\\nXX'.join(matches)."""
result = await toolset.find_files('*.txt')
assert 'XX' not in result
async def test_search_files_no_garbage_separator(self, toolset: FileSystemToolset) -> None:
"""Kill: '\\n'.join(results) → 'XX\\nXX'.join(results)."""
result = await toolset.search_files(r'line\d')
assert 'XX' not in result
async def test_file_info_exact_size(self, toolset: FileSystemToolset) -> None:
"""Kill: size = stat.st_size → size = None."""
result = await toolset.file_info('hello.txt')
assert '14 bytes' in result
async def test_file_info_no_garbage_separator(self, toolset: FileSystemToolset) -> None:
"""Kill: '\\n'.join(parts) → 'XX\\nXX'.join(parts)."""
result = await toolset.file_info('hello.txt')
assert 'XX' not in result
async def test_search_with_invalid_utf8_file(self, toolset: FileSystemToolset, fs_root: Path) -> None:
"""Kill: errors='replace' removal and errors='XXreplaceXX'.
A file with invalid UTF-8 (but no null bytes = not binary) should be searchable.
"""
# Write a file with invalid UTF-8 but no null bytes (not detected as binary)
(fs_root / 'bad_encoding.txt').write_bytes(b'marker_text \xff\xfe end\n')
result = await toolset.search_files('marker_text')
# Should find the file even with broken encoding
assert 'bad_encoding.txt' in result
async def test_search_binary_skip_does_not_stop_iteration(self, toolset: FileSystemToolset) -> None:
"""Kill: if _is_binary(raw): continue → break.
A binary file must be skipped, but subsequent text files must still be searched.
"""
# binary.bin exists in the fixture and comes before 'hello.txt' alphabetically
result = await toolset.search_files('Hello')
# hello.txt must still be found (binary.bin didn't break the loop)
assert 'hello.txt' in result
async def test_find_hidden_skip_does_not_stop_iteration(self, toolset: FileSystemToolset) -> None:
"""Kill: if any(part.startswith('.')): continue → break.
Hidden files must be skipped, but subsequent visible files must still appear.
"""
# .hidden comes before hello.txt alphabetically — skipping must not break the loop
result = await toolset.find_files('*')
assert 'hello.txt' in result
assert 'multi.txt' in result
class TestFileSystemCapability:
def test_default_construction(self) -> None:
fs = FileSystem()
assert fs.root_dir == '.'
assert fs.max_read_lines == 2000
def test_custom_construction(self, tmp_path: Path) -> None:
fs = FileSystem(
root_dir=tmp_path,
allowed_patterns=['*.py'],
denied_patterns=['test_*'],
max_read_lines=500,
)
assert fs.max_read_lines == 500
def test_get_toolset_returns_toolset(self, tmp_path: Path) -> None:
fs = FileSystem(root_dir=tmp_path)
toolset = fs.get_toolset()
assert isinstance(toolset, FileSystemToolset)
def test_protected_defaults(self) -> None:
fs = FileSystem()
assert '.git/*' in fs.protected_patterns
assert '.env' in fs.protected_patterns
@pytest.mark.anyio(backends=['asyncio'])
async def test_agent_integration(self, tmp_path: Path, anyio_backend: object) -> None:
if str(anyio_backend) != 'asyncio':
pytest.skip('Agent.run requires asyncio event loop')
(tmp_path / 'test.txt').write_text('hello agent\n')
model = TestModel(custom_output_text='done', call_tools=[])
agent: Agent[None, str] = Agent(model, capabilities=[FileSystem(root_dir=tmp_path)])
result = await agent.run('read test.txt')
assert result.output == 'done'
View File
File diff suppressed because it is too large Load Diff
Generated
+274
View File
@@ -1,6 +1,11 @@
version = 1
revision = 3
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version == '3.13.*'",
"python_full_version < '3.13'",
]
[options]
@@ -560,6 +565,86 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/2c/4b209e9dd6700cea0c0e39d7e5e70e9f494f817a374174a823bd11561d31/inline_snapshot-0.33.0-py3-none-any.whl", hash = "sha256:76b8c2c5899d27d3d464d1160eb3b8eee179ba635bb80a8e5e93220f10b60207", size = 89625, upload-time = "2026-05-12T18:39:46.43Z" },
]
[[package]]
name = "libcst"
version = "1.8.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyyaml", marker = "python_full_version != '3.13.*'" },
{ name = "pyyaml-ft", marker = "python_full_version == '3.13.*'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/cd/337df968b38d94c5aabd3e1b10630f047a2b345f6e1d4456bd9fe7417537/libcst-1.8.6.tar.gz", hash = "sha256:f729c37c9317126da9475bdd06a7208eb52fcbd180a6341648b45a56b4ba708b", size = 891354, upload-time = "2025-11-03T22:33:30.621Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/52/97d5454dee9d014821fe0c88f3dc0e83131b97dd074a4d49537056a75475/libcst-1.8.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a20c5182af04332cc94d8520792befda06d73daf2865e6dddc5161c72ea92cb9", size = 2211698, upload-time = "2025-11-03T22:31:50.117Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a4/d1205985d378164687af3247a9c8f8bdb96278b0686ac98ab951bc6d336a/libcst-1.8.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36473e47cb199b7e6531d653ee6ffed057de1d179301e6c67f651f3af0b499d6", size = 2093104, upload-time = "2025-11-03T22:31:52.189Z" },
{ url = "https://files.pythonhosted.org/packages/9e/de/1338da681b7625b51e584922576d54f1b8db8fc7ff4dc79121afc5d4d2cd/libcst-1.8.6-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:06fc56335a45d61b7c1b856bfab4587b84cfe31e9d6368f60bb3c9129d900f58", size = 2237419, upload-time = "2025-11-03T22:31:53.526Z" },
{ url = "https://files.pythonhosted.org/packages/50/06/ee66f2d83b870534756e593d464d8b33b0914c224dff3a407e0f74dc04e0/libcst-1.8.6-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6b23d14a7fc0addd9795795763af26b185deb7c456b1e7cc4d5228e69dab5ce8", size = 2300820, upload-time = "2025-11-03T22:31:55.995Z" },
{ url = "https://files.pythonhosted.org/packages/9c/ca/959088729de8e0eac8dd516e4fb8623d8d92bad539060fa85c9e94d418a5/libcst-1.8.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:16cfe0cfca5fd840e1fb2c30afb628b023d3085b30c3484a79b61eae9d6fe7ba", size = 2301201, upload-time = "2025-11-03T22:31:57.347Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4c/2a21a8c452436097dfe1da277f738c3517f3f728713f16d84b9a3d67ca8d/libcst-1.8.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:455f49a93aea4070132c30ebb6c07c2dea0ba6c1fde5ffde59fc45dbb9cfbe4b", size = 2408213, upload-time = "2025-11-03T22:31:59.221Z" },
{ url = "https://files.pythonhosted.org/packages/3e/26/8f7b671fad38a515bb20b038718fd2221ab658299119ac9bcec56c2ced27/libcst-1.8.6-cp310-cp310-win_amd64.whl", hash = "sha256:72cca15800ffc00ba25788e4626189fe0bc5fe2a0c1cb4294bce2e4df21cc073", size = 2119189, upload-time = "2025-11-03T22:32:00.696Z" },
{ url = "https://files.pythonhosted.org/packages/5b/bf/ffb23a48e27001165cc5c81c5d9b3d6583b21b7f5449109e03a0020b060c/libcst-1.8.6-cp310-cp310-win_arm64.whl", hash = "sha256:6cad63e3a26556b020b634d25a8703b605c0e0b491426b3e6b9e12ed20f09100", size = 2001736, upload-time = "2025-11-03T22:32:02.986Z" },
{ url = "https://files.pythonhosted.org/packages/dc/15/95c2ecadc0fb4af8a7057ac2012a4c0ad5921b9ef1ace6c20006b56d3b5f/libcst-1.8.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3649a813660fbffd7bc24d3f810b1f75ac98bd40d9d6f56d1f0ee38579021073", size = 2211289, upload-time = "2025-11-03T22:32:04.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/c3/7e1107acd5ed15cf60cc07c7bb64498a33042dc4821874aea3ec4942f3cd/libcst-1.8.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0cbe17067055829607c5ba4afa46bfa4d0dd554c0b5a583546e690b7367a29b6", size = 2092927, upload-time = "2025-11-03T22:32:06.209Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ff/0d2be87f67e2841a4a37d35505e74b65991d30693295c46fc0380ace0454/libcst-1.8.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:59a7e388c57d21d63722018978a8ddba7b176e3a99bd34b9b84a576ed53f2978", size = 2237002, upload-time = "2025-11-03T22:32:07.559Z" },
{ url = "https://files.pythonhosted.org/packages/69/99/8c4a1b35c7894ccd7d33eae01ac8967122f43da41325223181ca7e4738fe/libcst-1.8.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b6c1248cc62952a3a005792b10cdef2a4e130847be9c74f33a7d617486f7e532", size = 2301048, upload-time = "2025-11-03T22:32:08.869Z" },
{ url = "https://files.pythonhosted.org/packages/9b/8b/d1aa811eacf936cccfb386ae0585aa530ea1221ccf528d67144e041f5915/libcst-1.8.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6421a930b028c5ef4a943b32a5a78b7f1bf15138214525a2088f11acbb7d3d64", size = 2300675, upload-time = "2025-11-03T22:32:10.579Z" },
{ url = "https://files.pythonhosted.org/packages/c6/6b/7b65cd41f25a10c1fef2389ddc5c2b2cc23dc4d648083fa3e1aa7e0eeac2/libcst-1.8.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6d8b67874f2188399a71a71731e1ba2d1a2c3173b7565d1cc7ffb32e8fbaba5b", size = 2407934, upload-time = "2025-11-03T22:32:11.856Z" },
{ url = "https://files.pythonhosted.org/packages/c5/8b/401cfff374bb3b785adfad78f05225225767ee190997176b2a9da9ed9460/libcst-1.8.6-cp311-cp311-win_amd64.whl", hash = "sha256:b0d8c364c44ae343937f474b2e492c1040df96d94530377c2f9263fb77096e4f", size = 2119247, upload-time = "2025-11-03T22:32:13.279Z" },
{ url = "https://files.pythonhosted.org/packages/f1/17/085f59eaa044b6ff6bc42148a5449df2b7f0ba567307de7782fe85c39ee2/libcst-1.8.6-cp311-cp311-win_arm64.whl", hash = "sha256:5dcaaebc835dfe5755bc85f9b186fb7e2895dda78e805e577fef1011d51d5a5c", size = 2001774, upload-time = "2025-11-03T22:32:14.647Z" },
{ url = "https://files.pythonhosted.org/packages/0c/3c/93365c17da3d42b055a8edb0e1e99f1c60c776471db6c9b7f1ddf6a44b28/libcst-1.8.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c13d5bd3d8414a129e9dccaf0e5785108a4441e9b266e1e5e9d1f82d1b943c9", size = 2206166, upload-time = "2025-11-03T22:32:16.012Z" },
{ url = "https://files.pythonhosted.org/packages/1d/cb/7530940e6ac50c6dd6022349721074e19309eb6aa296e942ede2213c1a19/libcst-1.8.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1472eeafd67cdb22544e59cf3bfc25d23dc94058a68cf41f6654ff4fcb92e09", size = 2083726, upload-time = "2025-11-03T22:32:17.312Z" },
{ url = "https://files.pythonhosted.org/packages/1b/cf/7e5eaa8c8f2c54913160671575351d129170db757bb5e4b7faffed022271/libcst-1.8.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:089c58e75cb142ec33738a1a4ea7760a28b40c078ab2fd26b270dac7d2633a4d", size = 2235755, upload-time = "2025-11-03T22:32:18.859Z" },
{ url = "https://files.pythonhosted.org/packages/55/54/570ec2b0e9a3de0af9922e3bb1b69a5429beefbc753a7ea770a27ad308bd/libcst-1.8.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c9d7aeafb1b07d25a964b148c0dda9451efb47bbbf67756e16eeae65004b0eb5", size = 2301473, upload-time = "2025-11-03T22:32:20.499Z" },
{ url = "https://files.pythonhosted.org/packages/11/4c/163457d1717cd12181c421a4cca493454bcabd143fc7e53313bc6a4ad82a/libcst-1.8.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:207481197afd328aa91d02670c15b48d0256e676ce1ad4bafb6dc2b593cc58f1", size = 2298899, upload-time = "2025-11-03T22:32:21.765Z" },
{ url = "https://files.pythonhosted.org/packages/35/1d/317ddef3669883619ef3d3395ea583305f353ef4ad87d7a5ac1c39be38e3/libcst-1.8.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:375965f34cc6f09f5f809244d3ff9bd4f6cb6699f571121cebce53622e7e0b86", size = 2408239, upload-time = "2025-11-03T22:32:23.275Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a1/f47d8cccf74e212dd6044b9d6dbc223636508da99acff1d54786653196bc/libcst-1.8.6-cp312-cp312-win_amd64.whl", hash = "sha256:da95b38693b989eaa8d32e452e8261cfa77fe5babfef1d8d2ac25af8c4aa7e6d", size = 2119660, upload-time = "2025-11-03T22:32:24.822Z" },
{ url = "https://files.pythonhosted.org/packages/19/d0/dd313bf6a7942cdf951828f07ecc1a7695263f385065edc75ef3016a3cb5/libcst-1.8.6-cp312-cp312-win_arm64.whl", hash = "sha256:bff00e1c766658adbd09a175267f8b2f7616e5ee70ce45db3d7c4ce6d9f6bec7", size = 1999824, upload-time = "2025-11-03T22:32:26.131Z" },
{ url = "https://files.pythonhosted.org/packages/90/01/723cd467ec267e712480c772aacc5aa73f82370c9665162fd12c41b0065b/libcst-1.8.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7445479ebe7d1aff0ee094ab5a1c7718e1ad78d33e3241e1a1ec65dcdbc22ffb", size = 2206386, upload-time = "2025-11-03T22:32:27.422Z" },
{ url = "https://files.pythonhosted.org/packages/17/50/b944944f910f24c094f9b083f76f61e3985af5a376f5342a21e01e2d1a81/libcst-1.8.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fc3fef8a2c983e7abf5d633e1884c5dd6fa0dcb8f6e32035abd3d3803a3a196", size = 2083945, upload-time = "2025-11-03T22:32:28.847Z" },
{ url = "https://files.pythonhosted.org/packages/36/a1/bd1b2b2b7f153d82301cdaddba787f4a9fc781816df6bdb295ca5f88b7cf/libcst-1.8.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1a3a5e4ee870907aa85a4076c914ae69066715a2741b821d9bf16f9579de1105", size = 2235818, upload-time = "2025-11-03T22:32:30.504Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ab/f5433988acc3b4d188c4bb154e57837df9488cc9ab551267cdeabd3bb5e7/libcst-1.8.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6609291c41f7ad0bac570bfca5af8fea1f4a27987d30a1fa8b67fe5e67e6c78d", size = 2301289, upload-time = "2025-11-03T22:32:31.812Z" },
{ url = "https://files.pythonhosted.org/packages/5d/57/89f4ba7a6f1ac274eec9903a9e9174890d2198266eee8c00bc27eb45ecf7/libcst-1.8.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25eaeae6567091443b5374b4c7d33a33636a2d58f5eda02135e96fc6c8807786", size = 2299230, upload-time = "2025-11-03T22:32:33.242Z" },
{ url = "https://files.pythonhosted.org/packages/f2/36/0aa693bc24cce163a942df49d36bf47a7ed614a0cd5598eee2623bc31913/libcst-1.8.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04030ea4d39d69a65873b1d4d877def1c3951a7ada1824242539e399b8763d30", size = 2408519, upload-time = "2025-11-03T22:32:34.678Z" },
{ url = "https://files.pythonhosted.org/packages/db/18/6dd055b5f15afa640fb3304b2ee9df8b7f72e79513814dbd0a78638f4a0e/libcst-1.8.6-cp313-cp313-win_amd64.whl", hash = "sha256:8066f1b70f21a2961e96bedf48649f27dfd5ea68be5cd1bed3742b047f14acde", size = 2119853, upload-time = "2025-11-03T22:32:36.287Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ed/5ddb2a22f0b0abdd6dcffa40621ada1feaf252a15e5b2733a0a85dfd0429/libcst-1.8.6-cp313-cp313-win_arm64.whl", hash = "sha256:c188d06b583900e662cd791a3f962a8c96d3dfc9b36ea315be39e0a4c4792ebf", size = 1999808, upload-time = "2025-11-03T22:32:38.1Z" },
{ url = "https://files.pythonhosted.org/packages/25/d3/72b2de2c40b97e1ef4a1a1db4e5e52163fc7e7740ffef3846d30bc0096b5/libcst-1.8.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c41c76e034a1094afed7057023b1d8967f968782433f7299cd170eaa01ec033e", size = 2190553, upload-time = "2025-11-03T22:32:39.819Z" },
{ url = "https://files.pythonhosted.org/packages/0d/20/983b7b210ccc3ad94a82db54230e92599c4a11b9cfc7ce3bc97c1d2df75c/libcst-1.8.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5432e785322aba3170352f6e72b32bea58d28abd141ac37cc9b0bf6b7c778f58", size = 2074717, upload-time = "2025-11-03T22:32:41.373Z" },
{ url = "https://files.pythonhosted.org/packages/13/f2/9e01678fedc772e09672ed99930de7355757035780d65d59266fcee212b8/libcst-1.8.6-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:85b7025795b796dea5284d290ff69de5089fc8e989b25d6f6f15b6800be7167f", size = 2225834, upload-time = "2025-11-03T22:32:42.716Z" },
{ url = "https://files.pythonhosted.org/packages/4a/0d/7bed847b5c8c365e9f1953da274edc87577042bee5a5af21fba63276e756/libcst-1.8.6-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:536567441182a62fb706e7aa954aca034827b19746832205953b2c725d254a93", size = 2287107, upload-time = "2025-11-03T22:32:44.549Z" },
{ url = "https://files.pythonhosted.org/packages/02/f0/7e51fa84ade26c518bfbe7e2e4758b56d86a114c72d60309ac0d350426c4/libcst-1.8.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f04d3672bde1704f383a19e8f8331521abdbc1ed13abb349325a02ac56e5012", size = 2288672, upload-time = "2025-11-03T22:32:45.867Z" },
{ url = "https://files.pythonhosted.org/packages/ad/cd/15762659a3f5799d36aab1bc2b7e732672722e249d7800e3c5f943b41250/libcst-1.8.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f04febcd70e1e67917be7de513c8d4749d2e09206798558d7fe632134426ea4", size = 2392661, upload-time = "2025-11-03T22:32:47.232Z" },
{ url = "https://files.pythonhosted.org/packages/e4/6b/b7f9246c323910fcbe021241500f82e357521495dcfe419004dbb272c7cb/libcst-1.8.6-cp313-cp313t-win_amd64.whl", hash = "sha256:1dc3b897c8b0f7323412da3f4ad12b16b909150efc42238e19cbf19b561cc330", size = 2105068, upload-time = "2025-11-03T22:32:49.145Z" },
{ url = "https://files.pythonhosted.org/packages/a6/0b/4fd40607bc4807ec2b93b054594373d7fa3d31bb983789901afcb9bcebe9/libcst-1.8.6-cp313-cp313t-win_arm64.whl", hash = "sha256:44f38139fa95e488db0f8976f9c7ca39a64d6bc09f2eceef260aa1f6da6a2e42", size = 1985181, upload-time = "2025-11-03T22:32:50.597Z" },
{ url = "https://files.pythonhosted.org/packages/3a/60/4105441989e321f7ad0fd28ffccb83eb6aac0b7cfb0366dab855dcccfbe5/libcst-1.8.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b188e626ce61de5ad1f95161b8557beb39253de4ec74fc9b1f25593324a0279c", size = 2204202, upload-time = "2025-11-03T22:32:52.311Z" },
{ url = "https://files.pythonhosted.org/packages/67/2f/51a6f285c3a183e50cfe5269d4a533c21625aac2c8de5cdf2d41f079320d/libcst-1.8.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87e74f7d7dfcba9efa91127081e22331d7c42515f0a0ac6e81d4cf2c3ed14661", size = 2083581, upload-time = "2025-11-03T22:32:54.269Z" },
{ url = "https://files.pythonhosted.org/packages/2f/64/921b1c19b638860af76cdb28bc81d430056592910b9478eea49e31a7f47a/libcst-1.8.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:3a926a4b42015ee24ddfc8ae940c97bd99483d286b315b3ce82f3bafd9f53474", size = 2236495, upload-time = "2025-11-03T22:32:55.723Z" },
{ url = "https://files.pythonhosted.org/packages/12/a8/b00592f9bede618cbb3df6ffe802fc65f1d1c03d48a10d353b108057d09c/libcst-1.8.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:3f4fbb7f569e69fd9e89d9d9caa57ca42c577c28ed05062f96a8c207594e75b8", size = 2301466, upload-time = "2025-11-03T22:32:57.337Z" },
{ url = "https://files.pythonhosted.org/packages/af/df/790d9002f31580fefd0aec2f373a0f5da99070e04c5e8b1c995d0104f303/libcst-1.8.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:08bd63a8ce674be431260649e70fca1d43f1554f1591eac657f403ff8ef82c7a", size = 2300264, upload-time = "2025-11-03T22:32:58.852Z" },
{ url = "https://files.pythonhosted.org/packages/21/de/dc3f10e65bab461be5de57850d2910a02c24c3ddb0da28f0e6e4133c3487/libcst-1.8.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e00e275d4ba95d4963431ea3e409aa407566a74ee2bf309a402f84fc744abe47", size = 2408572, upload-time = "2025-11-03T22:33:00.552Z" },
{ url = "https://files.pythonhosted.org/packages/20/3b/35645157a7590891038b077db170d6dd04335cd2e82a63bdaa78c3297dfe/libcst-1.8.6-cp314-cp314-win_amd64.whl", hash = "sha256:fea5c7fa26556eedf277d4f72779c5ede45ac3018650721edd77fd37ccd4a2d4", size = 2193917, upload-time = "2025-11-03T22:33:02.354Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a2/1034a9ba7d3e82f2c2afaad84ba5180f601aed676d92b76325797ad60951/libcst-1.8.6-cp314-cp314-win_arm64.whl", hash = "sha256:bb9b4077bdf8857b2483879cbbf70f1073bc255b057ec5aac8a70d901bb838e9", size = 2078748, upload-time = "2025-11-03T22:33:03.707Z" },
{ url = "https://files.pythonhosted.org/packages/95/a1/30bc61e8719f721a5562f77695e6154e9092d1bdf467aa35d0806dcd6cea/libcst-1.8.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:55ec021a296960c92e5a33b8d93e8ad4182b0eab657021f45262510a58223de1", size = 2188980, upload-time = "2025-11-03T22:33:05.152Z" },
{ url = "https://files.pythonhosted.org/packages/2c/14/c660204532407c5628e3b615015a902ed2d0b884b77714a6bdbe73350910/libcst-1.8.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ba9ab2b012fbd53b36cafd8f4440a6b60e7e487cd8b87428e57336b7f38409a4", size = 2074828, upload-time = "2025-11-03T22:33:06.864Z" },
{ url = "https://files.pythonhosted.org/packages/82/e2/c497c354943dff644749f177ee9737b09ed811b8fc842b05709a40fe0d1b/libcst-1.8.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c0a0cc80aebd8aa15609dd4d330611cbc05e9b4216bcaeabba7189f99ef07c28", size = 2225568, upload-time = "2025-11-03T22:33:08.354Z" },
{ url = "https://files.pythonhosted.org/packages/86/ef/45999676d07bd6d0eefa28109b4f97124db114e92f9e108de42ba46a8028/libcst-1.8.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:42a4f68121e2e9c29f49c97f6154e8527cd31021809cc4a941c7270aa64f41aa", size = 2286523, upload-time = "2025-11-03T22:33:10.206Z" },
{ url = "https://files.pythonhosted.org/packages/f4/6c/517d8bf57d9f811862f4125358caaf8cd3320a01291b3af08f7b50719db4/libcst-1.8.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a434c521fadaf9680788b50d5c21f4048fa85ed19d7d70bd40549fbaeeecab1", size = 2288044, upload-time = "2025-11-03T22:33:11.628Z" },
{ url = "https://files.pythonhosted.org/packages/83/ce/24d7d49478ffb61207f229239879845da40a374965874f5ee60f96b02ddb/libcst-1.8.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a65f844d813ab4ef351443badffa0ae358f98821561d19e18b3190f59e71996", size = 2392605, upload-time = "2025-11-03T22:33:12.962Z" },
{ url = "https://files.pythonhosted.org/packages/39/c3/829092ead738b71e96a4e96896c96f276976e5a8a58b4473ed813d7c962b/libcst-1.8.6-cp314-cp314t-win_amd64.whl", hash = "sha256:bdb14bc4d4d83a57062fed2c5da93ecb426ff65b0dc02ddf3481040f5f074a82", size = 2181581, upload-time = "2025-11-03T22:33:14.514Z" },
{ url = "https://files.pythonhosted.org/packages/98/6d/5d6a790a02eb0d9d36c4aed4f41b277497e6178900b2fa29c35353aa45ed/libcst-1.8.6-cp314-cp314t-win_arm64.whl", hash = "sha256:819c8081e2948635cab60c603e1bbdceccdfe19104a242530ad38a36222cb88f", size = 2065000, upload-time = "2025-11-03T22:33:16.257Z" },
]
[[package]]
name = "linkify-it-py"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "uc-micro-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" },
]
[[package]]
name = "logfire"
version = "4.33.0"
@@ -605,6 +690,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[package.optional-dependencies]
linkify = [
{ name = "linkify-it-py" },
]
[[package]]
name = "mdit-py-plugins"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
@@ -614,6 +716,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "mutmut"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "coverage" },
{ name = "libcst" },
{ name = "pytest" },
{ name = "setproctitle" },
{ name = "textual" },
{ name = "toml", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/0d/9ce4fc8b219504a336eb814c5a7ea8e379ad93ce05327ff3842aea93bf0b/mutmut-3.5.0.tar.gz", hash = "sha256:548186d4b0c494b7b9895db82871cb1f229b9271c9ff7cd633e348dd9afcc772", size = 36389, upload-time = "2026-02-22T18:46:41.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/23/ac475f6db39643946feb09290a2178d603d2b623034d56d3f5059cddb769/mutmut-3.5.0-py3-none-any.whl", hash = "sha256:f19f2dd2e977eb9dc17255d8cb11e24fbfc3191620fba3108cac25779c9d78c9", size = 34242, upload-time = "2026-02-22T18:46:43.113Z" },
]
[[package]]
name = "nexus-rpc"
version = "1.4.0"
@@ -778,6 +898,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -934,6 +1063,7 @@ dev = [
{ name = "dirty-equals" },
{ name = "inline-snapshot" },
{ name = "logfire", extra = ["httpx"] },
{ name = "mutmut" },
{ name = "pydantic-ai-harness", extra = ["code-mode"] },
{ name = "pytest" },
{ name = "pytest-anyio" },
@@ -960,6 +1090,7 @@ dev = [
{ name = "dirty-equals", specifier = ">=0.9.0" },
{ name = "inline-snapshot", specifier = ">=0.32.5" },
{ name = "logfire", extras = ["httpx"], specifier = ">=4.31.0" },
{ name = "mutmut", specifier = ">=3.5.0" },
{ name = "pydantic-ai-harness", extras = ["code-mode"] },
{ name = "pytest", specifier = ">=9.0.0" },
{ name = "pytest-anyio" },
@@ -1327,6 +1458,30 @@ wheels = [
{ 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 = "pyyaml-ft"
version = "8.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/eb/5a0d575de784f9a1f94e2b1288c6886f13f34185e13117ed530f32b6f8a8/pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab", size = 141057, upload-time = "2025-06-10T15:32:15.613Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/ba/a067369fe61a2e57fb38732562927d5bae088c73cb9bb5438736a9555b29/pyyaml_ft-8.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8c1306282bc958bfda31237f900eb52c9bedf9b93a11f82e1aab004c9a5657a6", size = 187027, upload-time = "2025-06-10T15:31:48.722Z" },
{ url = "https://files.pythonhosted.org/packages/ad/c5/a3d2020ce5ccfc6aede0d45bcb870298652ac0cf199f67714d250e0cdf39/pyyaml_ft-8.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30c5f1751625786c19de751e3130fc345ebcba6a86f6bddd6e1285342f4bbb69", size = 176146, upload-time = "2025-06-10T15:31:50.584Z" },
{ url = "https://files.pythonhosted.org/packages/e3/bb/23a9739291086ca0d3189eac7cd92b4d00e9fdc77d722ab610c35f9a82ba/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fa992481155ddda2e303fcc74c79c05eddcdbc907b888d3d9ce3ff3e2adcfb0", size = 746792, upload-time = "2025-06-10T15:31:52.304Z" },
{ url = "https://files.pythonhosted.org/packages/5f/c2/e8825f4ff725b7e560d62a3609e31d735318068e1079539ebfde397ea03e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cec6c92b4207004b62dfad1f0be321c9f04725e0f271c16247d8b39c3bf3ea42", size = 786772, upload-time = "2025-06-10T15:31:54.712Z" },
{ url = "https://files.pythonhosted.org/packages/35/be/58a4dcae8854f2fdca9b28d9495298fd5571a50d8430b1c3033ec95d2d0e/pyyaml_ft-8.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06237267dbcab70d4c0e9436d8f719f04a51123f0ca2694c00dd4b68c338e40b", size = 778723, upload-time = "2025-06-10T15:31:56.093Z" },
{ url = "https://files.pythonhosted.org/packages/86/ed/fed0da92b5d5d7340a082e3802d84c6dc9d5fa142954404c41a544c1cb92/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a7f332bc565817644cdb38ffe4739e44c3e18c55793f75dddb87630f03fc254", size = 758478, upload-time = "2025-06-10T15:31:58.314Z" },
{ url = "https://files.pythonhosted.org/packages/f0/69/ac02afe286275980ecb2dcdc0156617389b7e0c0a3fcdedf155c67be2b80/pyyaml_ft-8.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7d10175a746be65f6feb86224df5d6bc5c049ebf52b89a88cf1cd78af5a367a8", size = 799159, upload-time = "2025-06-10T15:31:59.675Z" },
{ url = "https://files.pythonhosted.org/packages/4e/ac/c492a9da2e39abdff4c3094ec54acac9747743f36428281fb186a03fab76/pyyaml_ft-8.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:58e1015098cf8d8aec82f360789c16283b88ca670fe4275ef6c48c5e30b22a96", size = 158779, upload-time = "2025-06-10T15:32:01.029Z" },
{ url = "https://files.pythonhosted.org/packages/5d/9b/41998df3298960d7c67653669f37710fa2d568a5fc933ea24a6df60acaf6/pyyaml_ft-8.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5f3e2ceb790d50602b2fd4ec37abbd760a8c778e46354df647e7c5a4ebb", size = 191331, upload-time = "2025-06-10T15:32:02.602Z" },
{ url = "https://files.pythonhosted.org/packages/0f/16/2710c252ee04cbd74d9562ebba709e5a284faeb8ada88fcda548c9191b47/pyyaml_ft-8.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d445bf6ea16bb93c37b42fdacfb2f94c8e92a79ba9e12768c96ecde867046d1", size = 182879, upload-time = "2025-06-10T15:32:04.466Z" },
{ url = "https://files.pythonhosted.org/packages/9a/40/ae8163519d937fa7bfa457b6f78439cc6831a7c2b170e4f612f7eda71815/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c56bb46b4fda34cbb92a9446a841da3982cdde6ea13de3fbd80db7eeeab8b49", size = 811277, upload-time = "2025-06-10T15:32:06.214Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/28d82dbff7f87b96f0eeac79b7d972a96b4980c1e445eb6a857ba91eda00/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dab0abb46eb1780da486f022dce034b952c8ae40753627b27a626d803926483b", size = 831650, upload-time = "2025-06-10T15:32:08.076Z" },
{ url = "https://files.pythonhosted.org/packages/e8/df/161c4566facac7d75a9e182295c223060373d4116dead9cc53a265de60b9/pyyaml_ft-8.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd48d639cab5ca50ad957b6dd632c7dd3ac02a1abe0e8196a3c24a52f5db3f7a", size = 815755, upload-time = "2025-06-10T15:32:09.435Z" },
{ url = "https://files.pythonhosted.org/packages/05/10/f42c48fa5153204f42eaa945e8d1fd7c10d6296841dcb2447bf7da1be5c4/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:052561b89d5b2a8e1289f326d060e794c21fa068aa11255fe71d65baf18a632e", size = 810403, upload-time = "2025-06-10T15:32:11.051Z" },
{ url = "https://files.pythonhosted.org/packages/d5/d2/e369064aa51009eb9245399fd8ad2c562bd0bcd392a00be44b2a824ded7c/pyyaml_ft-8.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3bb4b927929b0cb162fb1605392a321e3333e48ce616cdcfa04a839271373255", size = 835581, upload-time = "2025-06-10T15:32:12.897Z" },
{ url = "https://files.pythonhosted.org/packages/c0/28/26534bed77109632a956977f60d8519049f545abc39215d086e33a61f1f2/pyyaml_ft-8.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:de04cfe9439565e32f178106c51dd6ca61afaa2907d143835d501d84703d3793", size = 171579, upload-time = "2025-06-10T15:32:14.34Z" },
]
[[package]]
name = "requests"
version = "2.34.0"
@@ -1380,6 +1535,90 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
]
[[package]]
name = "setproctitle"
version = "1.3.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8d/48/49393a96a2eef1ab418b17475fb92b8fcfad83d099e678751b05472e69de/setproctitle-1.3.7.tar.gz", hash = "sha256:bc2bc917691c1537d5b9bca1468437176809c7e11e5694ca79a9ca12345dcb9e", size = 27002, upload-time = "2025-09-05T12:51:25.278Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/48/fb401ec8c4953d519d05c87feca816ad668b8258448ff60579ac7a1c1386/setproctitle-1.3.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cf555b6299f10a6eb44e4f96d2f5a3884c70ce25dc5c8796aaa2f7b40e72cb1b", size = 18079, upload-time = "2025-09-05T12:49:07.732Z" },
{ url = "https://files.pythonhosted.org/packages/cc/a3/c2b0333c2716fb3b4c9a973dd113366ac51b4f8d56b500f4f8f704b4817a/setproctitle-1.3.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:690b4776f9c15aaf1023bb07d7c5b797681a17af98a4a69e76a1d504e41108b7", size = 13099, upload-time = "2025-09-05T12:49:09.222Z" },
{ url = "https://files.pythonhosted.org/packages/0e/f8/17bda581c517678260e6541b600eeb67745f53596dc077174141ba2f6702/setproctitle-1.3.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:00afa6fc507967d8c9d592a887cdc6c1f5742ceac6a4354d111ca0214847732c", size = 31793, upload-time = "2025-09-05T12:49:10.297Z" },
{ url = "https://files.pythonhosted.org/packages/27/d1/76a33ae80d4e788ecab9eb9b53db03e81cfc95367ec7e3fbf4989962fedd/setproctitle-1.3.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e02667f6b9fc1238ba753c0f4b0a37ae184ce8f3bbbc38e115d99646b3f4cd3", size = 32779, upload-time = "2025-09-05T12:49:12.157Z" },
{ url = "https://files.pythonhosted.org/packages/59/27/1a07c38121967061564f5e0884414a5ab11a783260450172d4fc68c15621/setproctitle-1.3.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:83fcd271567d133eb9532d3b067c8a75be175b2b3b271e2812921a05303a693f", size = 34578, upload-time = "2025-09-05T12:49:13.393Z" },
{ url = "https://files.pythonhosted.org/packages/d8/d4/725e6353935962d8bb12cbf7e7abba1d0d738c7f6935f90239d8e1ccf913/setproctitle-1.3.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13fe37951dda1a45c35d77d06e3da5d90e4f875c4918a7312b3b4556cfa7ff64", size = 32030, upload-time = "2025-09-05T12:49:15.362Z" },
{ url = "https://files.pythonhosted.org/packages/67/24/e4677ae8e1cb0d549ab558b12db10c175a889be0974c589c428fece5433e/setproctitle-1.3.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a05509cfb2059e5d2ddff701d38e474169e9ce2a298cf1b6fd5f3a213a553fe5", size = 33363, upload-time = "2025-09-05T12:49:16.829Z" },
{ url = "https://files.pythonhosted.org/packages/55/d4/69ce66e4373a48fdbb37489f3ded476bb393e27f514968c3a69a67343ae0/setproctitle-1.3.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6da835e76ae18574859224a75db6e15c4c2aaa66d300a57efeaa4c97ca4c7381", size = 31508, upload-time = "2025-09-05T12:49:18.032Z" },
{ url = "https://files.pythonhosted.org/packages/4b/5a/42c1ed0e9665d068146a68326529b5686a1881c8b9197c2664db4baf6aeb/setproctitle-1.3.7-cp310-cp310-win32.whl", hash = "sha256:9e803d1b1e20240a93bac0bc1025363f7f80cb7eab67dfe21efc0686cc59ad7c", size = 12558, upload-time = "2025-09-05T12:49:19.742Z" },
{ url = "https://files.pythonhosted.org/packages/dc/fe/dd206cc19a25561921456f6cb12b405635319299b6f366e0bebe872abc18/setproctitle-1.3.7-cp310-cp310-win_amd64.whl", hash = "sha256:a97200acc6b64ec4cada52c2ecaf1fba1ef9429ce9c542f8a7db5bcaa9dcbd95", size = 13245, upload-time = "2025-09-05T12:49:21.023Z" },
{ url = "https://files.pythonhosted.org/packages/04/cd/1b7ba5cad635510720ce19d7122154df96a2387d2a74217be552887c93e5/setproctitle-1.3.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a600eeb4145fb0ee6c287cb82a2884bd4ec5bbb076921e287039dcc7b7cc6dd0", size = 18085, upload-time = "2025-09-05T12:49:22.183Z" },
{ url = "https://files.pythonhosted.org/packages/8f/1a/b2da0a620490aae355f9d72072ac13e901a9fec809a6a24fc6493a8f3c35/setproctitle-1.3.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:97a090fed480471bb175689859532709e28c085087e344bca45cf318034f70c4", size = 13097, upload-time = "2025-09-05T12:49:23.322Z" },
{ url = "https://files.pythonhosted.org/packages/18/2e/bd03ff02432a181c1787f6fc2a678f53b7dacdd5ded69c318fe1619556e8/setproctitle-1.3.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1607b963e7b53e24ec8a2cb4e0ab3ae591d7c6bf0a160feef0551da63452b37f", size = 32191, upload-time = "2025-09-05T12:49:24.567Z" },
{ url = "https://files.pythonhosted.org/packages/28/78/1e62fc0937a8549f2220445ed2175daacee9b6764c7963b16148119b016d/setproctitle-1.3.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a20fb1a3974e2dab857870cf874b325b8705605cb7e7e8bcbb915bca896f52a9", size = 33203, upload-time = "2025-09-05T12:49:25.871Z" },
{ url = "https://files.pythonhosted.org/packages/a0/3c/65edc65db3fa3df400cf13b05e9d41a3c77517b4839ce873aa6b4043184f/setproctitle-1.3.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8d961bba676e07d77665204f36cffaa260f526e7b32d07ab3df6a2c1dfb44ba", size = 34963, upload-time = "2025-09-05T12:49:27.044Z" },
{ url = "https://files.pythonhosted.org/packages/a1/32/89157e3de997973e306e44152522385f428e16f92f3cf113461489e1e2ee/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:db0fd964fbd3a9f8999b502f65bd2e20883fdb5b1fae3a424e66db9a793ed307", size = 32398, upload-time = "2025-09-05T12:49:28.909Z" },
{ url = "https://files.pythonhosted.org/packages/4a/18/77a765a339ddf046844cb4513353d8e9dcd8183da9cdba6e078713e6b0b2/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:db116850fcf7cca19492030f8d3b4b6e231278e8fe097a043957d22ce1bdf3ee", size = 33657, upload-time = "2025-09-05T12:49:30.323Z" },
{ url = "https://files.pythonhosted.org/packages/6b/63/f0b6205c64d74d2a24a58644a38ec77bdbaa6afc13747e75973bf8904932/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:316664d8b24a5c91ee244460bdaf7a74a707adaa9e14fbe0dc0a53168bb9aba1", size = 31836, upload-time = "2025-09-05T12:49:32.309Z" },
{ url = "https://files.pythonhosted.org/packages/ba/51/e1277f9ba302f1a250bbd3eedbbee747a244b3cc682eb58fb9733968f6d8/setproctitle-1.3.7-cp311-cp311-win32.whl", hash = "sha256:b74774ca471c86c09b9d5037c8451fff06bb82cd320d26ae5a01c758088c0d5d", size = 12556, upload-time = "2025-09-05T12:49:33.529Z" },
{ url = "https://files.pythonhosted.org/packages/b6/7b/822a23f17e9003dfdee92cd72758441ca2a3680388da813a371b716fb07f/setproctitle-1.3.7-cp311-cp311-win_amd64.whl", hash = "sha256:acb9097213a8dd3410ed9f0dc147840e45ca9797785272928d4be3f0e69e3be4", size = 13243, upload-time = "2025-09-05T12:49:34.553Z" },
{ url = "https://files.pythonhosted.org/packages/fb/f0/2dc88e842077719d7384d86cc47403e5102810492b33680e7dadcee64cd8/setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2dc99aec591ab6126e636b11035a70991bc1ab7a261da428491a40b84376654e", size = 18049, upload-time = "2025-09-05T12:49:36.241Z" },
{ url = "https://files.pythonhosted.org/packages/f0/b4/50940504466689cda65680c9e9a1e518e5750c10490639fa687489ac7013/setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdd8aa571b7aa39840fdbea620e308a19691ff595c3a10231e9ee830339dd798", size = 13079, upload-time = "2025-09-05T12:49:38.088Z" },
{ url = "https://files.pythonhosted.org/packages/d0/99/71630546b9395b095f4082be41165d1078204d1696c2d9baade3de3202d0/setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2906b6c7959cdb75f46159bf0acd8cc9906cf1361c9e1ded0d065fe8f9039629", size = 32932, upload-time = "2025-09-05T12:49:39.271Z" },
{ url = "https://files.pythonhosted.org/packages/50/22/cee06af4ffcfb0e8aba047bd44f5262e644199ae7527ae2c1f672b86495c/setproctitle-1.3.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6915964a6dda07920a1159321dcd6d94fc7fc526f815ca08a8063aeca3c204f1", size = 33736, upload-time = "2025-09-05T12:49:40.565Z" },
{ url = "https://files.pythonhosted.org/packages/5c/00/a5949a8bb06ef5e7df214fc393bb2fb6aedf0479b17214e57750dfdd0f24/setproctitle-1.3.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff72899861c765bd4021d1ff1c68d60edc129711a2fdba77f9cb69ef726a8b6", size = 35605, upload-time = "2025-09-05T12:49:42.362Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3a/50caca532a9343828e3bf5778c7a84d6c737a249b1796d50dd680290594d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7cb05bd446687ff816a3aaaf831047fc4c364feff7ada94a66024f1367b448c", size = 33143, upload-time = "2025-09-05T12:49:43.515Z" },
{ url = "https://files.pythonhosted.org/packages/ca/14/b843a251296ce55e2e17c017d6b9f11ce0d3d070e9265de4ecad948b913d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3a57b9a00de8cae7e2a1f7b9f0c2ac7b69372159e16a7708aa2f38f9e5cc987a", size = 34434, upload-time = "2025-09-05T12:49:45.31Z" },
{ url = "https://files.pythonhosted.org/packages/c8/b7/06145c238c0a6d2c4bc881f8be230bb9f36d2bf51aff7bddcb796d5eed67/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d8828b356114f6b308b04afe398ed93803d7fca4a955dd3abe84430e28d33739", size = 32795, upload-time = "2025-09-05T12:49:46.419Z" },
{ url = "https://files.pythonhosted.org/packages/ef/dc/ef76a81fac9bf27b84ed23df19c1f67391a753eed6e3c2254ebcb5133f56/setproctitle-1.3.7-cp312-cp312-win32.whl", hash = "sha256:b0304f905efc845829ac2bc791ddebb976db2885f6171f4a3de678d7ee3f7c9f", size = 12552, upload-time = "2025-09-05T12:49:47.635Z" },
{ url = "https://files.pythonhosted.org/packages/e2/5b/a9fe517912cd6e28cf43a212b80cb679ff179a91b623138a99796d7d18a0/setproctitle-1.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:9888ceb4faea3116cf02a920ff00bfbc8cc899743e4b4ac914b03625bdc3c300", size = 13247, upload-time = "2025-09-05T12:49:49.16Z" },
{ url = "https://files.pythonhosted.org/packages/5d/2f/fcedcade3b307a391b6e17c774c6261a7166aed641aee00ed2aad96c63ce/setproctitle-1.3.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3736b2a423146b5e62230502e47e08e68282ff3b69bcfe08a322bee73407922", size = 18047, upload-time = "2025-09-05T12:49:50.271Z" },
{ url = "https://files.pythonhosted.org/packages/23/ae/afc141ca9631350d0a80b8f287aac79a76f26b6af28fd8bf92dae70dc2c5/setproctitle-1.3.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3384e682b158d569e85a51cfbde2afd1ab57ecf93ea6651fe198d0ba451196ee", size = 13073, upload-time = "2025-09-05T12:49:51.46Z" },
{ url = "https://files.pythonhosted.org/packages/87/ed/0a4f00315bc02510395b95eec3d4aa77c07192ee79f0baae77ea7b9603d8/setproctitle-1.3.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0564a936ea687cd24dffcea35903e2a20962aa6ac20e61dd3a207652401492dd", size = 33284, upload-time = "2025-09-05T12:49:52.741Z" },
{ url = "https://files.pythonhosted.org/packages/fc/e4/adf3c4c0a2173cb7920dc9df710bcc67e9bcdbf377e243b7a962dc31a51a/setproctitle-1.3.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5d1cb3f81531f0eb40e13246b679a1bdb58762b170303463cb06ecc296f26d0", size = 34104, upload-time = "2025-09-05T12:49:54.416Z" },
{ url = "https://files.pythonhosted.org/packages/52/4f/6daf66394152756664257180439d37047aa9a1cfaa5e4f5ed35e93d1dc06/setproctitle-1.3.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a7d159e7345f343b44330cbba9194169b8590cb13dae940da47aa36a72aa9929", size = 35982, upload-time = "2025-09-05T12:49:56.295Z" },
{ url = "https://files.pythonhosted.org/packages/1b/62/f2c0595403cf915db031f346b0e3b2c0096050e90e0be658a64f44f4278a/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0b5074649797fd07c72ca1f6bff0406f4a42e1194faac03ecaab765ce605866f", size = 33150, upload-time = "2025-09-05T12:49:58.025Z" },
{ url = "https://files.pythonhosted.org/packages/a0/29/10dd41cde849fb2f9b626c846b7ea30c99c81a18a5037a45cc4ba33c19a7/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61e96febced3f61b766115381d97a21a6265a0f29188a791f6df7ed777aef698", size = 34463, upload-time = "2025-09-05T12:49:59.424Z" },
{ url = "https://files.pythonhosted.org/packages/71/3c/cedd8eccfaf15fb73a2c20525b68c9477518917c9437737fa0fda91e378f/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:047138279f9463f06b858e579cc79580fbf7a04554d24e6bddf8fe5dddbe3d4c", size = 32848, upload-time = "2025-09-05T12:50:01.107Z" },
{ url = "https://files.pythonhosted.org/packages/d1/3e/0a0e27d1c9926fecccfd1f91796c244416c70bf6bca448d988638faea81d/setproctitle-1.3.7-cp313-cp313-win32.whl", hash = "sha256:7f47accafac7fe6535ba8ba9efd59df9d84a6214565108d0ebb1199119c9cbbd", size = 12544, upload-time = "2025-09-05T12:50:15.81Z" },
{ url = "https://files.pythonhosted.org/packages/36/1b/6bf4cb7acbbd5c846ede1c3f4d6b4ee52744d402e43546826da065ff2ab7/setproctitle-1.3.7-cp313-cp313-win_amd64.whl", hash = "sha256:fe5ca35aeec6dc50cabab9bf2d12fbc9067eede7ff4fe92b8f5b99d92e21263f", size = 13235, upload-time = "2025-09-05T12:50:16.89Z" },
{ url = "https://files.pythonhosted.org/packages/e6/a4/d588d3497d4714750e3eaf269e9e8985449203d82b16b933c39bd3fc52a1/setproctitle-1.3.7-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:10e92915c4b3086b1586933a36faf4f92f903c5554f3c34102d18c7d3f5378e9", size = 18058, upload-time = "2025-09-05T12:50:02.501Z" },
{ url = "https://files.pythonhosted.org/packages/05/77/7637f7682322a7244e07c373881c7e982567e2cb1dd2f31bd31481e45500/setproctitle-1.3.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:de879e9c2eab637f34b1a14c4da1e030c12658cdc69ee1b3e5be81b380163ce5", size = 13072, upload-time = "2025-09-05T12:50:03.601Z" },
{ url = "https://files.pythonhosted.org/packages/52/09/f366eca0973cfbac1470068d1313fa3fe3de4a594683385204ec7f1c4101/setproctitle-1.3.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c18246d88e227a5b16248687514f95642505000442165f4b7db354d39d0e4c29", size = 34490, upload-time = "2025-09-05T12:50:04.948Z" },
{ url = "https://files.pythonhosted.org/packages/71/36/611fc2ed149fdea17c3677e1d0df30d8186eef9562acc248682b91312706/setproctitle-1.3.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7081f193dab22df2c36f9fc6d113f3793f83c27891af8fe30c64d89d9a37e152", size = 35267, upload-time = "2025-09-05T12:50:06.015Z" },
{ url = "https://files.pythonhosted.org/packages/88/a4/64e77d0671446bd5a5554387b69e1efd915274686844bea733714c828813/setproctitle-1.3.7-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cc9b901ce129350637426a89cfd650066a4adc6899e47822e2478a74023ff7c", size = 37376, upload-time = "2025-09-05T12:50:07.484Z" },
{ url = "https://files.pythonhosted.org/packages/89/bc/ad9c664fe524fb4a4b2d3663661a5c63453ce851736171e454fa2cdec35c/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:80e177eff2d1ec172188d0d7fd9694f8e43d3aab76a6f5f929bee7bf7894e98b", size = 33963, upload-time = "2025-09-05T12:50:09.056Z" },
{ url = "https://files.pythonhosted.org/packages/ab/01/a36de7caf2d90c4c28678da1466b47495cbbad43badb4e982d8db8167ed4/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:23e520776c445478a67ee71b2a3c1ffdafbe1f9f677239e03d7e2cc635954e18", size = 35550, upload-time = "2025-09-05T12:50:10.791Z" },
{ url = "https://files.pythonhosted.org/packages/dd/68/17e8aea0ed5ebc17fbf03ed2562bfab277c280e3625850c38d92a7b5fcd9/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5fa1953126a3b9bd47049d58c51b9dac72e78ed120459bd3aceb1bacee72357c", size = 33727, upload-time = "2025-09-05T12:50:12.032Z" },
{ url = "https://files.pythonhosted.org/packages/b2/33/90a3bf43fe3a2242b4618aa799c672270250b5780667898f30663fd94993/setproctitle-1.3.7-cp313-cp313t-win32.whl", hash = "sha256:4a5e212bf438a4dbeece763f4962ad472c6008ff6702e230b4f16a037e2f6f29", size = 12549, upload-time = "2025-09-05T12:50:13.074Z" },
{ url = "https://files.pythonhosted.org/packages/0b/0e/50d1f07f3032e1f23d814ad6462bc0a138f369967c72494286b8a5228e40/setproctitle-1.3.7-cp313-cp313t-win_amd64.whl", hash = "sha256:cf2727b733e90b4f874bac53e3092aa0413fe1ea6d4f153f01207e6ce65034d9", size = 13243, upload-time = "2025-09-05T12:50:14.146Z" },
{ url = "https://files.pythonhosted.org/packages/89/c7/43ac3a98414f91d1b86a276bc2f799ad0b4b010e08497a95750d5bc42803/setproctitle-1.3.7-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:80c36c6a87ff72eabf621d0c79b66f3bdd0ecc79e873c1e9f0651ee8bf215c63", size = 18052, upload-time = "2025-09-05T12:50:17.928Z" },
{ url = "https://files.pythonhosted.org/packages/cd/2c/dc258600a25e1a1f04948073826bebc55e18dbd99dc65a576277a82146fa/setproctitle-1.3.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b53602371a52b91c80aaf578b5ada29d311d12b8a69c0c17fbc35b76a1fd4f2e", size = 13071, upload-time = "2025-09-05T12:50:19.061Z" },
{ url = "https://files.pythonhosted.org/packages/ab/26/8e3bb082992f19823d831f3d62a89409deb6092e72fc6940962983ffc94f/setproctitle-1.3.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fcb966a6c57cf07cc9448321a08f3be6b11b7635be502669bc1d8745115d7e7f", size = 33180, upload-time = "2025-09-05T12:50:20.395Z" },
{ url = "https://files.pythonhosted.org/packages/f1/af/ae692a20276d1159dd0cf77b0bcf92cbb954b965655eb4a69672099bb214/setproctitle-1.3.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46178672599b940368d769474fe13ecef1b587d58bb438ea72b9987f74c56ea5", size = 34043, upload-time = "2025-09-05T12:50:22.454Z" },
{ url = "https://files.pythonhosted.org/packages/34/b2/6a092076324dd4dac1a6d38482bedebbff5cf34ef29f58585ec76e47bc9d/setproctitle-1.3.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f9e9e3ff135cbcc3edd2f4cf29b139f4aca040d931573102742db70ff428c17", size = 35892, upload-time = "2025-09-05T12:50:23.937Z" },
{ url = "https://files.pythonhosted.org/packages/1c/1a/8836b9f28cee32859ac36c3df85aa03e1ff4598d23ea17ca2e96b5845a8f/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14c7eba8d90c93b0e79c01f0bd92a37b61983c27d6d7d5a3b5defd599113d60e", size = 32898, upload-time = "2025-09-05T12:50:25.617Z" },
{ url = "https://files.pythonhosted.org/packages/ef/22/8fabdc24baf42defb599714799d8445fe3ae987ec425a26ec8e80ea38f8e/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e64e98077fb30b6cf98073d6c439cd91deb8ebbf8fc62d9dbf52bd38b0c6ac0", size = 34308, upload-time = "2025-09-05T12:50:26.827Z" },
{ url = "https://files.pythonhosted.org/packages/15/1b/b9bee9de6c8cdcb3b3a6cb0b3e773afdb86bbbc1665a3bfa424a4294fda2/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b91387cc0f02a00ac95dcd93f066242d3cca10ff9e6153de7ee07069c6f0f7c8", size = 32536, upload-time = "2025-09-05T12:50:28.5Z" },
{ url = "https://files.pythonhosted.org/packages/37/0c/75e5f2685a5e3eda0b39a8b158d6d8895d6daf3ba86dec9e3ba021510272/setproctitle-1.3.7-cp314-cp314-win32.whl", hash = "sha256:52b054a61c99d1b72fba58b7f5486e04b20fefc6961cd76722b424c187f362ed", size = 12731, upload-time = "2025-09-05T12:50:43.955Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ae/acddbce90d1361e1786e1fb421bc25baeb0c22ef244ee5d0176511769ec8/setproctitle-1.3.7-cp314-cp314-win_amd64.whl", hash = "sha256:5818e4080ac04da1851b3ec71e8a0f64e3748bf9849045180566d8b736702416", size = 13464, upload-time = "2025-09-05T12:50:45.057Z" },
{ url = "https://files.pythonhosted.org/packages/01/6d/20886c8ff2e6d85e3cabadab6aab9bb90acaf1a5cfcb04d633f8d61b2626/setproctitle-1.3.7-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6fc87caf9e323ac426910306c3e5d3205cd9f8dcac06d233fcafe9337f0928a3", size = 18062, upload-time = "2025-09-05T12:50:29.78Z" },
{ url = "https://files.pythonhosted.org/packages/9a/60/26dfc5f198715f1343b95c2f7a1c16ae9ffa45bd89ffd45a60ed258d24ea/setproctitle-1.3.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6134c63853d87a4897ba7d5cc0e16abfa687f6c66fc09f262bb70d67718f2309", size = 13075, upload-time = "2025-09-05T12:50:31.604Z" },
{ url = "https://files.pythonhosted.org/packages/21/9c/980b01f50d51345dd513047e3ba9e96468134b9181319093e61db1c47188/setproctitle-1.3.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1403d2abfd32790b6369916e2313dffbe87d6b11dca5bbd898981bcde48e7a2b", size = 34744, upload-time = "2025-09-05T12:50:32.777Z" },
{ url = "https://files.pythonhosted.org/packages/86/b4/82cd0c86e6d1c4538e1a7eb908c7517721513b801dff4ba3f98ef816a240/setproctitle-1.3.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7c5bfe4228ea22373e3025965d1a4116097e555ee3436044f5c954a5e63ac45", size = 35589, upload-time = "2025-09-05T12:50:34.13Z" },
{ url = "https://files.pythonhosted.org/packages/8a/4f/9f6b2a7417fd45673037554021c888b31247f7594ff4bd2239918c5cd6d0/setproctitle-1.3.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:585edf25e54e21a94ccb0fe81ad32b9196b69ebc4fc25f81da81fb8a50cca9e4", size = 37698, upload-time = "2025-09-05T12:50:35.524Z" },
{ url = "https://files.pythonhosted.org/packages/20/92/927b7d4744aac214d149c892cb5fa6dc6f49cfa040cb2b0a844acd63dcaf/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:96c38cdeef9036eb2724c2210e8d0b93224e709af68c435d46a4733a3675fee1", size = 34201, upload-time = "2025-09-05T12:50:36.697Z" },
{ url = "https://files.pythonhosted.org/packages/0a/0c/fd4901db5ba4b9d9013e62f61d9c18d52290497f956745cd3e91b0d80f90/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:45e3ef48350abb49cf937d0a8ba15e42cee1e5ae13ca41a77c66d1abc27a5070", size = 35801, upload-time = "2025-09-05T12:50:38.314Z" },
{ url = "https://files.pythonhosted.org/packages/e7/e3/54b496ac724e60e61cc3447f02690105901ca6d90da0377dffe49ff99fc7/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1fae595d032b30dab4d659bece20debd202229fce12b55abab978b7f30783d73", size = 33958, upload-time = "2025-09-05T12:50:39.841Z" },
{ url = "https://files.pythonhosted.org/packages/ea/a8/c84bb045ebf8c6fdc7f7532319e86f8380d14bbd3084e6348df56bdfe6fd/setproctitle-1.3.7-cp314-cp314t-win32.whl", hash = "sha256:02432f26f5d1329ab22279ff863c83589894977063f59e6c4b4845804a08f8c2", size = 12745, upload-time = "2025-09-05T12:50:41.377Z" },
{ url = "https://files.pythonhosted.org/packages/08/b6/3a5a4f9952972791a9114ac01dfc123f0df79903577a3e0a7a404a695586/setproctitle-1.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:cbc388e3d86da1f766d8fc2e12682e446064c01cea9f88a88647cfe7c011de6a", size = 13469, upload-time = "2025-09-05T12:50:42.67Z" },
{ url = "https://files.pythonhosted.org/packages/34/8a/aff5506ce89bc3168cb492b18ba45573158d528184e8a9759a05a09088a9/setproctitle-1.3.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:eb440c5644a448e6203935ed60466ec8d0df7278cd22dc6cf782d07911bcbea6", size = 12654, upload-time = "2025-09-05T12:51:17.141Z" },
{ url = "https://files.pythonhosted.org/packages/41/89/5b6f2faedd6ced3d3c085a5efbd91380fb1f61f4c12bc42acad37932f4e9/setproctitle-1.3.7-pp310-pypy310_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:502b902a0e4c69031b87870ff4986c290ebbb12d6038a70639f09c331b18efb2", size = 14284, upload-time = "2025-09-05T12:51:18.393Z" },
{ url = "https://files.pythonhosted.org/packages/0a/c0/4312fed3ca393a29589603fd48f17937b4ed0638b923bac75a728382e730/setproctitle-1.3.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f6f268caeabb37ccd824d749e7ce0ec6337c4ed954adba33ec0d90cc46b0ab78", size = 13282, upload-time = "2025-09-05T12:51:19.703Z" },
{ url = "https://files.pythonhosted.org/packages/c3/5b/5e1c117ac84e3cefcf8d7a7f6b2461795a87e20869da065a5c087149060b/setproctitle-1.3.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b1cac6a4b0252b8811d60b6d8d0f157c0fdfed379ac89c25a914e6346cf355a1", size = 12587, upload-time = "2025-09-05T12:51:21.195Z" },
{ url = "https://files.pythonhosted.org/packages/73/02/b9eadc226195dcfa90eed37afe56b5dd6fa2f0e5220ab8b7867b8862b926/setproctitle-1.3.7-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f1704c9e041f2b1dc38f5be4552e141e1432fba3dd52c72eeffd5bc2db04dc65", size = 14286, upload-time = "2025-09-05T12:51:22.61Z" },
{ url = "https://files.pythonhosted.org/packages/28/26/1be1d2a53c2a91ec48fa2ff4a409b395f836798adf194d99de9c059419ea/setproctitle-1.3.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b08b61976ffa548bd5349ce54404bf6b2d51bd74d4f1b241ed1b0f25bce09c3a", size = 13282, upload-time = "2025-09-05T12:51:24.094Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
@@ -1496,6 +1735,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/e6/a147fad980d0f92b7b070d4fe439310b91375592c26d8cb6dc5d1a1c0ae4/temporalio-1.27.1-cp310-abi3-win_amd64.whl", hash = "sha256:a3afaed09643cfb24ac04837144ff37a02a0eac7eeeb1876065066806aeda512", size = 14979491, upload-time = "2026-05-13T16:20:50.622Z" },
]
[[package]]
name = "textual"
version = "8.2.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] },
{ name = "mdit-py-plugins" },
{ name = "platformdirs" },
{ name = "pygments" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/7a/c519db0aba5024f86e71e9631810bfdd6866ed2c8695bd7fa34b90e7ef59/textual-8.2.7.tar.gz", hash = "sha256:658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105", size = 1859249, upload-time = "2026-05-19T10:52:49.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/f5/c1e18bc0707300a0e90204343abbf7d7acd6fb7ebe03a6d4893b99a234b8/textual-8.2.7-py3-none-any.whl", hash = "sha256:4caaa13a90bc4cf9c6c862c067ccd34fe84e9c161710a2a907a8026313b6bd73", size = 731129, upload-time = "2026-05-19T10:52:51.773Z" },
]
[[package]]
name = "toml"
version = "0.10.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" },
]
[[package]]
name = "tomli"
version = "2.4.1"
@@ -1634,6 +1899,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
]
[[package]]
name = "uc-micro-py"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" },
]
[[package]]
name = "urllib3"
version = "2.7.0"