mirror of
https://github.com/pydantic/pydantic-ai-harness.git
synced 2026-07-21 02:45:34 +00:00
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:
@@ -25,3 +25,4 @@ wheels/
|
||||
# Hypothesis
|
||||
.hypothesis/
|
||||
.vscode/
|
||||
mutants/
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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']
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
|
||||
@@ -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'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user