commit 3fc1fa6843dfe44231a12016cfc1b463f2425b1a
parent deb7c4949aec3fdd72511b6c31ec5ec65ebf4241
Author: andrewlaack-collab <andrew.laack@imbue.com>
Date: Thu, 29 Jan 2026 04:20:42 +0000
Setup Dependencies (#1)
* Imbue_verify works with minimal imports from imbue_core and tools. Still should import relevant tests from said dependencies, and do some other code pruning with vulture.
* Removing
* Bug fix
* Black reformat
* Fixing formatting
* Improve pyproject.toml
* Spyware begone
Diffstat:
157 files changed, 26868 insertions(+), 423 deletions(-)
diff --git a/imbue_core/README.md b/imbue_core/README.md
@@ -0,0 +1,3 @@
+# Imbue Core
+
+Utilities for `imbue-desktop`. This package is not meant to be installed on its own.
diff --git a/imbue_core/imbue_core/__init__.py b/imbue_core/imbue_core/__init__.py
diff --git a/imbue_core/imbue_core/agents/__init__.py b/imbue_core/imbue_core/agents/__init__.py
diff --git a/imbue_core/imbue_core/agents/agent_api/__init__.py b/imbue_core/imbue_core/agents/agent_api/__init__.py
diff --git a/imbue_core/imbue_core/agents/agent_api/api.py b/imbue_core/imbue_core/agents/agent_api/api.py
@@ -0,0 +1,59 @@
+from __future__ import annotations
+
+from contextlib import contextmanager
+from functools import singledispatch
+from pathlib import Path
+from typing import Any
+from typing import ContextManager
+from typing import Iterator
+
+from imbue_core.agents.agent_api.claude.client import ClaudeCodeClient
+from imbue_core.agents.agent_api.claude.data_types import ClaudeCodeOptions
+from imbue_core.agents.agent_api.client import AgentClient
+from imbue_core.agents.agent_api.client import AgentOptionsT
+from imbue_core.agents.agent_api.client import CachedAgentClient
+from imbue_core.agents.agent_api.codex.client import CodexClient
+from imbue_core.agents.agent_api.codex.data_types import CodexOptions
+from imbue_core.agents.agent_api.data_types import AgentOptions
+
+
+@singledispatch
+def _build_client_from_options(
+ options: AgentOptions,
+) -> ContextManager[AgentClient[Any]]:
+ """Return a context manager that builds an AgentClient for the given options."""
+ raise ValueError(f"Unsupported agent options type: {type(options).__name__}")
+
+
+@_build_client_from_options.register
+def _(options: ClaudeCodeOptions) -> ContextManager[AgentClient[ClaudeCodeOptions]]:
+ return ClaudeCodeClient.build(options)
+
+
+@_build_client_from_options.register
+def _(options: CodexOptions) -> ContextManager[AgentClient[CodexOptions]]:
+ return CodexClient.build(options)
+
+
+@contextmanager
+def get_agent_client(
+ *,
+ options: AgentOptionsT,
+ cache_path: Path | None = None,
+) -> Iterator[AgentClient[AgentOptionsT]]:
+ """Build and manage the lifecycle of an AgentClient based on the provided options.
+
+ Args:
+ options: AgentOptions instance describing which agent to run.
+ cache_path: Optional path to use for caching agent interactions.
+
+ Yields:
+ An AgentClient (or CachedAgentClient) bound to the selected agent implementation.
+ """
+
+ with _build_client_from_options(options) as client:
+ if cache_path is None:
+ yield client
+ return
+
+ yield CachedAgentClient(client, cache_path)
diff --git a/imbue_core/imbue_core/agents/agent_api/cache_utils.py b/imbue_core/imbue_core/agents/agent_api/cache_utils.py
@@ -0,0 +1,36 @@
+import hashlib
+from pathlib import Path
+
+from imbue_core.agents.agent_api.data_types import AgentOptions
+from imbue_core.agents.agent_api.interaction import AgentInteractionRecord
+from imbue_core.caching import get_cache
+
+
+def _create_cache_key(prompt: str, options: AgentOptions) -> str:
+ """Create a cache key for the given prompt and options."""
+ return hashlib.md5(f"{prompt} | {options.model_dump_json() if options else ''}".encode()).hexdigest()
+
+
+def check_cache(cache_path: Path, prompt: str, options: AgentOptions) -> AgentInteractionRecord | None:
+ """Check the cache for the given prompt and options."""
+ cache_key = _create_cache_key(prompt, options)
+ cache = get_cache(cache_path)
+
+ with cache:
+ value = cache.get(cache_key)
+
+ if value is None:
+ return None
+ assert isinstance(value, str), f"Got value of type {type(value)} from cache, expected str"
+ return AgentInteractionRecord.model_validate_json(value)
+
+
+def update_cache(
+ agent_interaction: AgentInteractionRecord,
+ cache_dir: Path,
+) -> None:
+ """Save an agent interaction record to the cache."""
+ cache = get_cache(cache_dir)
+ cache_key = _create_cache_key(agent_interaction.prompt, agent_interaction.options)
+ with cache:
+ cache.set(cache_key, agent_interaction.model_dump_json())
diff --git a/imbue_core/imbue_core/agents/agent_api/claude/__init__.py b/imbue_core/imbue_core/agents/agent_api/claude/__init__.py
diff --git a/imbue_core/imbue_core/agents/agent_api/claude/client.py b/imbue_core/imbue_core/agents/agent_api/claude/client.py
@@ -0,0 +1,191 @@
+import json
+import shutil
+import tempfile
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Generator
+from typing import Iterator
+from typing import Self
+
+from loguru import logger
+
+from imbue_core.agents.agent_api.claude.data_types import ClaudeCodeOptions
+from imbue_core.agents.agent_api.claude.message_parser import parse_claude_message
+from imbue_core.agents.agent_api.client import RealAgentClient
+from imbue_core.agents.agent_api.data_types import AgentMessage
+from imbue_core.agents.agent_api.data_types import AgentResultMessage
+from imbue_core.agents.agent_api.errors import AgentCLINotFoundError
+from imbue_core.agents.agent_api.transport import AgentSubprocessCLITransport
+from imbue_core.agents.agent_api.transport import AgentSubprocessCLITransportOptions
+from imbue_core.agents.agent_api.transport import AgentTransport
+
+
+class ClaudeCodeClient(RealAgentClient[ClaudeCodeOptions]):
+ """Claude Code client implementation.
+
+ Most callers should obtain an instance through `get_agent_client(options=ClaudeCodeOptions(...))`,
+ which takes care of building and tearing down the underlying CLI transport.
+
+ Example:
+ ```python
+ with get_agent_client(options=ClaudeCodeOptions()) as client:
+ for message in client.process_query(prompt="Hello"):
+ print(message)
+ ```
+ """
+
+ def __init__(self, options: ClaudeCodeOptions, transport: AgentTransport) -> None:
+ super().__init__(options)
+ self._transport = transport
+
+ @classmethod
+ @contextmanager
+ def build(cls, options: ClaudeCodeOptions) -> Generator[Self, None, None]:
+ cmd = cls._build_cli_cmd(options)
+ with AgentSubprocessCLITransport.build(
+ AgentSubprocessCLITransportOptions(
+ cmd=cmd,
+ cwd=options.cwd,
+ extra_env_vars={"CLAUDE_CODE_ENTRYPOINT": "sdk-py"},
+ )
+ ) as transport:
+ yield cls(options=options, transport=transport)
+
+ def process_query(self, prompt: str) -> Iterator[AgentMessage]:
+ logger.trace(
+ "{client_name}: calling agent with prompt={prompt}",
+ client_name=type(self).__name__,
+ prompt=prompt,
+ )
+ # Claude code expects "User message" objects as inputs
+ self._transport.send_request(
+ [
+ {
+ "type": "user",
+ "message": {
+ "role": "user",
+ "content": [{"type": "text", "text": prompt}],
+ },
+ }
+ ],
+ self._options,
+ )
+
+ for data in self._transport.receive_messages():
+ logger.trace(
+ "{client_name}: received raw JSON message={data}",
+ client_name=type(self).__name__,
+ data=data,
+ )
+
+ message = parse_claude_message(data)
+ if message:
+ yield message
+
+ if isinstance(message, AgentResultMessage):
+ break
+
+ logger.trace(
+ "{client_name}: finished calling agent with prompt={prompt}",
+ client_name=type(self).__name__,
+ prompt=prompt,
+ )
+
+ @staticmethod
+ def _find_cli() -> str:
+ """Find Claude Code CLI binary."""
+ cli = shutil.which("claude")
+ if cli:
+ return cli
+
+ locations = [
+ # TODO: Document what these do. Does the path to claude inside the container need to be here?
+ Path("/imbue_addons/bin/claude"),
+ Path.home() / ".npm-global/bin/claude",
+ Path("/usr/local/bin/claude"),
+ Path.home() / ".local/bin/claude",
+ Path.home() / "node_modules/.bin/claude",
+ Path.home() / ".yarn/bin/claude",
+ ]
+
+ for path in locations:
+ if path.exists() and path.is_file():
+ return str(path)
+
+ node_installed = shutil.which("node") is not None
+
+ if not node_installed:
+ raise AgentCLINotFoundError(
+ "\n".join(
+ [
+ "Claude Code requires Node.js, which is not installed.",
+ "Install Node.js from: https://nodejs.org/",
+ "\nAfter installing Node.js, install Claude Code:",
+ " npm install -g @anthropic-ai/claude-code",
+ ]
+ )
+ )
+
+ raise AgentCLINotFoundError(
+ "\n".join(
+ [
+ "Claude Code not found. Install with:",
+ " npm install -g @anthropic-ai/claude-code",
+ "\nIf already installed locally, try:",
+ ' export PATH="$HOME/node_modules/.bin:$PATH"',
+ ]
+ )
+ )
+
+ @classmethod
+ def _build_cli_cmd(cls, options: ClaudeCodeOptions) -> list[str]:
+ """Build CLI command with arguments."""
+ if options.is_cached:
+ # in this case, the cmd should never be used
+ cmd = ["CACHED_CLAUDE_CODE_EXEC_PLACEHOLDER"]
+ return cmd
+ cli_path = str(options.cli_path) if options.cli_path is not None else cls._find_cli()
+ cmd = [
+ cli_path,
+ "--output-format",
+ "stream-json",
+ "--input-format",
+ "stream-json",
+ "--verbose",
+ ]
+ cmd.extend(cls._build_cli_args(options))
+ return cmd
+
+ @staticmethod
+ def _build_cli_args(options: ClaudeCodeOptions) -> list[str]:
+ args = []
+ if options.system_prompt:
+ args.extend(["--system-prompt", options.system_prompt])
+
+ if options.append_system_prompt:
+ args.extend(["--append-system-prompt", options.append_system_prompt])
+
+ if options.model:
+ args.extend(["--model", options.model])
+
+ if options.permission_prompt_tool_name:
+ args.extend(["--permission-prompt-tool", options.permission_prompt_tool_name])
+
+ if options.permission_mode:
+ args.extend(["--permission-mode", options.permission_mode])
+
+ if options.continue_conversation:
+ args.append("--continue")
+
+ if options.resume:
+ args.extend(["--resume", options.resume])
+
+ if options.mcp_servers:
+ mcp_config_file = tempfile.NamedTemporaryFile(delete=False, suffix=".json")
+ mcp_config_file.write(
+ json.dumps({"mcpServers": {k: v.model_dump() for k, v in options.mcp_servers.items()}}).encode("utf-8")
+ )
+ args.extend(["--mcp-config", mcp_config_file.name])
+
+ args.append("--print")
+ return args
diff --git a/imbue_core/imbue_core/agents/agent_api/claude/data_types.py b/imbue_core/imbue_core/agents/agent_api/claude/data_types.py
@@ -0,0 +1,79 @@
+from pathlib import Path
+from typing import Literal
+
+from pydantic import Field
+
+from imbue_core.agents.agent_api.data_types import AgentOptions
+from imbue_core.agents.agent_api.data_types import AgentToolName
+from imbue_core.pydantic_serialization import SerializableModel
+
+ClaudePermissionMode = Literal["plan", "default", "acceptEdits", "bypassPermissions"]
+
+
+class ClaudeMcpStdioServerConfig(SerializableModel):
+ """MCP stdio server configuration."""
+
+ type: Literal["stdio"] = "stdio"
+ command: str
+ args: list[str] = Field(default_factory=list)
+ env: dict[str, str] = Field(default_factory=dict)
+
+
+class ClaudeMcpHttpServerConfig(SerializableModel):
+ """MCP HTTP server configuration."""
+
+ type: Literal["http"] = "http"
+ url: str
+ headers: dict[str, str] | None = None
+
+
+ClaudeMcpServerConfig = ClaudeMcpStdioServerConfig | ClaudeMcpHttpServerConfig
+
+
+class ClaudeCodeOptions(AgentOptions):
+ """Query options for Claude SDK."""
+
+ object_type: Literal["ClaudeCodeOptions"] = "ClaudeCodeOptions"
+
+ allowed_tools: list[str] = Field(default_factory=list)
+ max_thinking_tokens: int = 8000
+ system_prompt: str | None = None
+ append_system_prompt: str | None = None
+ mcp_tools: list[str] = Field(default_factory=list)
+ mcp_servers: dict[str, ClaudeMcpServerConfig] = Field(default_factory=dict)
+ permission_mode: ClaudePermissionMode | None = None
+ continue_conversation: bool = False
+ resume: str | None = None
+ max_turns: int | None = None
+ disallowed_tools: list[str] = Field(default_factory=list)
+ model: str | None = None
+ permission_prompt_tool_name: str | None = None
+ # Optional override for the Claude CLI path
+ cli_path: Path | None = None
+ is_cached: bool = False
+
+
+CLAUDE_TOOLS = (
+ AgentToolName.READ,
+ AgentToolName.WRITE,
+ AgentToolName.EDIT,
+ AgentToolName.MULTI_EDIT,
+ AgentToolName.GLOB,
+ AgentToolName.NOTEBOOK_READ,
+ AgentToolName.NOTEBOOK_EDIT,
+ AgentToolName.LS,
+ AgentToolName.GREP,
+ AgentToolName.BASH,
+ AgentToolName.BASH_OUTPUT,
+ AgentToolName.KILL_SHELL,
+ AgentToolName.WEB_SEARCH,
+ AgentToolName.WEB_FETCH,
+ AgentToolName.TASK,
+ AgentToolName.TODO_READ,
+ AgentToolName.TODO_WRITE,
+ AgentToolName.SLASH_COMMAND,
+ AgentToolName.EXIT_PLAN_MODE,
+ AgentToolName.MCP_TOOL,
+ AgentToolName.LIST_MCP_RESOURCES,
+ AgentToolName.READ_MCP_RESOURCE,
+)
diff --git a/imbue_core/imbue_core/agents/agent_api/claude/message_parser.py b/imbue_core/imbue_core/agents/agent_api/claude/message_parser.py
@@ -0,0 +1,117 @@
+from typing import Any
+from typing import assert_never
+
+from imbue_core.agents.agent_api.data_types import AgentAssistantMessage
+from imbue_core.agents.agent_api.data_types import AgentContentBlock
+from imbue_core.agents.agent_api.data_types import AgentMessage
+from imbue_core.agents.agent_api.data_types import AgentResultMessage
+from imbue_core.agents.agent_api.data_types import AgentSystemEventType
+from imbue_core.agents.agent_api.data_types import AgentSystemMessage
+from imbue_core.agents.agent_api.data_types import AgentTextBlock
+from imbue_core.agents.agent_api.data_types import AgentThinkingBlock
+from imbue_core.agents.agent_api.data_types import AgentToolResultBlock
+from imbue_core.agents.agent_api.data_types import AgentToolUseBlock
+from imbue_core.agents.agent_api.data_types import AgentUsage
+from imbue_core.agents.agent_api.data_types import AgentUserMessage
+
+
+def parse_claude_message(data: dict[str, Any]) -> AgentMessage | None:
+ """Parse message from CLI output using unified types.
+
+ Reference:
+ https://github.com/anthropics/claude-agent-sdk-python/blob/main/src/claude_agent_sdk/_internal/message_parser.py
+ https://docs.claude.com/en/api/agent-sdk/typescript#sdkmessage
+ https://docs.claude.com/en/api/agent-sdk/python#message-types
+ """
+
+ match data["type"]:
+ case "user":
+ return AgentUserMessage(content=parse_claude_content_blocks(data), original_message=data)
+
+ case "assistant":
+ return AgentAssistantMessage(content=parse_claude_content_blocks(data), original_message=data)
+
+ case "system":
+ # Normalize system event types
+ event_type = parse_claude_system_event_type(data.get("subtype", ""))
+ return AgentSystemMessage(
+ event_type=event_type,
+ session_id=data.get("session_id"),
+ error=data.get("error"),
+ original_message=data,
+ )
+
+ case "result":
+ # Build normalized usage
+ usage = None
+ raw_usage = data.get("usage")
+ if raw_usage or data.get("total_cost_usd"):
+ usage = AgentUsage(
+ input_tokens=raw_usage.get("input_tokens") if raw_usage else None,
+ output_tokens=raw_usage.get("output_tokens") if raw_usage else None,
+ cached_tokens=(raw_usage.get("cache_read_input_tokens") if raw_usage else None),
+ total_tokens=(
+ raw_usage.get("input_tokens", 0) + raw_usage.get("output_tokens", 0) if raw_usage else None
+ ),
+ total_cost_usd=data.get("total_cost_usd"),
+ )
+
+ return AgentResultMessage(
+ session_id=data["session_id"],
+ is_error=data["is_error"],
+ duration_ms=data.get("duration_ms"),
+ api_duration_ms=data.get("duration_api_ms"),
+ num_turns=data.get("num_turns"),
+ usage=usage,
+ result=data.get("result"),
+ error=data.get("error") if data["is_error"] else None,
+ original_message=data,
+ )
+
+ case _ as unreachable:
+ assert_never(unreachable)
+
+
+def parse_claude_system_event_type(subtype: str) -> AgentSystemEventType:
+ """Parse Claude system event subtype to unified event type."""
+ subtype_lower = subtype.lower()
+
+ # TODO add other system event types as we find them
+ # basically the documentattion doesn't mention any other system event types
+ # other than init AFAIKT
+ if "init" in subtype_lower:
+ return AgentSystemEventType.SESSION_STARTED
+ else:
+ return AgentSystemEventType.OTHER
+
+
+def parse_claude_content_blocks(data: dict[str, Any]) -> list[AgentContentBlock]:
+ return [parse_claude_content_block(block) for block in data["message"]["content"]]
+
+
+def parse_claude_content_block(block: dict[str, Any]) -> AgentContentBlock:
+ """Parse content block from CLI output using unified types."""
+
+ match block["type"]:
+ case "text":
+ return AgentTextBlock(text=block["text"])
+
+ case "thinking":
+ # Claude Code thinking blocks
+ return AgentThinkingBlock(
+ content=block.get("thinking", ""),
+ thinking_tokens=block.get("thinking_tokens"),
+ )
+
+ case "tool_use":
+ return AgentToolUseBlock(id=block["id"], name=block["name"], input=block["input"])
+
+ case "tool_result":
+ return AgentToolResultBlock(
+ tool_use_id=block["tool_use_id"],
+ content=block.get("content"),
+ is_error=block.get("is_error"),
+ )
+
+ case _ as unreachable:
+ assert_never(unreachable)
diff --git a/imbue_core/imbue_core/agents/agent_api/client.py b/imbue_core/imbue_core/agents/agent_api/client.py
@@ -0,0 +1,90 @@
+import abc
+from pathlib import Path
+from typing import Generic
+from typing import Iterator
+from typing import TypeVar
+
+from imbue_core.agents.agent_api.cache_utils import check_cache
+from imbue_core.agents.agent_api.cache_utils import update_cache
+from imbue_core.agents.agent_api.data_types import AgentMessage
+from imbue_core.agents.agent_api.data_types import AgentOptions
+from imbue_core.agents.agent_api.interaction import AgentInteraction
+from imbue_core.agents.agent_api.interaction import AgentInteractionRecord
+
+AgentOptionsT = TypeVar("AgentOptionsT", bound=AgentOptions)
+
+
+class AgentClient(abc.ABC, Generic[AgentOptionsT]):
+ """Base code agent client interface.
+
+ This client defines the interface for launching and interacting with a coding agent (e.g., ClaudeCode, Codex, etc.)
+
+ Clients are usually created through `get_agent_client`, which selects the right concrete implementation
+ and manages any transports. Direct subclasses only need to implement `process_query`.
+ """
+
+ def __init__(self, options: AgentOptionsT) -> None:
+ self._options = options
+
+ @abc.abstractmethod
+ def process_query(self, prompt: str) -> Iterator[AgentMessage]:
+ """Call the underlying agent to process a query."""
+
+
+class RealAgentClient(AgentClient[AgentOptionsT]):
+ """Agent client that is not cached or dummy; it runs real commands."""
+
+ @staticmethod
+ @abc.abstractmethod
+ def _find_cli() -> str:
+ """Find the CLI binary for the agent."""
+
+ @classmethod
+ @abc.abstractmethod
+ def _build_cli_cmd(cls, options: AgentOptionsT) -> list[str]:
+ """Build the CLI command for the agent."""
+
+ @staticmethod
+ @abc.abstractmethod
+ def _build_cli_args(options: AgentOptionsT) -> list[str]:
+ """Build the CLI arguments for the agent."""
+
+
+class CachedAgentClient(AgentClient[AgentOptionsT]):
+ """Cached agent client implementation.
+
+ This client is a wrapper around an agent client that caches the agent responses.
+ """
+
+ def __init__(self, client: AgentClient[AgentOptionsT], cache_path: Path) -> None:
+ super().__init__(client._options)
+ self._client = client
+ self._cache_path = cache_path
+
+ def process_query(self, prompt: str) -> Iterator[AgentMessage]:
+ cache_path = self._cache_path
+ if cache_path is not None:
+ cache_record = check_cache(cache_path, prompt, self._client._options)
+ if cache_record is not None:
+ for message in cache_record.messages:
+ yield message
+ return
+
+ agent_interaction = AgentInteraction(prompt, self._client._options)
+ for message in self._client.process_query(prompt):
+ agent_interaction.put(message)
+ yield message
+
+ # NOTE we only cache full interactions given the 'process_query' method is called till
+ # the generator is exhausted.
+ # This means that if the generator is not exhausted, the cache will not be updated.
+ # If we do want a way to still cache interactions, even if we early exit the generator,
+ # then we could use a separate thread to get the agent response and cache it in the background.
+ # See https://gitlab.com/generally-intelligent/generally_intelligent/-/merge_requests/7323#note_2897340073
+ agent_interaction_record = AgentInteractionRecord.from_agent_interaction(agent_interaction)
+ update_cache(agent_interaction_record, cache_path)
+
+ @property
+ def client(self) -> AgentClient[AgentOptionsT]:
+ """Get the underlying client."""
+ return self._client
diff --git a/imbue_core/imbue_core/agents/agent_api/codex/__init__.py b/imbue_core/imbue_core/agents/agent_api/codex/__init__.py
diff --git a/imbue_core/imbue_core/agents/agent_api/codex/client.py b/imbue_core/imbue_core/agents/agent_api/codex/client.py
@@ -0,0 +1,166 @@
+import json
+import shutil
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Generator
+from typing import Iterator
+from typing import Self
+
+from loguru import logger
+
+from imbue_core.agents.agent_api.client import RealAgentClient
+from imbue_core.agents.agent_api.codex.data_types import CodexOptions
+from imbue_core.agents.agent_api.codex.message_parser import parse_codex_event
+from imbue_core.agents.agent_api.data_types import AgentMessage
+from imbue_core.agents.agent_api.data_types import AgentSystemMessage
+from imbue_core.agents.agent_api.errors import AgentCLINotFoundError
+from imbue_core.agents.agent_api.transport import AgentSubprocessCLITransport
+from imbue_core.agents.agent_api.transport import AgentSubprocessCLITransportOptions
+
+
+class CodexClient(RealAgentClient[CodexOptions]):
+ """Codex CLI client implementation."""
+
+ def __init__(self, options: CodexOptions) -> None:
+ super().__init__(options=options)
+ self._session_id: str | None = options.resume_session_id
+
+ @classmethod
+ @contextmanager
+ def build(cls, options: CodexOptions) -> Generator[Self, None, None]:
+ yield cls(options=options)
+
+ def process_query(self, prompt: str) -> Iterator[AgentMessage]:
+ logger.trace(
+ "{client_name}: calling agent with prompt={prompt}",
+ client_name=type(self).__name__,
+ prompt=prompt,
+ )
+
+ # NOTE: (2025-11-20) Codex CLI does not support streaming inputs, and only supports using codex CLI via
+ # non-interactive mode, where each call is a new process.
+ # So here we just create a new transport for each call, and handle things like resuming the session as
+ # needed.
+ options = self._options
+ if self._session_id is not None and self._session_id != self._options.resume_session_id:
+ # Inject the current session id into the options before building the command
+ options = self._options.model_copy(update={"resume_session_id": self._session_id})
+ cmd = self._build_cli_cmd(options)
+ with AgentSubprocessCLITransport.build(
+ AgentSubprocessCLITransportOptions(cmd=[*cmd, prompt], cwd=options.cwd)
+ ) as transport:
+ transport.send_request([prompt], options)
+
+ thread_id: str | None = None
+ for data in transport.receive_messages():
+ logger.trace(
+ "{client_name}: received raw JSON message={data}",
+ client_name=type(self).__name__,
+ data=data,
+ )
+
+ message = parse_codex_event(data, thread_id)
+ if message:
+ if isinstance(message, AgentSystemMessage):
+ thread_id = message.session_id
+ # Store the new session id for subsequent calls to process_query on this client
+ self._session_id = message.session_id
+
+ yield message
+
+ logger.trace(
+ "{client_name}: finished calling agent with prompt={prompt}",
+ client_name=type(self).__name__,
+ prompt=prompt,
+ )
+
+ @staticmethod
+ def _find_cli() -> str:
+ """Find Codex CLI binary."""
+ cli = shutil.which("codex")
+ if cli:
+ return cli
+
+ locations = [
+ Path("/usr/local/bin/codex"),
+ Path.home() / ".local/bin/codex",
+ Path.home() / "node_modules/.bin/codex",
+ Path.home() / ".npm-global/bin/codex",
+ ]
+
+ for path in locations:
+ if path.exists() and path.is_file():
+ return str(path)
+
+ node_installed = shutil.which("node") is not None
+ npm_installed = shutil.which("npm") is not None
+
+ if not node_installed or not npm_installed:
+ raise AgentCLINotFoundError(
+ "\n".join(
+ [
+ "Codex CLI requires Node.js and npm, which may not be installed.",
+ "Install Node.js from: https://nodejs.org/",
+ "\nAfter installing Node.js, install Codex CLI:",
+ " npm install -g @openai/codex",
+ ]
+ )
+ )
+
+ raise AgentCLINotFoundError(
+ "\n".join(
+ [
+ "Codex CLI not found. Install with:",
+ " npm install -g @openai/codex",
+ "\nOr via Homebrew:",
+ " brew install codex",
+ "\nIf already installed locally, try:",
+ ' export PATH="$HOME/node_modules/.bin:$PATH"',
+ ]
+ )
+ )
+
+ @classmethod
+ def _build_cli_cmd(cls, options: CodexOptions) -> list[str]:
+ """Build CLI command with arguments."""
+ if options.is_cached:
+ # in this case, the cmd should never be used
+ cmd = ["CACHED_CODEX_EXEC_PLACEHOLDER"]
+ return cmd
+ cli_path = str(options.cli_path) if options.cli_path is not None else cls._find_cli()
+ cmd = [cli_path, "exec"]
+ cmd.extend(cls._build_cli_args(options))
+ return cmd
+
+ @staticmethod
+ def _build_cli_args(options: CodexOptions) -> list[str]:
+ args = []
+ # Permissions flags
+ if options.approval_mode:
+ args.extend(["-c", f"'approval_mode={options.approval_mode}'"])
+ if options.sandbox_mode:
+ args.extend(["--sandbox", options.sandbox_mode])
+ if options.approval_policy:
+ args.extend(["-c", f"'approval={options.approval_policy}'"])
+
+ # JSON streaming output
+ args.append("--json")
+
+ # Model selection
+ if options.model:
+ args.extend(["--model", options.model])
+
+ # Skip git repo check
+ if options.skip_git_repo_check:
+ args.append("--skip-git-repo-check")
+
+ # Output schema for structured output
+ if options.output_schema:
+ args.extend(["--output-schema", json.dumps(options.output_schema)])
+
+ # Session resumption
+ if options.resume_last:
+ args.extend(["resume", "--last"])
+ elif options.resume_session_id:
+ args.extend(["resume", options.resume_session_id])
+ return args
diff --git a/imbue_core/imbue_core/agents/agent_api/codex/data_types.py b/imbue_core/imbue_core/agents/agent_api/codex/data_types.py
@@ -0,0 +1,241 @@
+"""Data types for Codex agent integration."""
+
+from pathlib import Path
+from typing import Annotated
+from typing import Any
+from typing import Literal
+
+from pydantic import Field
+from pydantic import Tag
+
+from imbue_core.agents.agent_api.data_types import AgentOptions
+from imbue_core.agents.agent_api.data_types import AgentToolName
+from imbue_core.pydantic_serialization import SerializableModel
+from imbue_core.pydantic_serialization import build_discriminator
+
+# https://developers.openai.com/codex/cli/features#approval-modes
+CodexApprovalMode = Literal["auto", "read-only", "full-access"] | None
+
+# https://developers.openai.com/codex/cli/reference, --sandbox options
+CodexSandboxMode = Literal["read-only", "workspace-write", "danger-full-access"] | None
+
+# https://developers.openai.com/codex/cli/reference, --ask-for-approval options
+CodexApprovalPolicy = Literal["untrusted", "on-failure", "on-request", "never"] | None
+
+
+class CodexOptions(AgentOptions):
+ """Options for Codex CLI execution."""
+
+ object_type: Literal["CodexOptions"] = "CodexOptions"
+
+ approval_mode: CodexApprovalMode = None
+ sandbox_mode: CodexSandboxMode = None
+ approval_policy: CodexApprovalPolicy = None
+ model: str | None = None
+ system_prompt: str | None = None
+ image_paths: list[Path] = Field(default_factory=list)
+ skip_git_repo_check: bool = False
+ output_schema: dict[str, Any] | None = None
+ # Session management
+ resume_session_id: str | None = None
+ resume_last: bool = False
+ thread_id: str | None = None
+ # Optional override for the Codex CLI path
+ cli_path: Path | None = None
+ is_cached: bool = False
+
+
+# Codex item types
+# Ref: https://github.com/openai/codex/blob/main/sdk/typescript/src/items.ts
+# Ref: https://github.com/openai/codex/blob/main/codex-rs/exec/src/exec_events.rs
+
+
+# The status of a command execution.
+CommandExecutionStatus = Literal["in_progress", "completed", "failed"]
+
+
+class CodexCommandExecutionItem(SerializableModel):
+ type: Literal["command_execution"] = "command_execution"
+ id: str
+ command: str
+ aggregated_output: str
+ exit_code: int | None = None
+ status: CommandExecutionStatus
+
+
+# Indicates the type of the file change.
+PatchChangeKind = Literal["add", "delete", "update"]
+
+
+class CodexFileUpdateChange(SerializableModel):
+ path: str
+ kind: PatchChangeKind
+
+
+# The status of a file change.
+PatchApplyStatus = Literal["completed", "failed"]
+
+
+class CodexFileChangeItem(SerializableModel):
+ type: Literal["file_change"] = "file_change"
+ id: str
+ changes: list[CodexFileUpdateChange]
+ status: PatchApplyStatus
+
+
+# The status of an MCP tool call.
+McpToolCallStatus = Literal["in_progress", "completed", "failed"]
+
+
+class CodexMcpToolCallItem(SerializableModel):
+ type: Literal["mcp_tool_call"] = "mcp_tool_call"
+ id: str
+ server: str
+ tool: str
+ status: McpToolCallStatus
+
+
+class CodexAgentMessageItem(SerializableModel):
+ type: Literal["agent_message"] = "agent_message"
+ id: str
+ text: str
+
+
+class CodexReasoningItem(SerializableModel):
+ type: Literal["reasoning"] = "reasoning"
+ id: str
+ text: str
+
+
+class CodexWebSearchItem(SerializableModel):
+ type: Literal["web_search"] = "web_search"
+ id: str
+ query: str
+
+
+class CodexErrorItem(SerializableModel):
+ type: Literal["error"] = "error"
+ id: str
+ message: str
+
+
+class CodexTodoItem(SerializableModel):
+ text: str
+ completed: bool
+
+
+class CodexTodoListItem(SerializableModel):
+ type: Literal["todo_list"] = "todo_list"
+ id: str
+ items: list[CodexTodoItem]
+
+
+# Canonical union of thread items and their type-specific payloads.
+CodexThreadItemUnion = Annotated[
+ (
+ Annotated[CodexAgentMessageItem, Tag("agent_message")]
+ | Annotated[CodexReasoningItem, Tag("reasoning")]
+ | Annotated[CodexCommandExecutionItem, Tag("command_execution")]
+ | Annotated[CodexFileChangeItem, Tag("file_change")]
+ | Annotated[CodexMcpToolCallItem, Tag("mcp_tool_call")]
+ | Annotated[CodexWebSearchItem, Tag("web_search")]
+ | Annotated[CodexTodoListItem, Tag("todo_list")]
+ | Annotated[CodexErrorItem, Tag("error")]
+ ),
+ build_discriminator("type"),
+]
+
+
+# Codex (JSONL) event stream models
+# Ref:https://github.com/openai/codex/blob/main/sdk/typescript/src/events.ts
+
+
+class CodexThreadStartedEvent(SerializableModel):
+ type: Literal["thread.started"] = "thread.started"
+ thread_id: str
+
+
+class CodexTurnStartedEvent(SerializableModel):
+ type: Literal["turn.started"] = "turn.started"
+
+
+class CodexUsage(SerializableModel):
+ input_tokens: int
+ cached_input_tokens: int
+ output_tokens: int
+
+
+class CodexTurnCompletedEvent(SerializableModel):
+ type: Literal["turn.completed"] = "turn.completed"
+ usage: CodexUsage
+
+
+class CodexThreadError(SerializableModel):
+ message: str
+
+
+class CodexTurnFailedEvent(SerializableModel):
+ type: Literal["turn.failed"] = "turn.failed"
+ error: CodexThreadError
+
+
+class CodexItemStartedEvent(SerializableModel):
+ type: Literal["item.started"] = "item.started"
+ item: CodexThreadItemUnion
+
+
+class CodexItemUpdatedEvent(SerializableModel):
+ type: Literal["item.updated"] = "item.updated"
+ item: CodexThreadItemUnion
+
+
+class CodexItemCompletedEvent(SerializableModel):
+ type: Literal["item.completed"] = "item.completed"
+ item: CodexThreadItemUnion
+
+
+class CodexThreadErrorEvent(SerializableModel):
+ type: Literal["error"] = "error"
+ message: str
+
+
+CodexThreadEvent = Annotated[
+ (
+ Annotated[CodexThreadStartedEvent, Tag("thread.started")]
+ | Annotated[CodexTurnStartedEvent, Tag("turn.started")]
+ | Annotated[CodexTurnCompletedEvent, Tag("turn.completed")]
+ | Annotated[CodexTurnFailedEvent, Tag("turn.failed")]
+ | Annotated[CodexItemStartedEvent, Tag("item.started")]
+ | Annotated[CodexItemUpdatedEvent, Tag("item.updated")]
+ | Annotated[CodexItemCompletedEvent, Tag("item.completed")]
+ | Annotated[CodexThreadErrorEvent, Tag("error")]
+ ),
+ build_discriminator("type"),
+]
+
+# TODO: some of these might not actually be valid for codex!
+CODEX_TOOLS = (
+ AgentToolName.AGENT,
+ AgentToolName.BASH,
+ AgentToolName.EDIT,
+ AgentToolName.GLOB,
+ AgentToolName.GREP,
+ AgentToolName.LS,
+ AgentToolName.MULTI_EDIT,
+ AgentToolName.NOTEBOOK_EDIT,
+ AgentToolName.NOTEBOOK_READ,
+ AgentToolName.READ,
+ AgentToolName.TODO_READ,
+ AgentToolName.TODO_WRITE,
+ AgentToolName.WEB_FETCH,
+ AgentToolName.WEB_SEARCH,
+ AgentToolName.WRITE,
+ AgentToolName.COMPUTER,
+ AgentToolName.MEMORY,
+ AgentToolName.OTHER,
+ AgentToolName.CODE_EXECUTION,
+ AgentToolName.BASH_CODE_EXECUTION,
+ AgentToolName.TEXT_EDITOR_CODE_EXECUTION,
+ AgentToolName.COMMAND_EXECUTION,
+ AgentToolName.FILE_CHANGE,
+)
diff --git a/imbue_core/imbue_core/agents/agent_api/codex/message_parser.py b/imbue_core/imbue_core/agents/agent_api/codex/message_parser.py
@@ -0,0 +1,218 @@
+from typing import Any
+from typing import assert_never
+
+from pydantic import TypeAdapter
+
+from imbue_core.agents.agent_api.codex.data_types import CodexAgentMessageItem
+from imbue_core.agents.agent_api.codex.data_types import CodexCommandExecutionItem
+from imbue_core.agents.agent_api.codex.data_types import CodexErrorItem
+from imbue_core.agents.agent_api.codex.data_types import CodexFileChangeItem
+from imbue_core.agents.agent_api.codex.data_types import CodexItemCompletedEvent
+from imbue_core.agents.agent_api.codex.data_types import CodexItemStartedEvent
+from imbue_core.agents.agent_api.codex.data_types import CodexItemUpdatedEvent
+from imbue_core.agents.agent_api.codex.data_types import CodexMcpToolCallItem
+from imbue_core.agents.agent_api.codex.data_types import CodexReasoningItem
+from imbue_core.agents.agent_api.codex.data_types import CodexThreadErrorEvent
+from imbue_core.agents.agent_api.codex.data_types import CodexThreadEvent
+from imbue_core.agents.agent_api.codex.data_types import CodexThreadItemUnion
+from imbue_core.agents.agent_api.codex.data_types import CodexThreadStartedEvent
+from imbue_core.agents.agent_api.codex.data_types import CodexTodoListItem
+from imbue_core.agents.agent_api.codex.data_types import CodexTurnCompletedEvent
+from imbue_core.agents.agent_api.codex.data_types import CodexTurnFailedEvent
+from imbue_core.agents.agent_api.codex.data_types import CodexTurnStartedEvent
+from imbue_core.agents.agent_api.codex.data_types import CodexWebSearchItem
+from imbue_core.agents.agent_api.data_types import AgentAssistantMessage
+from imbue_core.agents.agent_api.data_types import AgentContentBlock
+from imbue_core.agents.agent_api.data_types import AgentMessage
+from imbue_core.agents.agent_api.data_types import AgentResultMessage
+from imbue_core.agents.agent_api.data_types import AgentSystemEventType
+from imbue_core.agents.agent_api.data_types import AgentSystemMessage
+from imbue_core.agents.agent_api.data_types import AgentTextBlock
+from imbue_core.agents.agent_api.data_types import AgentThinkingBlock
+from imbue_core.agents.agent_api.data_types import AgentToolResultBlock
+from imbue_core.agents.agent_api.data_types import AgentToolUseBlock
+from imbue_core.agents.agent_api.data_types import AgentUsage
+
+
+def parse_codex_event(data: dict[str, Any], thread_id: str | None = None) -> AgentMessage | None:
+ """Parse Codex event into unified message.
+
+ Reference:
+ https://github.com/openai/codex/blob/main/docs/exec.md
+ https://github.com/openai/codex/blob/main/sdk/typescript/src/events.ts
+ """
+ codex_event = TypeAdapter(CodexThreadEvent).validate_python(data)
+ match codex_event:
+ case CodexThreadStartedEvent():
+ return AgentSystemMessage(
+ event_type=AgentSystemEventType.SESSION_STARTED,
+ session_id=codex_event.thread_id,
+ original_message=data,
+ )
+
+ case CodexTurnStartedEvent():
+ # Turn started within a thread. Nothing to do
+ return None
+
+ case CodexTurnCompletedEvent():
+ assert thread_id is not None, "thread_id is required for turn.completed event"
+ usage = AgentUsage(
+ input_tokens=codex_event.usage.input_tokens,
+ output_tokens=codex_event.usage.output_tokens,
+ cached_tokens=codex_event.usage.cached_input_tokens,
+ total_tokens=codex_event.usage.input_tokens + codex_event.usage.output_tokens,
+ )
+ return AgentResultMessage(
+ session_id=thread_id,
+ is_error=False,
+ usage=usage,
+ original_message=data,
+ )
+
+ case CodexTurnFailedEvent():
+ assert thread_id is not None, "thread_id is required for turn.failed event"
+ return AgentResultMessage(
+ session_id=thread_id,
+ is_error=True,
+ error=codex_event.error.message,
+ usage=None,
+ original_message=data,
+ )
+
+ case CodexItemStartedEvent():
+ content_blocks = parse_codex_item(codex_event.item)
+ return AgentAssistantMessage(content=content_blocks, original_message=data)
+
+ case CodexItemUpdatedEvent():
+ # Intermediate item, don't return anything
+ return None
+
+ case CodexItemCompletedEvent():
+ content_blocks = parse_codex_item(codex_event.item)
+ return AgentAssistantMessage(content=content_blocks, original_message=data)
+
+ case CodexThreadErrorEvent():
+ return AgentResultMessage(
+ session_id=thread_id or "",
+ is_error=True,
+ error=codex_event.message,
+ usage=None,
+ original_message=data,
+ )
+ case _ as unreachable:
+ assert_never(unreachable)
+
+
+def parse_codex_item(
+ item_data: dict[str, Any] | CodexThreadItemUnion,
+) -> list[AgentContentBlock]:
+ """Parse Codex item into unified content blocks.
+
+ Refs:
+ https://github.com/openai/codex/blob/main/sdk/typescript/src/items.ts
+ """
+ if isinstance(item_data, dict):
+ codex_item = TypeAdapter(CodexThreadItemUnion).validate_python(item_data)
+ else:
+ codex_item = item_data
+
+ match codex_item:
+ case CodexAgentMessageItem():
+ return [AgentTextBlock(text=codex_item.text)]
+
+ case CodexReasoningItem():
+ return [AgentThinkingBlock(content=codex_item.text)]
+
+ case CodexErrorItem():
+ return [AgentTextBlock(text=f"[Error: {codex_item.message}]")]
+
+ case CodexCommandExecutionItem():
+ if codex_item.status == "in_progress":
+ return [
+ AgentToolUseBlock(
+ id=codex_item.id,
+ name=codex_item.type,
+ input={"command": codex_item.command},
+ )
+ ]
+ return [
+ AgentToolResultBlock(
+ tool_use_id=codex_item.id,
+ content=codex_item.aggregated_output,
+ exit_code=codex_item.exit_code,
+ is_error=codex_item.exit_code != 0,
+ )
+ ]
+
+ case CodexFileChangeItem():
+ is_error = codex_item.status == "failed"
+ return [
+ AgentToolUseBlock(
+ id=codex_item.id,
+ name=codex_item.type,
+ input={"changes": [change.model_dump() for change in codex_item.changes]},
+ ),
+ AgentToolResultBlock(
+ tool_use_id=codex_item.id,
+ content=[change.model_dump() for change in codex_item.changes],
+ is_error=is_error,
+ exit_code=-1 if is_error else 0,
+ ),
+ ]
+
+ case CodexMcpToolCallItem():
+ if codex_item.status == "in_progress":
+ return [
+ AgentToolUseBlock(
+ id=codex_item.id,
+ name=codex_item.type,
+ input={"server": codex_item.server, "tool": codex_item.tool},
+ )
+ ]
+ # NOTE: currently (24-oct-2025) the MCP tool call item is not really well defined
+ # it does not have a result field or anything. So for now, we just return the server and tool as the content.
+ return [
+ AgentToolResultBlock(
+ tool_use_id=codex_item.id,
+ content=[{"server": codex_item.server, "tool": codex_item.tool}],
+ is_error=codex_item.status == "failed",
+ exit_code=-1 if codex_item.status == "failed" else 0,
+ )
+ ]
+
+ case CodexWebSearchItem():
+ # NOTE: currently (24-oct-2025) the web search item is not really well defined
+ # i.e. it only has a query field, and no other fields like results, progress, etc.
+ # so for now, so that each tool use has a matching result, we just return the query as the content.
+ return [
+ AgentToolUseBlock(
+ id=codex_item.id,
+ name=codex_item.type,
+ input={"query": codex_item.query},
+ ),
+ AgentToolResultBlock(
+ tool_use_id=codex_item.id,
+ content=codex_item.query,
+ # No error reported for web search
+ is_error=False,
+ exit_code=0,
+ ),
+ ]
+
+ case CodexTodoListItem():
+ return [
+ AgentToolUseBlock(
+ id=codex_item.id,
+ name=codex_item.type,
+ input={"todos": [item.model_dump() for item in codex_item.items]},
+ ),
+ AgentToolResultBlock(
+ tool_use_id=codex_item.id,
+ content=[item.model_dump() for item in codex_item.items],
+ is_error=False,
+ exit_code=0,
+ ),
+ ]
+
+ case _ as unreachable:
+ assert_never(unreachable)
diff --git a/imbue_core/imbue_core/agents/agent_api/data_types.py b/imbue_core/imbue_core/agents/agent_api/data_types.py
@@ -0,0 +1,252 @@
+import enum
+from pathlib import Path
+from typing import Annotated
+from typing import Any
+from typing import Literal
+
+from pydantic import Field
+from pydantic import Tag
+
+from imbue_core.pydantic_serialization import SerializableModel
+from imbue_core.pydantic_serialization import build_discriminator
+
+AgentPermissionMode = Literal["default", "acceptEdits", "bypassPermissions"]
+
+
+class AgentToolName(enum.StrEnum):
+ """Enumeration of all known coding agent tools across Claude Code and Codex.
+
+ This is a superset of tools available across different coding agents.
+ Not all tools are available in all agents.
+ """
+
+ # File operations
+ READ = "Read"
+ WRITE = "Write"
+ EDIT = "Edit"
+ MULTI_EDIT = "MultiEdit"
+ GLOB = "Glob"
+ NOTEBOOK_READ = "NotebookRead"
+ NOTEBOOK_EDIT = "NotebookEdit"
+ LS = "LS"
+
+ # Search operations
+ GREP = "Grep"
+
+ # Execution tools
+ BASH = "Bash"
+ BASH_OUTPUT = "BashOutput"
+ KILL_SHELL = "KillShell"
+
+ # Web operations
+ WEB_SEARCH = "WebSearch"
+ WEB_FETCH = "WebFetch"
+
+ # Agent orchestration
+ TASK = "Task"
+ TODO_READ = "TodoRead"
+ TODO_WRITE = "TodoWrite"
+ SLASH_COMMAND = "SlashCommand"
+ EXIT_PLAN_MODE = "exit_plan_mode"
+
+ # MCP tools
+ MCP_TOOL = "mcp_tool" # Generic MCP tool prefix
+ LIST_MCP_RESOURCES = "ListMcpResourcesTool"
+ READ_MCP_RESOURCE = "ReadMcpResourceTool"
+
+ # Code execution
+ CODE_EXECUTION = "code_execution"
+ BASH_CODE_EXECUTION = "bash_code_execution"
+ TEXT_EDITOR_CODE_EXECUTION = "text_editor_code_execution"
+
+ # Codex-specific operations
+ COMMAND_EXECUTION = "command_execution" # Codex's command execution
+ FILE_CHANGE = "file_change" # Codex's file change operation
+
+ # Other tools
+ AGENT = "Agent"
+ COMPUTER = "computer" # Computer use capability
+ MEMORY = "memory" # Memory storage
+ OTHER = "other" # Catch-all for unknown/custom tools
+
+
+# TODO: these are not, in the strict sense, read-only; perhaps we should have finer gradations
+READ_ONLY_TOOLS = (
+ AgentToolName.TASK,
+ AgentToolName.READ,
+ AgentToolName.GLOB,
+ AgentToolName.GREP,
+ AgentToolName.LS,
+ AgentToolName.BASH,
+ AgentToolName.NOTEBOOK_READ,
+ AgentToolName.TODO_READ,
+ AgentToolName.TODO_WRITE,
+ AgentToolName.WEB_FETCH,
+ AgentToolName.WEB_SEARCH,
+)
+
+
+# Content block types
+class AgentTextBlock(SerializableModel):
+ """Text content block.
+
+ Represents plain text output from the agent.
+ """
+
+ text: str
+
+
+class AgentThinkingBlock(SerializableModel):
+ """Agent's internal reasoning/thinking block.
+
+ Represents the agent's thought process or reasoning, which may be hidden
+ from the end user in some interfaces.
+ """
+
+ content: str
+ thinking_tokens: int | None = Field(default=None, description="Number of tokens used for thinking")
+
+
+class AgentToolUseBlock(SerializableModel):
+ """Tool invocation request.
+
+ Represents a request from the agent to use a specific tool.
+ """
+
+ id: str
+ name: AgentToolName | str # allow str for flexibility
+ input: dict[str, Any]
+
+
+class AgentToolResultBlock(SerializableModel):
+ """Tool execution result.
+
+ Represents the result of executing a tool, which is fed back to the agent.
+ """
+
+ tool_use_id: str
+ content: str | list[dict[str, Any]] | None = None
+ is_error: bool | None = None
+ exit_code: int | None = Field(default=None, description="Exit code for command executions")
+
+
+AgentContentBlock = AgentTextBlock | AgentThinkingBlock | AgentToolUseBlock | AgentToolResultBlock
+
+
+class AgentSystemEventType(enum.StrEnum):
+ """System event types
+
+ Super set of system event types across all agents.
+ """
+
+ SESSION_STARTED = "session_started"
+ SESSION_RESUMED = "session_resumed"
+ TURN_STARTED = "turn_started"
+ TURN_COMPLETED = "turn_completed"
+ TURN_FAILED = "turn_failed"
+ # For agent-specific events that don't fit into the above categories
+ OTHER = "other"
+
+
+# Message types (`type` field is required for serialization)
+class AgentUserMessage(SerializableModel):
+ """User message.
+
+ Represents input from the user to the agent.
+ """
+
+ object_type: Literal["AgentUserMessage"] = "AgentUserMessage"
+ content: str | list[AgentContentBlock]
+ original_message: dict[str, Any] | None = Field(default=None, description="Original agent-specific message data")
+
+
+class AgentAssistantMessage(SerializableModel):
+ """Assistant message with content blocks.
+
+ Represents output from the agent, which may include text, thinking,
+ tool uses, and tool results.
+ """
+
+ object_type: Literal["AgentAssistantMessage"] = "AgentAssistantMessage"
+ content: list[AgentContentBlock]
+ original_message: dict[str, Any] | None = Field(default=None, description="Original agent-specific message data")
+
+
+class AgentSystemMessage(SerializableModel):
+ """System message with normalized event data.
+
+ Represents lifecycle events from the agent session (e.g., turn started,
+ turn completed, session started).
+ """
+
+ object_type: Literal["AgentSystemMessage"] = "AgentSystemMessage"
+ event_type: AgentSystemEventType
+ session_id: str | None = Field(default=None, description="Session/thread identifier")
+ error: str | None = Field(default=None, description="Error message for failed events")
+ original_message: dict[str, Any] | None = Field(default=None, description="Original agent-specific message data")
+
+
+class AgentUsage(SerializableModel):
+ """Normalized usage tracking across agents.
+
+ Tracks token usage and costs in a unified format.
+ """
+
+ input_tokens: int | None = Field(default=None, description="Input/prompt tokens consumed")
+ output_tokens: int | None = Field(default=None, description="Output/completion tokens generated")
+ cached_tokens: int | None = Field(default=None, description="Cached input tokens reused")
+ total_tokens: int | None = Field(default=None, description="Total tokens (input + output)")
+ thinking_tokens: int | None = Field(default=None, description="Tokens used for extended thinking")
+ total_cost_usd: float | None = Field(default=None, description="Estimated cost in USD")
+
+
+class AgentResultMessage(SerializableModel):
+ """Result message with cost and usage information.
+
+ Represents the final result of an agent session, including timing,
+ usage statistics, and success/error status.
+ """
+
+ object_type: Literal["AgentResultMessage"] = "AgentResultMessage"
+ session_id: str
+ is_error: bool
+ duration_ms: int | None = Field(default=None, description="Total duration in milliseconds")
+ api_duration_ms: int | None = Field(default=None, description="API call duration in milliseconds")
+ num_turns: int | None = Field(default=None, description="Number of conversation turns")
+ usage: AgentUsage | None = Field(default=None, description="Token usage and cost information")
+ result: str | None = Field(default=None, description="Final result or output from the agent")
+ error: str | None = Field(default=None, description="Error message if is_error=True")
+ original_message: dict[str, Any] | None = Field(default=None, description="Original agent-specific message data")
+
+
+AgentMessage = AgentUserMessage | AgentAssistantMessage | AgentSystemMessage | AgentResultMessage
+AgentMessageUnion = Annotated[
+ Annotated[AgentUserMessage, Tag("AgentUserMessage")]
+ | Annotated[AgentAssistantMessage, Tag("AgentAssistantMessage")]
+ | Annotated[AgentSystemMessage, Tag("AgentSystemMessage")]
+ | Annotated[AgentResultMessage, Tag("AgentResultMessage")],
+ build_discriminator(),
+]
+
+
+class ToolUseRecord(SerializableModel):
+ """A record of a tool use."""
+
+ request_message: AgentToolUseBlock
+ result_message: AgentToolResultBlock
+
+ @property
+ def tool_name(self) -> str:
+ """The name of the tool used."""
+ return self.request_message.name
+
+ @property
+ def tool_input(self) -> dict[str, Any]:
+ """The input to the tool."""
+ return self.request_message.input
+
+
+class AgentOptions(SerializableModel):
+ """Parent class for all agent options."""
+
+ cwd: str | Path | None = None
diff --git a/imbue_core/imbue_core/agents/agent_api/errors.py b/imbue_core/imbue_core/agents/agent_api/errors.py
@@ -0,0 +1,62 @@
+"""Error types for Agent API."""
+
+from typing import Any
+
+
+class AgentAPIError(Exception):
+ """Base exception for all Agent API errors."""
+
+
+class AgentCLIConnectionError(AgentAPIError):
+ """Raised when unable to connect to Agent Code."""
+
+
+class AgentCLINotFoundError(AgentCLIConnectionError):
+ """Raised when Agent Code is not found or not installed."""
+
+ def __init__(self, message: str, cli_path: str | None = None) -> None:
+ if cli_path:
+ message = f"{message}: {cli_path}"
+ super().__init__(message)
+
+
+class AgentProcessError(AgentAPIError):
+ """Raised when the CLI process fails."""
+
+ def __init__(self, message: str, exit_code: int | None = None, stderr: str | None = None) -> None:
+ self.exit_code = exit_code
+ self.stderr = stderr
+
+ if exit_code is not None:
+ message = f"{message} (exit code: {exit_code})"
+ if stderr:
+ message = f"{message}\nError output: {stderr}"
+
+ super().__init__(message)
+
+
+class AgentCLIJSONDecodeError(AgentAPIError):
+ """Raised when unable to decode JSON from CLI output."""
+
+ def __init__(self, line: str, original_error: Exception) -> None:
+ self.line = line
+ self.original_error = original_error
+ super().__init__(f"Failed to decode JSON: {line[:100]}...")
+
+
+class AgentUnknownMessageTypeError(AgentAPIError):
+ """Raised when an unknown message type is encountered."""
+
+ def __init__(self, message_type: str, data: dict[str, Any]) -> None:
+ self.message_type = message_type
+ self.data = data
+ super().__init__(f"Unknown message type: {message_type}\nData: {data}")
+
+
+class AgentUnknownContentBlockTypeError(AgentAPIError):
+ """Raised when an unknown content block type is encountered."""
+
+ def __init__(self, block_type: str, data: dict[str, Any]) -> None:
+ self.block_type = block_type
+ self.data = data
+ super().__init__(f"Unknown content block type: {block_type}\nData: {data}")
diff --git a/imbue_core/imbue_core/agents/agent_api/interaction.py b/imbue_core/imbue_core/agents/agent_api/interaction.py
@@ -0,0 +1,96 @@
+from typing import Sequence
+
+from imbue_core.agents.agent_api.data_types import AgentAssistantMessage
+from imbue_core.agents.agent_api.data_types import AgentMessage
+from imbue_core.agents.agent_api.data_types import AgentOptions
+from imbue_core.agents.agent_api.data_types import AgentToolResultBlock
+from imbue_core.agents.agent_api.data_types import AgentToolUseBlock
+from imbue_core.agents.agent_api.data_types import AgentUserMessage
+from imbue_core.agents.agent_api.data_types import ToolUseRecord
+from imbue_core.pydantic_serialization import SerializableModel
+
+
+class AgentInteraction:
+ """A class for tracking an ongoing interaction with an agent.
+
+ Note that this class is not thread-safe.
+ """
+
+ def __init__(self, prompt: str, options: AgentOptions) -> None:
+ self.prompt = prompt
+ self.options = options
+ self.messages: list[AgentMessage] = []
+ self.tool_use_records: list[ToolUseRecord] = []
+ self._unresolved_tool_use_requests: list[AgentToolUseBlock] = []
+
+ def put(self, message: AgentMessage) -> None:
+ self.messages.append(message)
+
+ if isinstance(message, AgentAssistantMessage):
+ for assistant_content_block in message.content:
+ if isinstance(assistant_content_block, AgentToolUseBlock):
+ self._unresolved_tool_use_requests.append(assistant_content_block)
+ elif isinstance(message, AgentUserMessage) and isinstance(message.content, list):
+ for content_block in message.content:
+ if isinstance(content_block, AgentToolResultBlock):
+ remaining_unresolved_requests = []
+ for request in self._unresolved_tool_use_requests:
+ if request.id == content_block.tool_use_id:
+ self.tool_use_records.append(
+ ToolUseRecord(
+ request_message=request,
+ result_message=content_block,
+ )
+ )
+ else:
+ remaining_unresolved_requests.append(request)
+ self._unresolved_tool_use_requests = remaining_unresolved_requests
+
+ def find_tool_use_record_by_command(self, command: str, by_most_recent: bool = True) -> ToolUseRecord | None:
+ """Look for tool use request and result messages by the tool command.
+
+ If by_most_recent is True, the records are searched in reverse order (most recent first).
+ """
+ return _find_tool_use_record_by_command(self.tool_use_records, command, by_most_recent)
+
+
+class AgentInteractionRecord(SerializableModel):
+ """A serializable record of a completed agent interaction.
+
+ This is meant to be used for storing a completed log in a database or cache.
+ """
+
+ prompt: str
+ options: AgentOptions
+ messages: tuple[AgentMessage, ...]
+ tool_use_records: tuple[ToolUseRecord, ...]
+
+ @classmethod
+ def from_agent_interaction(cls, agent_interaction: AgentInteraction) -> "AgentInteractionRecord":
+ return cls(
+ prompt=agent_interaction.prompt,
+ options=agent_interaction.options,
+ messages=tuple(agent_interaction.messages),
+ tool_use_records=tuple(agent_interaction.tool_use_records),
+ )
+
+ def find_tool_use_record_by_command(self, command: str, by_most_recent: bool = True) -> ToolUseRecord | None:
+ """Look for tool use request and result messages by the tool command.
+
+ If by_most_recent is True, the records are searched in reverse order (most recent first).
+ """
+ return _find_tool_use_record_by_command(self.tool_use_records, command, by_most_recent)
+
+
+def _find_tool_use_record_by_command(
+ tool_use_records: Sequence[ToolUseRecord], command: str, reverse: bool = True
+) -> ToolUseRecord | None:
+ """Look for tool use request and result messages by the tool command.
+
+ If reverse is True, the records are searched in reverse order (most recent first).
+ """
+ for record in reversed(tool_use_records) if reverse else tool_use_records:
+ tool_input = record.tool_input
+ if "command" in tool_input and tool_input["command"] == command:
+ return record
+ return None
diff --git a/imbue_core/imbue_core/agents/agent_api/transport.py b/imbue_core/imbue_core/agents/agent_api/transport.py
@@ -0,0 +1,176 @@
+import json
+import os
+import subprocess
+import threading
+from abc import ABC
+from abc import abstractmethod
+from contextlib import contextmanager
+from pathlib import Path
+from subprocess import PIPE
+from typing import Any
+from typing import ContextManager
+from typing import Generator
+from typing import Generic
+from typing import Iterable
+from typing import Iterator
+from typing import Self
+from typing import Sequence
+from typing import TypeVar
+
+from imbue_core.agents.agent_api.data_types import AgentOptions
+from imbue_core.agents.agent_api.errors import AgentCLIConnectionError
+from imbue_core.agents.agent_api.errors import (
+ AgentCLIJSONDecodeError as SDKJSONDecodeError,
+)
+from imbue_core.agents.agent_api.errors import AgentCLINotFoundError
+from imbue_core.agents.agent_api.errors import AgentProcessError
+from imbue_core.pydantic_serialization import SerializableModel
+
+TransportOptionsT = TypeVar("TransportOptionsT", bound=SerializableModel)
+
+
+class AgentTransport(ABC, Generic[TransportOptionsT]):
+ """Abstract transport for Agent communication."""
+
+ @classmethod
+ @abstractmethod
+ def build(cls, options: TransportOptionsT) -> ContextManager[Self]:
+ """Build a transport from options.
+
+ This is the main entry point for building a transport and managing its lifecycle.
+ """
+
+ @abstractmethod
+ def send_request(self, messages: list[Any], agent_options: AgentOptions) -> None:
+ """Send request to underlying agent via transport."""
+
+ @abstractmethod
+ def receive_messages(self) -> Iterator[dict[str, Any]]:
+ """Receive messages from underlying agent via transport."""
+
+ @abstractmethod
+ def is_connected(self) -> bool:
+ """Check if transport is connected."""
+
+
+class AgentSubprocessCLITransportOptions(SerializableModel):
+ """Options for AgentSubprocessCLITransport."""
+
+ cmd: Sequence[str]
+ cwd: str | Path | None = None
+ extra_env_vars: dict[str, str] | None = None
+
+
+class AgentSubprocessCLITransport(AgentTransport[AgentSubprocessCLITransportOptions]):
+ """Subprocess transport using Coding Agent via a CLI."""
+
+ def __init__(
+ self,
+ popen: subprocess.Popen[str],
+ ) -> None:
+ self._process = popen
+ self._stdin_stream = popen.stdin
+ self._stdout_stream = popen.stdout
+ self._stderr_stream = popen.stderr
+
+ @classmethod
+ @contextmanager
+ def build(cls, options: AgentSubprocessCLITransportOptions) -> Generator[Self, None, None]:
+ extra_env_vars = options.extra_env_vars or {}
+ try:
+ popen = subprocess.Popen(
+ options.cmd,
+ stdin=PIPE,
+ stdout=PIPE,
+ stderr=PIPE,
+ cwd=options.cwd,
+ env={**os.environ, **extra_env_vars},
+ # ensure output is line buffered
+ bufsize=1,
+ text=True,
+ encoding="utf-8",
+ )
+ except FileNotFoundError as e:
+ raise AgentCLINotFoundError(f"Agent CLI not found for: cmd={options.cmd}") from e
+ except Exception as e:
+ raise AgentCLIConnectionError(f"Failed to start Agent CLI via cmd={options.cmd}: {e}") from e
+
+ try:
+ yield cls(popen)
+ finally:
+ # Make sure to terminate the process if it is still running, and clean up the streams
+ if popen.poll() is None:
+ try:
+ popen.terminate()
+ popen.wait(timeout=5.0)
+ except subprocess.TimeoutExpired:
+ popen.kill()
+ popen.wait(timeout=5.0)
+ popen.stdout and popen.stdout.close()
+ popen.stderr and popen.stderr.close()
+ popen.stdin and popen.stdin.close()
+
+ def send_request(self, messages: Iterable[dict[str, Any] | str], agent_options: AgentOptions) -> None:
+ process = self._process
+ stdin_stream = self._stdin_stream
+ if not process or not stdin_stream:
+ raise AgentCLIConnectionError("Not connected")
+
+ for message in messages:
+ stdin_stream.write(json.dumps(message) + "\n")
+ stdin_stream.flush()
+
+ def _read_stderr(self, output_buffer: list[str]) -> None:
+ """Read stderr in background."""
+ stderr_stream = self._stderr_stream
+ if stderr_stream:
+ try:
+ for line in stderr_stream:
+ output_buffer.append(line.strip())
+ except subprocess.SubprocessError:
+ pass
+
+ def receive_messages(self) -> Iterator[dict[str, Any]]:
+ process = self._process
+ stdout_stream = self._stdout_stream
+ if not process or not stdout_stream:
+ raise AgentCLIConnectionError("Not connected")
+
+ stderr_lines: list[str] = []
+ stderr_read_thread = threading.Thread(target=self._read_stderr, args=(stderr_lines,))
+ stderr_read_thread.start()
+
+ try:
+ for line in stdout_stream:
+ line_str = line.strip()
+ if not line_str:
+ continue
+
+ try:
+ data = json.loads(line_str)
+ try:
+ yield data
+ except GeneratorExit:
+ # Handle generator cleanup gracefully
+ return
+ except json.JSONDecodeError as e:
+ if line_str.startswith("{") or line_str.startswith("["):
+ raise SDKJSONDecodeError(line_str, e) from e
+ continue
+
+ except subprocess.SubprocessError:
+ pass
+
+ process.wait()
+ if process.returncode is not None and process.returncode != 0:
+ stderr_output = "\n".join(stderr_lines)
+ if stderr_output and "error" in stderr_output.lower():
+ raise AgentProcessError(
+ "CLI process failed",
+ exit_code=process.returncode,
+ stderr=stderr_output,
+ )
+
+ def is_connected(self) -> bool:
+ process = self._process
+ return process is not None and process.returncode is None
diff --git a/imbue_core/imbue_core/agents/agent_api/union_types.py b/imbue_core/imbue_core/agents/agent_api/union_types.py
@@ -0,0 +1,12 @@
+from typing import Annotated
+
+from pydantic import Tag
+
+from imbue_core.agents.agent_api.claude.data_types import ClaudeCodeOptions
+from imbue_core.agents.agent_api.codex.data_types import CodexOptions
+from imbue_core.pydantic_serialization import build_discriminator
+
+AgentOptionsUnion = Annotated[
+ Annotated[ClaudeCodeOptions, Tag("ClaudeCodeOptions")] | Annotated[CodexOptions, Tag("CodexOptions")],
+ build_discriminator(),
+]
diff --git a/imbue_core/imbue_core/agents/configs.py b/imbue_core/imbue_core/agents/configs.py
@@ -0,0 +1,123 @@
+from pathlib import Path
+from typing import Any
+from typing import assert_never
+
+from imbue_core.agents.llm_apis.anthropic_api import AnthropicModelName
+from imbue_core.agents.llm_apis.anthropic_api import count_anthropic_tokens
+from imbue_core.agents.llm_apis.common import get_model_max_context_length
+from imbue_core.agents.llm_apis.constants import approximate_token_count
+from imbue_core.agents.llm_apis.data_types import ModelStr
+from imbue_core.agents.llm_apis.mock_api import MY_MOCK_MODEL_INFO
+from imbue_core.agents.llm_apis.openai_api import OpenAIModelName
+from imbue_core.agents.llm_apis.openai_api import count_openai_tokens
+from imbue_core.language_model_mode import LanguageModelMode
+from imbue_core.pydantic_serialization import SerializableModel
+
+
+class LanguageModelGenerationConfig(SerializableModel):
+ model_name: ModelStr = OpenAIModelName.GPT_4O_2024_08_06
+ # this should almost always be None (you dont want to save your cache path into the hammer invocation data!)
+ cache_path: Path | None = None
+ count_tokens_cache_path: Path | None = None
+ is_prompt_debugging_enabled: bool = False
+
+ # If true, the LLM API will cache the inputs to the LLM call as well as the outputs, which makes prompt diffing easier.
+ is_caching_inputs: bool = False
+
+ # if this is set, the LLM API will return ONLY cached responses
+ is_running_offline: bool = False
+
+ # if set, the LLM API will return log probabilities for the output tokens (if supported by the model)
+ is_using_logprobs: bool = False
+
+ # Retry configuration
+ retry_jitter_factor: float = 0.5
+
+ def model_post_init(self, __context: Any) -> None:
+ super().model_post_init(__context)
+ # FIXME: do proper validation
+ if self.cache_path is None and self.is_caching_inputs:
+ raise ValueError("cache_path must be provided if is_caching_inputs is True")
+
+ def count_tokens(self, text: str) -> int:
+ """Count tokens in the given text using the model's tokenizer."""
+ if self.model_name in (v for v in OpenAIModelName):
+ return count_openai_tokens(text, self.model_name)
+ if self.model_name in (v for v in AnthropicModelName):
+ return count_anthropic_tokens(text)
+ return approximate_token_count(text)
+
+ def get_max_context_length(self) -> int:
+ """Get the maximum context length for this model."""
+ return get_model_max_context_length(self.model_name)
+
+ def is_custom_model(self) -> bool:
+ """Return True if this is a custom/user-defined model.
+
+ Custom models use approximate token counting since there's no mechanism
+ for defining a tokenizer for them.
+ """
+ return False
+
+
+class OpenAICompatibleModelConfig(LanguageModelGenerationConfig):
+ """Configuration for custom models using OpenAI-compatible APIs (e.g., Ollama, local LLMs)."""
+
+ custom_base_url: str
+ custom_api_key_env: str
+ custom_context_window: int
+ custom_max_output_tokens: int
+
+ def count_tokens(self, text: str) -> int:
+ """Count tokens using approximation since we don't have access to the model's tokenizer."""
+ return approximate_token_count(text)
+
+ def get_max_context_length(self) -> int:
+ """Get the maximum context length for this model."""
+ return self.custom_context_window
+
+ def is_custom_model(self) -> bool:
+ """Return True if this is a custom/user-defined model.
+
+ Custom models use approximate token counting since there's no mechanism
+ for defining a tokenizer for them.
+ """
+ # TODO: Support custom tokenizers with custom models.
+ return True
+
+
+class MockedLanguageModelGenerationConfig(LanguageModelGenerationConfig):
+ model_name: ModelStr = MY_MOCK_MODEL_INFO.model_name
+ is_running_offline: bool = True
+ mock_responses_path: Path
+
+
+def create_safe_llm_config(
+ llm_name: ModelStr, mode: LanguageModelMode, cache_path: Path | None = None
+) -> LanguageModelGenerationConfig:
+ match mode:
+ case LanguageModelMode.LIVE:
+ assert cache_path is None
+ language_model_config = LanguageModelGenerationConfig(model_name=llm_name)
+ case LanguageModelMode.OFFLINE:
+ assert cache_path is not None
+ language_model_config = LanguageModelGenerationConfig(
+ model_name=llm_name,
+ is_running_offline=True,
+ is_caching_inputs=True,
+ cache_path=cache_path,
+ )
+ case LanguageModelMode.UPDATE_SNAPSHOT:
+ assert cache_path is not None
+ language_model_config = LanguageModelGenerationConfig(
+ model_name=llm_name, is_caching_inputs=True, cache_path=cache_path
+ )
+ case LanguageModelMode.MOCKED:
+ assert cache_path is not None
+ language_model_config = MockedLanguageModelGenerationConfig(
+ model_name=llm_name, mock_responses_path=cache_path
+ )
+ case _ as unreachable:
+ assert_never(unreachable) # pyre-ignore[6]: pyre doesn't understand enums
+ assert False # because pyre doesn't really understand assert_never, either
+ return language_model_config
diff --git a/imbue_core/imbue_core/agents/data_types/__init__.py b/imbue_core/imbue_core/agents/data_types/__init__.py
diff --git a/imbue_core/imbue_core/agents/data_types/ids.py b/imbue_core/imbue_core/agents/data_types/ids.py
@@ -0,0 +1,64 @@
+from abc import ABC
+
+from pydantic import GetCoreSchemaHandler
+from pydantic_core import core_schema
+from typeid import TypeID
+from typeid import get_prefix_and_suffix
+
+
+class TypeIDPrefixMismatchError(Exception):
+ pass
+
+
+class ObjectID(TypeID, ABC):
+ """
+ A convenience class for string-based object IDs.
+
+ Use in place of strings for IDs. (We don't use raw UUIDs because they are not supported by SQLite.)
+
+ Use `tag` to prefix the ID with the ID type. (We don't use `prefix` because it's already taken by the ancestor class.)
+
+ """
+
+ # Override this in subclasses to specify the ID type.
+ tag: str = "oid"
+
+ def __init__(self, value: str | None = None) -> None:
+ if value is not None:
+ prefix, suffix = get_prefix_and_suffix(value)
+ # For convenience, don't require the caller to strip the prefix from existing IDs.
+ if prefix is not None:
+ if prefix != self.tag:
+ raise TypeIDPrefixMismatchError(f"Expected prefix '{self.tag}', got '{prefix}'")
+ value = suffix
+ super().__init__(self.tag, value)
+
+ @classmethod
+ def __get_pydantic_core_schema__(cls, source_type: type, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
+ """
+ Support transparently deserializing strings into ObjectID instances and vice versa.
+ """
+ return core_schema.no_info_before_validator_function(
+ lambda raw_value: (cls(raw_value) if isinstance(raw_value, str) else raw_value),
+ core_schema.union_schema(
+ [
+ core_schema.is_instance_schema(cls),
+ core_schema.str_schema(),
+ ]
+ ),
+ serialization=core_schema.plain_serializer_function_ser_schema(
+ lambda instance: str(instance), return_schema=core_schema.str_schema()
+ ),
+ )
+
+
+class TaskID(ObjectID):
+ tag: str = "tsk"
+
+
+class ProjectID(ObjectID):
+ tag: str = "prj"
+
+
+class AgentMessageID(ObjectID):
+ tag: str = "agm"
diff --git a/imbue_core/imbue_core/agents/llm_apis/__init__.py b/imbue_core/imbue_core/agents/llm_apis/__init__.py
diff --git a/imbue_core/imbue_core/agents/llm_apis/anthropic_api.py b/imbue_core/imbue_core/agents/llm_apis/anthropic_api.py
@@ -0,0 +1,822 @@
+import enum
+import inspect
+from contextlib import contextmanager
+from functools import lru_cache
+from pathlib import Path
+from types import FrameType
+from typing import AsyncGenerator
+from typing import Final
+from typing import Iterator
+
+import anthropic
+import httpx
+from anthropic._types import NOT_GIVEN
+from anthropic.types import CacheControlEphemeralParam
+from anthropic.types import MessageParam
+from anthropic.types import TextBlockParam
+from loguru import logger
+from pydantic.functional_validators import field_validator
+import tiktoken
+
+from imbue_core.agents.llm_apis.anthropic_data_types import AnthropicCachingInfo
+from imbue_core.agents.llm_apis.anthropic_data_types import AnthropicModelInfo
+from imbue_core.agents.llm_apis.api_utils import convert_prompt_to_messages
+from imbue_core.agents.llm_apis.api_utils import (
+ create_costed_language_model_response_for_single_result,
+)
+from imbue_core.agents.llm_apis.data_types import CachedCountTokensResponse
+from imbue_core.agents.llm_apis.data_types import CachingInfo
+from imbue_core.agents.llm_apis.data_types import CostedLanguageModelResponse
+from imbue_core.agents.llm_apis.data_types import CountTokensInputs
+from imbue_core.agents.llm_apis.data_types import CountTokensResponse
+from imbue_core.agents.llm_apis.data_types import LanguageModelGenerationParams
+from imbue_core.agents.llm_apis.data_types import LanguageModelResponseUsage
+from imbue_core.agents.llm_apis.data_types import ResponseStopReason
+from imbue_core.agents.llm_apis.errors import BadAPIRequestError
+from imbue_core.agents.llm_apis.errors import LanguageModelInvalidModelNameError
+from imbue_core.agents.llm_apis.errors import MissingAPIKeyError
+from imbue_core.agents.llm_apis.errors import NewSeedRetriableLanguageModelError
+from imbue_core.agents.llm_apis.errors import SafelyRetriableTransientLanguageModelError
+from imbue_core.agents.llm_apis.errors import TransientLanguageModelError
+from imbue_core.agents.llm_apis.errors import UnsetCachePathError
+from imbue_core.agents.llm_apis.language_model_api import LanguageModelAPI
+from imbue_core.agents.llm_apis.models import ModelInfo
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamDeltaEvent
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamEndEvent
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamEvent
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamStartEvent
+from imbue_core.async_monkey_patches import log_exception
+from imbue_core.caching import AsyncCache
+from imbue_core.frozen_utils import FrozenDict
+from imbue_core.frozen_utils import FrozenMapping
+from imbue_core.itertools import only
+from imbue_core.nested_evolver import assign
+from imbue_core.nested_evolver import chill
+from imbue_core.nested_evolver import evolver
+from imbue_core.secrets_utils import get_secret
+
+
+class AnthropicModelName(enum.StrEnum):
+ CLAUDE_3_HAIKU_2024_03_07 = "claude-3-haiku-20240307"
+ CLAUDE_3_OPUS_2024_02_29 = "claude-3-opus-20240229"
+ CLAUDE_3_5_SONNET_2024_06_20 = "claude-3-5-sonnet-20240620"
+ CLAUDE_3_5_SONNET_2024_10_22 = "claude-3-5-sonnet-20241022"
+ CLAUDE_3_5_HAIKU_2024_10_22 = "claude-3-5-haiku-20241022"
+ CLAUDE_3_7_SONNET_2025_02_19 = "claude-3-7-sonnet-20250219"
+ CLAUDE_4_OPUS_2025_05_14 = "claude-opus-4-20250514"
+ CLAUDE_4_1_OPUS_2025_08_05 = "claude-opus-4-1-20250805"
+ CLAUDE_4_SONNET_2025_05_14 = "claude-sonnet-4-20250514"
+ CLAUDE_4_5_SONNET_2025_09_29 = "claude-sonnet-4-5-20250929"
+ CLAUDE_4_5_HAIKU_2025_10_01 = "claude-haiku-4-5-20251001"
+ CLAUDE_4_5_OPUS_2025_11_01 = "claude-opus-4-5-20251101"
+ # the same as above but with the token limit and cost per token for the 1M token limit
+ # TODO: combine these and add ability for token costs to be nonlinear
+ # FIXME: this is an exception where the model name is not the same as the model name in the API
+ CLAUDE_4_SONNET_2025_05_14_LONG = "claude-sonnet-4-20250514-long"
+ CLAUDE_4_5_SONNET_2025_09_29_LONG = "claude-sonnet-4-5-20250929-long"
+
+ # the following are 'retired' and are no longer available: https://docs.claude.com/en/docs/about-claude/model-deprecations
+ # CLAUDE_2_1 = "claude-2.1"
+ # CLAUDE_2 = "claude-2"
+ # CLAUDE_3_SONNET_2024_02_29 = "claude-3-sonnet-20240229"
+
+
+# Basic info is available at https://docs.anthropic.com/claude/reference/models
+# Rate limits for Anthropic models are available on our dashboard: https://console.anthropic.com/settings/limits
+# (we have a custom plan, so the public docs don't reflect our actual rate limits)
+# Prompt caching pricing is available at https://docs.claude.com/en/docs/build-with-claude/prompt-caching#pricing
+# NOTE: as of 2025-06-04, there are some models that don't have rate limits set in our dashboard
+ANTHROPIC_MODEL_INFO_BY_NAME: FrozenMapping[AnthropicModelName, ModelInfo] = FrozenDict(
+ {
+ AnthropicModelName.CLAUDE_3_HAIKU_2024_03_07: ModelInfo(
+ model_name=AnthropicModelName.CLAUDE_3_HAIKU_2024_03_07,
+ cost_per_input_token=0.25 / 1_000_000,
+ cost_per_output_token=1.25 / 1_000_000,
+ max_input_tokens=200_000,
+ max_output_tokens=4096,
+ rate_limit_req=4000 / 60, # 4000 RPM = 66.67 RPS
+ rate_limit_tok=4_000_000 / 60,
+ rate_limit_output_tok=800_000 / 60,
+ provider_specific_info=AnthropicModelInfo(
+ cost_per_5m_cache_write_token=0.3 / 1_000_000,
+ cost_per_1h_cache_write_token=0.5 / 1_000_000,
+ cost_per_cache_read_token=0.03 / 1_000_000,
+ ),
+ ),
+ AnthropicModelName.CLAUDE_3_OPUS_2024_02_29: ModelInfo(
+ model_name=AnthropicModelName.CLAUDE_3_OPUS_2024_02_29,
+ cost_per_input_token=15.00 / 1_000_000,
+ cost_per_output_token=75.00 / 1_000_000,
+ max_input_tokens=200_000,
+ max_output_tokens=4096,
+ rate_limit_req=4000 / 60, # 4000 RPM = 66.67 RPS
+ rate_limit_tok=1_000_000 / 60,
+ rate_limit_output_tok=150_000 / 60,
+ provider_specific_info=AnthropicModelInfo(
+ cost_per_5m_cache_write_token=18.75 / 1_000_000,
+ cost_per_1h_cache_write_token=30 / 1_000_000,
+ cost_per_cache_read_token=1.5 / 1_000_000,
+ ),
+ ),
+ AnthropicModelName.CLAUDE_3_5_SONNET_2024_06_20: ModelInfo(
+ model_name=AnthropicModelName.CLAUDE_3_5_SONNET_2024_06_20,
+ cost_per_input_token=3.00 / 1_000_000,
+ cost_per_output_token=15.00 / 1_000_000,
+ max_input_tokens=200_000,
+ max_output_tokens=4096,
+ rate_limit_req=5000 / 60, # 5000 RPM = 83.33 RPS
+ rate_limit_tok=8_000_000 / 60,
+ rate_limit_output_tok=1_600_000 / 60,
+ provider_specific_info=AnthropicModelInfo(
+ cost_per_5m_cache_write_token=3.75 / 1_000_000,
+ cost_per_1h_cache_write_token=6 / 1_000_000,
+ cost_per_cache_read_token=0.3 / 1_000_000,
+ ),
+ ),
+ AnthropicModelName.CLAUDE_3_5_SONNET_2024_10_22: ModelInfo(
+ model_name=AnthropicModelName.CLAUDE_3_5_SONNET_2024_10_22,
+ cost_per_input_token=3.00 / 1_000_000,
+ cost_per_output_token=15.00 / 1_000_000,
+ max_input_tokens=200_000,
+ max_output_tokens=8192,
+ rate_limit_req=5000 / 60, # 5000 RPM = 83.33 RPS
+ rate_limit_tok=8_000_000 / 60,
+ rate_limit_output_tok=400_000 / 60,
+ ),
+ AnthropicModelName.CLAUDE_3_5_HAIKU_2024_10_22: ModelInfo(
+ model_name=AnthropicModelName.CLAUDE_3_5_HAIKU_2024_10_22,
+ cost_per_input_token=1.00 / 1_000_000,
+ cost_per_output_token=5.00 / 1_000_000,
+ max_input_tokens=200_000,
+ max_output_tokens=8192,
+ rate_limit_req=4000 / 60, # 4000 RPM = 66.67 RPS
+ rate_limit_tok=4_000_000 / 60,
+ rate_limit_output_tok=800_000 / 60,
+ provider_specific_info=AnthropicModelInfo(
+ cost_per_5m_cache_write_token=1 / 1_000_000,
+ cost_per_1h_cache_write_token=1.6 / 1_000_000,
+ cost_per_cache_read_token=0.08 / 1_000_000,
+ ),
+ ),
+ AnthropicModelName.CLAUDE_3_7_SONNET_2025_02_19: ModelInfo(
+ model_name=AnthropicModelName.CLAUDE_3_7_SONNET_2025_02_19,
+ cost_per_input_token=3.00 / 1_000_000,
+ cost_per_output_token=15.00 / 1_000_000,
+ max_input_tokens=200_000,
+ max_output_tokens=8192,
+ rate_limit_req=None, # Currently no limit set in our dashboard
+ rate_limit_tok=2_000_000 / 60,
+ rate_limit_output_tok=400_000 / 60,
+ provider_specific_info=AnthropicModelInfo(
+ cost_per_5m_cache_write_token=3.75 / 1_000_000,
+ cost_per_1h_cache_write_token=6 / 1_000_000,
+ cost_per_cache_read_token=0.3 / 1_000_000,
+ ),
+ ),
+ AnthropicModelName.CLAUDE_4_OPUS_2025_05_14: ModelInfo(
+ model_name=AnthropicModelName.CLAUDE_4_OPUS_2025_05_14,
+ cost_per_input_token=15.00 / 1_000_000,
+ cost_per_output_token=75.00 / 1_000_000,
+ max_input_tokens=200_000,
+ max_output_tokens=32_000,
+ rate_limit_req=4000 / 60,
+ rate_limit_tok=2_000_000 / 60,
+ rate_limit_output_tok=400_000 / 60,
+ provider_specific_info=AnthropicModelInfo(
+ cost_per_5m_cache_write_token=18.75 / 1_000_000,
+ cost_per_1h_cache_write_token=30 / 1_000_000,
+ cost_per_cache_read_token=1.5 / 1_000_000,
+ ),
+ ),
+ AnthropicModelName.CLAUDE_4_1_OPUS_2025_08_05: ModelInfo(
+ model_name=AnthropicModelName.CLAUDE_4_1_OPUS_2025_08_05,
+ cost_per_input_token=15.00 / 1_000_000,
+ cost_per_output_token=75.00 / 1_000_000,
+ max_input_tokens=200_000,
+ max_output_tokens=32_000,
+ rate_limit_req=4000 / 60,
+ rate_limit_tok=2_000_000 / 60,
+ rate_limit_output_tok=400_000 / 60,
+ provider_specific_info=AnthropicModelInfo(
+ cost_per_5m_cache_write_token=18.75 / 1_000_000,
+ cost_per_1h_cache_write_token=30 / 1_000_000,
+ cost_per_cache_read_token=1.5 / 1_000_000,
+ ),
+ ),
+ AnthropicModelName.CLAUDE_4_5_OPUS_2025_11_01: ModelInfo(
+ model_name=AnthropicModelName.CLAUDE_4_5_OPUS_2025_11_01,
+ cost_per_input_token=5.00 / 1_000_000,
+ cost_per_output_token=25.00 / 1_000_000,
+ max_input_tokens=200_000,
+ max_output_tokens=64_000,
+ rate_limit_req=4000 / 60,
+ rate_limit_tok=2_000_000 / 60,
+ rate_limit_output_tok=400_000 / 60,
+ provider_specific_info=AnthropicModelInfo(
+ cost_per_5m_cache_write_token=6.25 / 1_000_000,
+ cost_per_1h_cache_write_token=10 / 1_000_000,
+ cost_per_cache_read_token=0.5 / 1_000_000,
+ ),
+ ),
+ AnthropicModelName.CLAUDE_4_SONNET_2025_05_14: ModelInfo(
+ model_name=AnthropicModelName.CLAUDE_4_SONNET_2025_05_14,
+ cost_per_input_token=3.00 / 1_000_000,
+ cost_per_output_token=15.00 / 1_000_000,
+ max_input_tokens=200_000,
+ max_output_tokens=64_000,
+ rate_limit_req=None, # Currently no limit set in our dashboard
+ rate_limit_tok=2_000_000 / 60,
+ rate_limit_output_tok=400_000 / 60,
+ provider_specific_info=AnthropicModelInfo(
+ cost_per_5m_cache_write_token=3.75 / 1_000_000,
+ cost_per_1h_cache_write_token=6 / 1_000_000,
+ cost_per_cache_read_token=0.3 / 1_000_000,
+ ),
+ ),
+ AnthropicModelName.CLAUDE_4_5_SONNET_2025_09_29: ModelInfo(
+ model_name=AnthropicModelName.CLAUDE_4_5_SONNET_2025_09_29,
+ cost_per_input_token=3.00 / 1_000_000,
+ cost_per_output_token=15.00 / 1_000_000,
+ max_input_tokens=200_000,
+ max_output_tokens=64_000,
+ rate_limit_req=None, # Currently no limit set in our dashboard
+ rate_limit_tok=2_000_000 / 60,
+ rate_limit_output_tok=400_000 / 60,
+ provider_specific_info=AnthropicModelInfo(
+ cost_per_5m_cache_write_token=3.75 / 1_000_000,
+ cost_per_1h_cache_write_token=6 / 1_000_000,
+ cost_per_cache_read_token=0.3 / 1_000_000,
+ ),
+ ),
+ AnthropicModelName.CLAUDE_4_5_HAIKU_2025_10_01: ModelInfo(
+ model_name=AnthropicModelName.CLAUDE_4_5_HAIKU_2025_10_01,
+ cost_per_input_token=1.00 / 1_000_000,
+ cost_per_output_token=5.00 / 1_000_000,
+ max_input_tokens=200_000,
+ max_output_tokens=64_000,
+ rate_limit_req=4_000 / 60,
+ rate_limit_tok=4_000_000 / 60,
+ rate_limit_output_tok=800_000 / 60,
+ provider_specific_info=AnthropicModelInfo(
+ cost_per_5m_cache_write_token=1.25 / 1_000_000,
+ cost_per_1h_cache_write_token=2.0 / 1_000_000,
+ cost_per_cache_read_token=0.1 / 1_000_000,
+ ),
+ ),
+ AnthropicModelName.CLAUDE_4_SONNET_2025_05_14_LONG: ModelInfo(
+ model_name=AnthropicModelName.CLAUDE_4_SONNET_2025_05_14_LONG,
+ # the first 200_000 input tokens use the rates above, and the next up to 800_000 use the rate 6.0 / 1_000_000.
+ # thus the maximum average cost per input token is (3.0 * 200_000 + 6.0 * 800_000) / 1_000_000 = 5.4 per 1_000_000.
+ # (all output tokens may be past 200_000 input tokens, so the max average cost there is just the cost for tokens after 200_000)
+ cost_per_input_token=5.40 / 1_000_000,
+ cost_per_output_token=22.50 / 1_000_000,
+ max_input_tokens=1_000_000,
+ max_output_tokens=64_000,
+ rate_limit_req=None, # Currently no limit set in our dashboard
+ rate_limit_tok=1_000_000 / 60, # <-- yeah they let us have one (1) 1M request per minute
+ rate_limit_output_tok=200_000 / 60,
+ ),
+ AnthropicModelName.CLAUDE_4_5_SONNET_2025_09_29_LONG: ModelInfo(
+ model_name=AnthropicModelName.CLAUDE_4_5_SONNET_2025_09_29_LONG,
+ # the first 200_000 input tokens use the rates above, and the next up to 800_000 use the rate 6.0 / 1_000_000.
+ # thus the maximum average cost per input token is (3.0 * 200_000 + 6.0 * 800_000) / 1_000_000 = 5.4 per 1_000_000.
+ # (all output tokens may be past 200_000 input tokens, so the max average cost there is just the cost for tokens after 200_000)
+ cost_per_input_token=5.40 / 1_000_000,
+ cost_per_output_token=22.50 / 1_000_000,
+ max_input_tokens=1_000_000,
+ max_output_tokens=64_000,
+ rate_limit_req=None, # Currently no limit set in our dashboard
+ rate_limit_tok=1_000_000 / 60, # <-- yeah they let us have one (1) 1M request per minute
+ rate_limit_output_tok=200_000 / 60,
+ ),
+ }
+)
+
+
+_ROLE_TO_ANTHROPIC_ROLE: Final[FrozenMapping[str, str]] = FrozenDict(
+ {
+ "HUMAN": "user",
+ "ASSISTANT": "assistant",
+ "USER": "user",
+ "USER_CACHED": "user",
+ "SYSTEM": "system",
+ "SYSTEM_CACHED": "system",
+ }
+)
+
+_ANTHROPIC_STOP_REASON_TO_STOP_REASON: Final[FrozenMapping[str, ResponseStopReason]] = FrozenDict(
+ {
+ "end_turn": ResponseStopReason.END_TURN,
+ "max_tokens": ResponseStopReason.MAX_TOKENS,
+ "stop_sequence": ResponseStopReason.STOP_SEQUENCE,
+ "refusal": ResponseStopReason.CONTENT_FILTER,
+ }
+)
+
+_ANTHROPIC_BETA_PROMPT_CACHING = "prompt-caching-2024-07-31"
+_ANTHROPIC_BETA_OAUTH = "oauth-2025-04-20"
+
+
+@lru_cache(maxsize=1)
+def get_anthropic_tokenizer() -> tiktoken.Encoding:
+ """Use cl100k_base encoding as an approximation for Claude tokenization.
+
+ Modern Anthropic SDK does not expose a tokenizer directly and instead
+ relies on API calls to count tokens. Using that implementation would
+ put HTTP calls in our `count_tokens` implementation which would be tricky
+ as the method would have to be async or block the event loop.
+
+ Instead, we use tiktoken's cl100k_base encoding (used by GPT-4) as a
+ reasonable approximation. This allows us to count tokens without making
+ HTTP requests at the cost of slightly inaccurate token counts.
+ """
+ return tiktoken.get_encoding("cl100k_base")
+
+
+def count_anthropic_tokens(text: str) -> int:
+ return int(len(get_anthropic_tokenizer().encode(text)) * 1.1)
+
+
+SystemMessageParam = TextBlockParam
+
+
+def _convert_prompt_to_anthropic_messages(
+ prompt: str,
+) -> tuple[list[MessageParam], list[SystemMessageParam] | None]:
+ """Converts a prompt into list of non-system (user/assistant) messages and the optional system prompt."""
+ non_system_messages = []
+ system_messages = []
+ for msg in convert_prompt_to_messages(prompt, is_cache_role_preserved=True):
+ role = _ROLE_TO_ANTHROPIC_ROLE[msg.role]
+ if msg.role == "SYSTEM_CACHED":
+ system_messages.append(
+ {
+ "type": "text",
+ "text": msg.content,
+ "cache_control": {"type": "ephemeral"},
+ },
+ )
+ elif role == "system":
+ system_messages.append(
+ {
+ "type": "text",
+ "text": msg.content,
+ },
+ )
+ elif role == "USER_CACHED":
+ non_system_messages.append(
+ MessageParam( # pyre-fixme[28]: MessageParam doesn't have cache_control
+ content=msg.content,
+ role="user",
+ cache_control=CacheControlEphemeralParam(type="ephemeral"),
+ )
+ )
+ else:
+ non_system_messages.append(MessageParam(content=msg.content, role=role)) # type: ignore
+
+ if len(system_messages) > 1:
+ logger.debug("system_messages: {}", system_messages)
+ raise ValueError(f"Anthropic API supports only 0 or 1 system message; got {len(system_messages)}.")
+
+ if len(non_system_messages) == 0:
+ system_messages = None
+
+ return non_system_messages, system_messages
+
+
+@contextmanager
+def _anthropic_exception_manager() -> Iterator[None]:
+ """Simple context manager for parsing Anthropic API exceptions."""
+ # ref
+ try:
+ yield
+ except anthropic.InternalServerError as e:
+ # this can be caused by either malformed requests or transient errors, so play it safe and retry
+ raise TransientLanguageModelError(str(e)) from e
+ except anthropic.BadRequestError as e:
+ logger.info("BadAPIRequestError {e}", e=e)
+ raise BadAPIRequestError(str(e)) from e
+ except TypeError as e:
+ logger.info("Type error calling Anthropic API: {e}", e=e)
+ raise BadAPIRequestError(str(e)) from e
+ except anthropic.APIConnectionError as e:
+ raise TransientLanguageModelError(str(e)) from e
+ except anthropic.RateLimitError as e:
+ extra_header_keys = [x for x in e.response.headers.keys() if x.startswith("anthropic-")]
+ extra_data = ", ".join([f"{key}={e.response.headers[key]}" for key in extra_header_keys])
+ extra_info = f"Rate limit data: {extra_data}"
+ raise TransientLanguageModelError(extra_info) from e
+ except anthropic.APIStatusError as e:
+ if "overloaded_error" in str(e):
+ raise SafelyRetriableTransientLanguageModelError(str(e)) from e
+ if "internal server error" in str(e).lower():
+ raise SafelyRetriableTransientLanguageModelError(str(e)) from e
+ # this happens when anthropic provides us open source code and then feels bad about it
+ # anthropic.APIStatusError: {'type': 'error', 'error': {'details': None, 'type': 'invalid_request_error', 'message': 'Output blocked by content filtering policy'}}
+ if e.message == "Output blocked by content filtering policy":
+ raise NewSeedRetriableLanguageModelError(e)
+ logger.info(str(e))
+ if e.status_code == 409 or e.status_code >= 500:
+ raise TransientLanguageModelError(str(e)) from e
+ raise
+ except httpx.RemoteProtocolError as e:
+ logger.info(str(e))
+ raise TransientLanguageModelError("httpx.RemoteProtocolError") from e
+ except (BadAPIRequestError, TransientLanguageModelError, MissingAPIKeyError):
+ # we already raised this error ourselves earlier, so we don't need to mark it as unknown
+ raise
+ except Exception as e:
+ # we catch TransientLanguageModelError later to retry it, but we still want to log it so it's not silent
+ log_exception(
+ e,
+ "Failed to generate output from Anthropic, unknown error of type {type_name}",
+ type_name=type(e).__name__,
+ )
+ raise TransientLanguageModelError("Unknown error") from e
+
+
+class MissingCachingInfoError(Exception):
+ pass
+
+
+class AnthropicAPI(LanguageModelAPI):
+ model_name: AnthropicModelName = AnthropicModelName.CLAUDE_4_SONNET_2025_05_14
+ is_conversational: bool = True
+
+ # Anthropic specific args
+ # unclear what the timeout ought to be actually, set to 1 minute for now because their default of 10 minutes seems insane
+ timeout: float = 60.0
+ max_retries: int = 0
+ count_tokens_cache_path: Path | None = None
+
+ @field_validator("model_name") # pyre-ignore[56]: pyre doesn't understand pydantic
+ @classmethod
+ def validate_model_name(cls, v: str) -> str:
+ if v not in ANTHROPIC_MODEL_INFO_BY_NAME:
+ raise LanguageModelInvalidModelNameError(v, cls.__name__, list(ANTHROPIC_MODEL_INFO_BY_NAME))
+ return v
+
+ @property
+ def model_info(self) -> ModelInfo:
+ return ANTHROPIC_MODEL_INFO_BY_NAME[self.model_name]
+
+ def _get_sync_client(self) -> anthropic.Anthropic:
+ api_key, auth_token = _get_api_key_or_auth_token()
+ if api_key:
+ return anthropic.Anthropic(api_key=api_key)
+ else:
+ return anthropic.Anthropic(
+ auth_token=auth_token,
+ default_headers={"anthropic-beta": _ANTHROPIC_BETA_OAUTH},
+ )
+
+ def _get_client(self) -> anthropic.AsyncAnthropic:
+ api_key, auth_token = _get_api_key_or_auth_token()
+ if api_key:
+ return anthropic.AsyncAnthropic(
+ api_key=api_key,
+ max_retries=self.max_retries,
+ timeout=self.timeout,
+ default_headers={"anthropic-beta": _ANTHROPIC_BETA_PROMPT_CACHING},
+ )
+ else:
+ return anthropic.AsyncAnthropic(
+ auth_token=auth_token,
+ max_retries=self.max_retries,
+ timeout=self.timeout,
+ default_headers={"anthropic-beta": f"{_ANTHROPIC_BETA_PROMPT_CACHING},{_ANTHROPIC_BETA_OAUTH}"},
+ )
+
+ async def _call_api(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ network_failure_count: int = 0,
+ ) -> CostedLanguageModelResponse:
+ assert (
+ params.count == 1
+ ), "Anthropic API only supports count=1. It is possible to hack around this by using a for loop, but doesn't seem worth it right now."
+
+ non_system_messages, system_messages = _convert_prompt_to_anthropic_messages(prompt)
+
+ with _anthropic_exception_manager():
+ async with self._get_client() as client:
+ if params.max_tokens is None:
+ # NOTE: anthropic's API REQUIRES you to provide this, if you don't pass it in we just set it to the maximum possible
+
+ # use the evolver method of updating instead
+ # params.max_tokens = self.model_info.max_output_tokens
+ param_with_max_tokens_evolver = evolver(params)
+ assign(
+ param_with_max_tokens_evolver.max_tokens,
+ lambda: self.model_info.max_output_tokens,
+ )
+ params = chill(param_with_max_tokens_evolver)
+ assert params.max_tokens is not None, "max_tokens must be provided for Anthropic API"
+
+ if self.model_name in (
+ AnthropicModelName.CLAUDE_4_5_SONNET_2025_09_29_LONG,
+ AnthropicModelName.CLAUDE_4_SONNET_2025_05_14_LONG,
+ ):
+ # FIXME: Fix this once this is no longer beta or as this becomes required for more models
+ # Map the name back to the actual model name for the API call
+ if self.model_name == AnthropicModelName.CLAUDE_4_5_SONNET_2025_09_29_LONG:
+ model_name = AnthropicModelName.CLAUDE_4_5_SONNET_2025_09_29
+ elif self.model_name == AnthropicModelName.CLAUDE_4_SONNET_2025_05_14_LONG:
+ model_name = AnthropicModelName.CLAUDE_4_SONNET_2025_05_14
+ else:
+ assert False, "unreachable"
+ api_result = await client.beta.messages.create(
+ messages=non_system_messages,
+ stop_sequences=([params.stop] if params.stop is not None else NOT_GIVEN),
+ model=model_name,
+ temperature=params.temperature,
+ system=prepend_claude_code_system_prompt(system_messages),
+ max_tokens=params.max_tokens,
+ betas=["context-1m-2025-08-07"],
+ )
+ detailed_caching_data = AnthropicCachingInfo(
+ written_5m=api_result.usage.cache_creation.ephemeral_5m_input_tokens,
+ written_1h=api_result.usage.cache_creation.ephemeral_1h_input_tokens,
+ )
+ else:
+ api_result = await client.messages.create(
+ messages=non_system_messages,
+ stop_sequences=([params.stop] if params.stop is not None else NOT_GIVEN),
+ model=self.model_name,
+ temperature=params.temperature,
+ system=prepend_claude_code_system_prompt(system_messages),
+ max_tokens=params.max_tokens,
+ )
+ detailed_caching_data = AnthropicCachingInfo(
+ written_5m=api_result.usage.cache_creation.ephemeral_5m_input_tokens,
+ written_1h=api_result.usage.cache_creation.ephemeral_1h_input_tokens,
+ )
+ text = only(api_result.content).text
+ if api_result.stop_reason:
+ stop_reason = _ANTHROPIC_STOP_REASON_TO_STOP_REASON.get(
+ str(api_result.stop_reason), ResponseStopReason.NONE
+ )
+ else:
+ stop_reason = ResponseStopReason.NONE
+ if params.stop and stop_reason == ResponseStopReason.STOP_SEQUENCE:
+ text += params.stop
+ logger.trace(text)
+
+ prompt_tokens = api_result.usage.input_tokens
+ completion_tokens = api_result.usage.output_tokens # type: ignore
+ caching_info = CachingInfo(
+ read_from_cache=api_result.usage.cache_read_input_tokens,
+ provider_specific_data=detailed_caching_data,
+ )
+ dollars_used = self.calculate_cost(prompt_tokens, completion_tokens, caching_info)
+ logger.trace("Dollars used: {dollars_used}", dollars_used=dollars_used)
+
+ return create_costed_language_model_response_for_single_result(
+ text=text,
+ prompt_tokens=prompt_tokens,
+ completion_tokens=completion_tokens,
+ stop_reason=stop_reason,
+ network_failure_count=network_failure_count,
+ dollars_used=dollars_used,
+ caching_info=caching_info,
+ )
+
+ async def _get_api_stream(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ ) -> AsyncGenerator[LanguageModelStreamEvent, None]:
+ non_system_messages, system_messages = _convert_prompt_to_anthropic_messages(prompt)
+ with _anthropic_exception_manager():
+ async with self._get_client() as client:
+ yield LanguageModelStreamStartEvent()
+
+ # NOTE: anthropic's API REQUIRES you to provide this, if you don't pass it in we just set it to the maximum possible
+ max_tokens = params.max_tokens if params.max_tokens is not None else self.model_info.max_output_tokens
+ assert max_tokens is not None, "max_tokens must be provided for Anthropic API"
+
+ if self.model_name in (
+ AnthropicModelName.CLAUDE_4_5_SONNET_2025_09_29_LONG,
+ AnthropicModelName.CLAUDE_4_SONNET_2025_05_14_LONG,
+ ):
+ # FIXME: Fix this once this is no longer beta or as this becomes required for more models
+ # Map the name back to the actual model name for the API call
+ if self.model_name == AnthropicModelName.CLAUDE_4_5_SONNET_2025_09_29_LONG:
+ model_name = AnthropicModelName.CLAUDE_4_5_SONNET_2025_09_29
+ elif self.model_name == AnthropicModelName.CLAUDE_4_SONNET_2025_05_14_LONG:
+ model_name = AnthropicModelName.CLAUDE_4_SONNET_2025_05_14
+ else:
+ assert False, "unreachable"
+ stream_fn = lambda **kwargs: client.beta.messages.stream(**kwargs, betas=["context-1m-2025-08-07"])
+ cache_info_maker = lambda api_result: AnthropicCachingInfo(
+ written_5m=api_result.usage.cache_creation.ephemeral_5m_input_tokens,
+ written_1h=api_result.usage.cache_creation.ephemeral_1h_input_tokens,
+ )
+ else:
+ model_name = self.model_name
+ stream_fn = lambda **kwargs: client.messages.stream(**kwargs)
+ cache_info_maker = lambda api_result: AnthropicCachingInfo(
+ written_5m=api_result.usage.cache_creation.ephemeral_5m_input_tokens,
+ written_1h=api_result.usage.cache_creation.ephemeral_1h_input_tokens,
+ )
+ async with stream_fn(
+ max_tokens=max_tokens,
+ messages=non_system_messages,
+ model=model_name,
+ stop_sequences=([params.stop] if params.stop is not None else NOT_GIVEN),
+ system=system_messages or NOT_GIVEN,
+ temperature=params.temperature,
+ ) as stream:
+ async for text_delta in stream.text_stream:
+ yield LanguageModelStreamDeltaEvent(delta=text_delta)
+
+ final_message = await stream.get_final_message()
+ text = only(final_message.content).text
+ stop_reason = (
+ final_message.stop_reason if final_message.stop_reason is not None else ResponseStopReason.NONE
+ )
+ if params.stop and stop_reason == ResponseStopReason.STOP_SEQUENCE:
+ yield LanguageModelStreamDeltaEvent(delta=params.stop)
+ text += params.stop
+ logger.trace(text)
+
+ prompt_tokens = final_message.usage.input_tokens
+ # useful to confirm that the cache is actually being hit
+ logger.debug(
+ "Used this many cached read tokens: {cached_tokens}",
+ cached_tokens=final_message.usage.cache_read_input_tokens,
+ )
+ completion_tokens = final_message.usage.output_tokens
+ caching_info = CachingInfo(
+ read_from_cache=final_message.usage.cache_read_input_tokens,
+ provider_specific_data=cache_info_maker(final_message),
+ )
+ dollars_used = self.calculate_cost(prompt_tokens, completion_tokens, caching_info)
+
+ if final_message.stop_reason:
+ stop_reason = _ANTHROPIC_STOP_REASON_TO_STOP_REASON.get(
+ str(final_message.stop_reason), ResponseStopReason.NONE
+ )
+ else:
+ stop_reason = ResponseStopReason.NONE
+
+ logger.trace("Dollars used: {dollars_used}", dollars_used=dollars_used)
+ yield LanguageModelStreamEndEvent(
+ usage=LanguageModelResponseUsage(
+ prompt_tokens_used=prompt_tokens,
+ completion_tokens_used=completion_tokens,
+ dollars_used=dollars_used,
+ caching_info=caching_info,
+ ),
+ stop_reason=stop_reason,
+ )
+
+ def count_tokens(self, text: str) -> int:
+ return count_anthropic_tokens(text)
+
+ def get_count_tokens_response_cache(self) -> AsyncCache[CachedCountTokensResponse]:
+ if self.count_tokens_cache_path is None:
+ raise UnsetCachePathError()
+ return AsyncCache(self.count_tokens_cache_path, CachedCountTokensResponse)
+
+ async def check_count_tokens_cache(self, cache_key: str) -> CountTokensResponse | None:
+ return await self.check_cache_core(self.get_count_tokens_response_cache, cache_key)
+
+ async def _get_from_count_tokens_cache(
+ self, frame: FrameType | None
+ ) -> tuple[str | None, CountTokensResponse | None]:
+ return await self._get_from_cache_core(frame, lambda cr: cr, self.check_count_tokens_cache)
+
+ async def count_tokens_api(self, prompt: str, is_caching_enabled: bool) -> int:
+ """
+ Call the count tokens API. This API is free, so we don't ensure resource limits before calling it.
+ There are rate limits though: https://docs.anthropic.com/en/docs/build-with-claude/token-counting#pricing-and-rate-limits
+ """
+
+ self.assert_caching_enabled_if_offline(is_caching_enabled)
+
+ frame: FrameType | None = None
+ if is_caching_enabled:
+ frame = inspect.currentframe()
+
+ cache_key: str | None = None
+ if is_caching_enabled:
+ cache_key, cached_response = await self._get_from_count_tokens_cache(frame)
+
+ if cached_response is not None:
+ return cached_response.input_tokens
+
+ self.assert_not_offline_if_cache_miss(prompt)
+
+ non_system_messages, system_messages = _convert_prompt_to_anthropic_messages(prompt)
+
+ with _anthropic_exception_manager():
+ async with self._get_client() as client:
+ raw_response = await client.messages.count_tokens(
+ model=self.model_info.model_name,
+ messages=non_system_messages,
+ system=system_messages,
+ )
+
+ response = CountTokensResponse(input_tokens=raw_response.input_tokens)
+ result = CachedCountTokensResponse(
+ response=response,
+ inputs=(
+ CountTokensInputs(model=self.model_info.model_name, prompt=prompt)
+ if self.is_caching_inputs
+ else None
+ ),
+ )
+
+ if is_caching_enabled:
+ assert cache_key is not None
+ async with self.get_count_tokens_response_cache() as cache:
+ await cache.set(cache_key, result)
+
+ return response.input_tokens
+
+ def calculate_cost(
+ self,
+ prompt_tokens: int,
+ completion_tokens: int,
+ caching_info: CachingInfo | None = None,
+ ) -> float:
+ try:
+ # find the cost for the prompt, broken down into cache writes and regular input tokens
+
+ # if we don't have the caching info, use the basic cost model (we catch the error below)
+ if (
+ caching_info is None
+ or caching_info.provider_specific_data is None
+ or self.model_info.provider_specific_info is None
+ ):
+ raise MissingCachingInfoError(
+ f"Missing required info for more precise cost estimates; caching info: {caching_info}, model info: {self.model_info.provider_specific_info}"
+ )
+ anthropic_caching_usage = caching_info.provider_specific_data
+ assert isinstance(anthropic_caching_usage, AnthropicCachingInfo), "Expected AnthropicCachingInfo"
+ anthropic_caching_rates = self.model_info.provider_specific_info
+ assert isinstance(anthropic_caching_rates, AnthropicModelInfo), "Expected AnthropicModelInfo"
+ cache_write_5m_tokens = anthropic_caching_usage.written_5m
+ cache_write_1h_tokens = anthropic_caching_usage.written_1h
+ cache_read_tokens = caching_info.read_from_cache
+ regular_input_tokens = prompt_tokens - cache_write_5m_tokens - cache_write_1h_tokens
+
+ input_cost = (
+ cache_write_5m_tokens * anthropic_caching_rates.cost_per_5m_cache_write_token
+ + cache_write_1h_tokens * anthropic_caching_rates.cost_per_1h_cache_write_token
+ + cache_read_tokens * anthropic_caching_rates.cost_per_cache_read_token
+ + regular_input_tokens * self.model_info.cost_per_input_token
+ )
+
+ output_cost = completion_tokens * self.model_info.cost_per_output_token
+
+ return input_cost + output_cost
+
+ except MissingCachingInfoError as e:
+ logger.info("{}; using basic cost model", e)
+ return self.basic_calculate_cost(prompt_tokens, completion_tokens)
+
+
+def _get_api_key_or_auth_token() -> tuple[str | None, str | None]:
+ api_key = get_secret("ANTHROPIC_API_KEY")
+ # The standard environment variable for this is ANTHROPIC_AUTH_TOKEN,
+ # but we don't use it since it has some bad interactions with Claude Code.
+ auth_token = get_secret("IMBUE_ANTHROPIC_AUTH_TOKEN")
+ if not api_key and not auth_token:
+ raise MissingAPIKeyError(
+ "Neither of ANTHROPIC_API_KEY and IMBUE_ANTHROPIC_AUTH_TOKEN environment variables is set"
+ )
+ return api_key, auth_token
+
+
+_CLAUDE_CODE_SYSTEM_PROMPT = TextBlockParam(
+ type="text",
+ text="You are Claude Code, Anthropic's official CLI for Claude.",
+ cache_control=CacheControlEphemeralParam(type="ephemeral"),
+)
+
+
+def prepend_claude_code_system_prompt(
+ system_prompt: str | list[TextBlockParam] | None,
+) -> list[TextBlockParam]:
+ """Prepends the system prompt used by Claude Code.
+
+ When using the Claude API through Claude Pro/Max subscriptions,
+ the Claude API requires this particular system prompt to be set;
+ otherwise the request will fail.
+
+ For simplicity and consistency,
+ we always do this even when it's not strictly required,
+ (like when using the Claude API through API keys).
+ """
+ if not system_prompt:
+ return [_CLAUDE_CODE_SYSTEM_PROMPT]
+ elif isinstance(system_prompt, str):
+ return [
+ _CLAUDE_CODE_SYSTEM_PROMPT,
+ TextBlockParam(type="text", text=system_prompt),
+ ]
+ else:
+ return [_CLAUDE_CODE_SYSTEM_PROMPT] + system_prompt
diff --git a/imbue_core/imbue_core/agents/llm_apis/anthropic_data_types.py b/imbue_core/imbue_core/agents/llm_apis/anthropic_data_types.py
@@ -0,0 +1,15 @@
+from imbue_core.pydantic_serialization import SerializableModel
+
+
+class AnthropicModelInfo(SerializableModel):
+ object_type: str = "AnthropicModelInfo"
+ cost_per_5m_cache_write_token: float
+ cost_per_1h_cache_write_token: float
+ cost_per_cache_read_token: float
+
+
+class AnthropicCachingInfo(SerializableModel):
+ object_type: str = "AnthropicCachingInfo"
+ # record info on cache writes for 5 minute and 1 hour durations
+ written_5m: int
+ written_1h: int
diff --git a/imbue_core/imbue_core/agents/llm_apis/api_utils.py b/imbue_core/imbue_core/agents/llm_apis/api_utils.py
@@ -0,0 +1,110 @@
+from typing import Final
+from typing import Iterable
+
+from loguru import logger
+
+from imbue_core.agents.llm_apis.data_types import CachingInfo
+from imbue_core.agents.llm_apis.data_types import ConversationMessage
+from imbue_core.agents.llm_apis.data_types import CostedLanguageModelResponse
+from imbue_core.agents.llm_apis.data_types import LanguageModelResponse
+from imbue_core.agents.llm_apis.data_types import LanguageModelResponseUsage
+from imbue_core.agents.llm_apis.data_types import LanguageModelResponseWithThoughts
+from imbue_core.agents.llm_apis.data_types import ResponseStopReason
+from imbue_core.agents.llm_apis.data_types import ThoughtResponse
+from imbue_core.frozen_utils import FrozenDict
+from imbue_core.frozen_utils import FrozenMapping
+
+_ROLE_TO_OPENAI_ROLE: Final[FrozenMapping] = FrozenDict(
+ {
+ "HUMAN": "user",
+ "ASSISTANT": "assistant",
+ "SYSTEM": "system",
+ "USER": "user",
+ "SYSTEM_CACHED": "system",
+ "USER_CACHED": "user",
+ }
+)
+
+
+def convert_prompt_to_messages(prompt: str, is_cache_role_preserved: bool = False) -> tuple[ConversationMessage, ...]:
+ messages = []
+ for raw_message in convert_prompt_to_openai_messages(prompt, is_cache_role_preserved):
+ messages.append(ConversationMessage(role=raw_message["role"].upper(), content=raw_message["content"]))
+ return tuple(messages)
+
+
+def convert_messages_to_prompt_template(messages: Iterable[ConversationMessage]) -> str:
+ return "\n".join(f"[ROLE={message.role.upper()}]\n{message.content}" for message in messages)
+
+
+def create_costed_language_model_response_for_single_result(
+ text: str,
+ prompt_tokens: int,
+ completion_tokens: int,
+ stop_reason: ResponseStopReason,
+ network_failure_count: int,
+ dollars_used: float,
+ thoughts: ThoughtResponse | None = None,
+ caching_info: CachingInfo | None = None,
+) -> CostedLanguageModelResponse:
+ logger.trace("dollars used: {}", dollars_used)
+ logger.trace("completion_tokens_used used: {}", completion_tokens)
+ if thoughts is None:
+ result = LanguageModelResponse(
+ text=text,
+ token_count=completion_tokens + prompt_tokens,
+ stop_reason=stop_reason,
+ network_failure_count=network_failure_count,
+ )
+ else:
+ result = LanguageModelResponseWithThoughts(
+ text=text,
+ token_count=completion_tokens + prompt_tokens,
+ stop_reason=stop_reason,
+ network_failure_count=network_failure_count,
+ thoughts=thoughts,
+ )
+
+ return CostedLanguageModelResponse(
+ usage=LanguageModelResponseUsage(
+ prompt_tokens_used=prompt_tokens,
+ completion_tokens_used=completion_tokens,
+ dollars_used=dollars_used,
+ caching_info=caching_info,
+ ),
+ responses=(result,),
+ )
+
+
+# FIXME: we should make sure that all our LLM providers use the same function here, some clean up is required
+def convert_prompt_to_openai_messages(prompt: str, is_cache_role_preserved: bool = False) -> list[dict[str, str]]:
+ prompt = prompt.lstrip()
+ assert prompt.startswith("[ROLE=")
+ prompt = prompt.replace("[ROLE=", "", 1)
+ chunks = prompt.split("\n[ROLE=")
+ messages: list[dict[str, str]] = []
+ for chunk in chunks:
+ lines = chunk.split("\n")
+ role = lines[0].strip().rstrip("]")
+ assert role in (
+ "HUMAN",
+ "ASSISTANT",
+ "USER",
+ "SYSTEM",
+ "SYSTEM_CACHED",
+ "USER_CACHED",
+ ), f"Unknown role {role} in prompt {prompt}"
+ lines.pop(0)
+ if role == "HUMAN":
+ role = "USER"
+ if len(messages) > 0:
+ messages[-1]["content"] = messages[-1]["content"] + "\n"
+ content = "\n".join(lines)
+ content = content.rstrip()
+ fixed_role = _ROLE_TO_OPENAI_ROLE[role]
+ if is_cache_role_preserved and role == "SYSTEM_CACHED":
+ fixed_role = "SYSTEM_CACHED"
+ elif is_cache_role_preserved and role == "USER_CACHED":
+ fixed_role = "USER_CACHED"
+ messages.append({"role": fixed_role, "content": content})
+ return messages
diff --git a/imbue_core/imbue_core/agents/llm_apis/build_apis.py b/imbue_core/imbue_core/agents/llm_apis/build_apis.py
@@ -0,0 +1,128 @@
+from pathlib import Path
+
+from imbue_core.agents.configs import LanguageModelGenerationConfig
+from imbue_core.agents.configs import MockedLanguageModelGenerationConfig
+from imbue_core.agents.configs import OpenAICompatibleModelConfig
+from imbue_core.agents.llm_apis.anthropic_api import AnthropicAPI
+from imbue_core.agents.llm_apis.anthropic_api import AnthropicModelName
+from imbue_core.agents.llm_apis.constants import approximate_token_count
+from imbue_core.agents.llm_apis.gemini_api import GeminiAPI
+from imbue_core.agents.llm_apis.gemini_api import GeminiModelName
+from imbue_core.agents.llm_apis.groq_api import GroqChatAPI
+from imbue_core.agents.llm_apis.groq_api import GroqSupportedModelName
+from imbue_core.agents.llm_apis.language_model_api import LanguageModelAPI
+from imbue_core.agents.llm_apis.mock_api import FileBasedLanguageModelMock
+from imbue_core.agents.llm_apis.mock_api import MockModelName
+from imbue_core.agents.llm_apis.openai_api import OpenAIChatAPI
+from imbue_core.agents.llm_apis.openai_api import OpenAIModelName
+from imbue_core.agents.llm_apis.openai_compatible_api import OpenAICompatibleAPI
+from imbue_core.agents.llm_apis.together_api import TogetherAIModelName
+from imbue_core.agents.llm_apis.together_api import TogetherAPI
+
+
+def build_language_model_from_config(
+ config: LanguageModelGenerationConfig,
+) -> LanguageModelAPI:
+ if isinstance(config, MockedLanguageModelGenerationConfig):
+ return FileBasedLanguageModelMock(cache_path=config.mock_responses_path)
+
+ if isinstance(config, OpenAICompatibleModelConfig):
+ return OpenAICompatibleAPI(
+ model_name=config.model_name,
+ base_url=config.custom_base_url,
+ api_key_env=config.custom_api_key_env,
+ context_window=config.custom_context_window,
+ max_output_tokens=config.custom_max_output_tokens,
+ cache_path=config.cache_path,
+ is_caching_inputs=config.is_caching_inputs,
+ is_running_offline=config.is_running_offline,
+ is_conversational=True,
+ retry_jitter_factor=config.retry_jitter_factor,
+ )
+
+ if config.model_name in (v for v in MockModelName):
+ return FileBasedLanguageModelMock(cache_path=config.cache_path)
+ if config.model_name in (v for v in OpenAIModelName):
+ return OpenAIChatAPI(
+ model_name=config.model_name,
+ cache_path=config.cache_path,
+ is_caching_inputs=config.is_caching_inputs,
+ is_running_offline=config.is_running_offline,
+ is_conversational=True,
+ is_using_logprobs=config.is_using_logprobs,
+ retry_jitter_factor=config.retry_jitter_factor,
+ )
+ if config.model_name in (v for v in GroqSupportedModelName):
+ return GroqChatAPI(
+ model_name=config.model_name,
+ cache_path=config.cache_path,
+ is_caching_inputs=config.is_caching_inputs,
+ is_running_offline=config.is_running_offline,
+ is_conversational=True,
+ is_using_logprobs=config.is_using_logprobs,
+ retry_jitter_factor=config.retry_jitter_factor,
+ )
+ if config.model_name in (v for v in AnthropicModelName):
+ return AnthropicAPI(
+ model_name=config.model_name,
+ cache_path=config.cache_path,
+ count_tokens_cache_path=config.count_tokens_cache_path,
+ is_caching_inputs=config.is_caching_inputs,
+ is_running_offline=config.is_running_offline,
+ is_conversational=True,
+ is_using_logprobs=config.is_using_logprobs,
+ retry_jitter_factor=config.retry_jitter_factor,
+ )
+ if config.model_name in (v for v in TogetherAIModelName):
+ return TogetherAPI(
+ model_name=config.model_name,
+ cache_path=config.cache_path,
+ # count tokens is not supported for Together API
+ # count_tokens_cache_path=config.count_tokens_cache_path,
+ is_caching_inputs=config.is_caching_inputs,
+ is_running_offline=config.is_running_offline,
+ is_conversational=True,
+ is_using_logprobs=config.is_using_logprobs,
+ retry_jitter_factor=config.retry_jitter_factor,
+ )
+ if config.model_name in (v for v in GeminiModelName):
+ return GeminiAPI(
+ model_name=config.model_name,
+ cache_path=config.cache_path,
+ count_tokens_cache_path=config.count_tokens_cache_path,
+ is_caching_inputs=config.is_caching_inputs,
+ is_running_offline=config.is_running_offline,
+ is_conversational=True,
+ is_using_logprobs=config.is_using_logprobs,
+ retry_jitter_factor=config.retry_jitter_factor,
+ )
+ # if config.model_name in MISTRAL_CHAT_MODEL_NAMES:
+ # return MistralChatAPI(
+ # model_name=config.model_name,
+ # cache_path=config.cache_path,
+ # is_conversational=True,
+ # )
+
+ raise NotImplementedError(f"{config.model_name} not supported by LanguageModelAPI")
+
+
+def build_language_model_by_name(
+ model_name: str,
+ cache_path: Path | None = None,
+ is_caching_inputs: bool = False,
+ is_using_logprobs: bool = False,
+) -> LanguageModelAPI:
+ config = LanguageModelGenerationConfig(
+ model_name=model_name,
+ cache_path=cache_path,
+ is_caching_inputs=is_caching_inputs,
+ is_using_logprobs=is_using_logprobs,
+ )
+ return build_language_model_from_config(config)
+
+
+def get_token_count_for_text_and_model(text: str, model_name: str) -> int:
+ try:
+ return build_language_model_by_name(model_name).count_tokens(text)
+ except NotImplementedError:
+ return approximate_token_count(text)
diff --git a/imbue_core/imbue_core/agents/llm_apis/common.py b/imbue_core/imbue_core/agents/llm_apis/common.py
@@ -0,0 +1,73 @@
+from imbue_core.agents.llm_apis.anthropic_api import ANTHROPIC_MODEL_INFO_BY_NAME
+from imbue_core.agents.llm_apis.anthropic_api import AnthropicModelName
+from imbue_core.agents.llm_apis.gemini_api import GEMINI_MODEL_INFO_BY_NAME
+from imbue_core.agents.llm_apis.gemini_api import GeminiModelName
+from imbue_core.agents.llm_apis.groq_api import GroqSupportedModelName
+from imbue_core.agents.llm_apis.groq_api import get_model_info as get_groq_model_info
+from imbue_core.agents.llm_apis.mock_api import MY_MOCK_MODEL_INFO
+from imbue_core.agents.llm_apis.models import ModelInfo
+from imbue_core.agents.llm_apis.openai_api import OpenAIModelName
+from imbue_core.agents.llm_apis.openai_api import (
+ get_model_info as get_openai_model_info,
+)
+from imbue_core.agents.llm_apis.together_api import TOGETHERAI_MODEL_INFO_BY_NAME
+from imbue_core.agents.llm_apis.together_api import TogetherAIModelName
+
+ModelName = AnthropicModelName | OpenAIModelName | GroqSupportedModelName | TogetherAIModelName | GeminiModelName
+
+
+def get_model_info_from_name(model_name: str) -> ModelInfo:
+ if model_name == MY_MOCK_MODEL_INFO.model_name:
+ return MY_MOCK_MODEL_INFO
+ if model_name in (v for v in AnthropicModelName):
+ return ANTHROPIC_MODEL_INFO_BY_NAME[AnthropicModelName(model_name)]
+ elif model_name in (v for v in OpenAIModelName):
+ return get_openai_model_info(OpenAIModelName(model_name))
+ elif model_name in (v for v in GroqSupportedModelName):
+ return get_groq_model_info(GroqSupportedModelName(model_name))
+ elif model_name in (v for v in TogetherAIModelName):
+ return TOGETHERAI_MODEL_INFO_BY_NAME[TogetherAIModelName(model_name)]
+ elif model_name in (v for v in GeminiModelName):
+ return GEMINI_MODEL_INFO_BY_NAME[GeminiModelName(model_name)]
+ else:
+ raise Exception(f"Unknown model: {model_name}")
+
+
+def get_model_max_context_length(model_name: str) -> int:
+ model_info = get_model_info_from_name(model_name)
+ return model_info.max_input_tokens
+
+
+def get_model_max_output_tokens(model_name: str) -> int:
+ model_info = get_model_info_from_name(model_name)
+ if model_info.max_output_tokens is None:
+ raise ValueError(f"Model {model_name} does not have max_output_tokens defined")
+ return model_info.max_output_tokens
+
+
+def get_all_model_names() -> list[str]:
+ names = []
+ names.extend(list(v for v in AnthropicModelName))
+ names.extend(list(v for v in OpenAIModelName))
+ names.extend(list(v for v in GroqSupportedModelName))
+ names.extend(list(v for v in TogetherAIModelName))
+ names.extend(list(v for v in GeminiModelName))
+ return names
+
+
+def get_formatted_model_name(model_name: str) -> str:
+ """Get a nicely formatted model name.
+
+ Does things like removing generic prefixes like 'models/' and forward slashes (which can interfere with file names).
+
+ Some examples:
+
+ - `models/gemini-1.5-flash-001` -> `gemini-1.5-flash-001`
+ - 'groq/llama-3.3-70b-versatile' -> 'groq-llama-3.3-70b-versatile'
+ - 'claude-3-5-haiku-20241022' -> 'claude-3-5-haiku-20241022'
+ - 'together/google/gemma-2-27b-it' -> 'together-google-gemma-2-27b-it'
+
+ """
+ if model_name.startswith("models/"):
+ model_name = model_name[len("models/") :]
+ return model_name.replace("/", "-")
diff --git a/imbue_core/imbue_core/agents/llm_apis/constants.py b/imbue_core/imbue_core/agents/llm_apis/constants.py
@@ -0,0 +1,16 @@
+HUMAN_ROLE = "HUMAN"
+ASSISTANT_ROLE = "ASSISTANT"
+USER_ROLE = "USER"
+SYSTEM_ROLE = "SYSTEM"
+
+
+def approximate_token_count(text: str) -> int:
+ """Approximate token count using a fixed characters-per-token ratio.
+
+ This is used for custom/user-defined models where we don't have access to the actual tokenizer.
+ The ratio of 4.5 characters per token is a reasonable empirical estimate for most LLMs.
+
+ In the future, it might be useful to allow users to configure this ratio per-model in models.json,
+ but for now we use a single hardcoded value for simplicity.
+ """
+ return round(len(text) / 4.5)
diff --git a/imbue_core/imbue_core/agents/llm_apis/data_types.py b/imbue_core/imbue_core/agents/llm_apis/data_types.py
@@ -0,0 +1,214 @@
+import datetime
+import enum
+import math
+from abc import ABC
+from typing import Any
+from typing import Generic
+from typing import TypeVar
+
+import attr
+from pydantic import ValidationInfo
+from pydantic import field_validator
+
+from imbue_core.agents.llm_apis.union_data_types import ProviderSpecificCachingInfoUnion
+from imbue_core.pydantic_serialization import SerializableModel
+from imbue_core.serialization_types import Serializable
+from imbue_core.time_utils import get_current_time
+
+__all__ = [
+ "CachedCostedLanguageModelResponse",
+ "ConversationMessage",
+ "CostedLanguageModelResponse",
+ "LanguageModelCompleteInputs",
+ "LanguageModelResponse",
+ "LanguageModelResponseUsage",
+ "LanguageModelResponseWithLogits",
+ "LanguageModelResponseWithThoughts",
+ "LanguageModelStreamInputs",
+ "ModelStr",
+ "ResponseStopReason",
+ "TokenProbability",
+]
+
+
+class ConversationMessage(SerializableModel):
+ role: str
+ content: str
+
+
+class ResponseStopReason(enum.StrEnum):
+ END_TURN = "end_turn"
+ MAX_TOKENS = "max_tokens"
+ STOP_SEQUENCE = "stop_sequence"
+ ERROR = "error"
+ NONE = "none"
+ # TODO: We aren't handling any of the below, we should likely error in these cases
+ CONTENT_FILTER = "content_filter"
+ TOOL_CALLS = "tool_calls"
+ FUNCTION_CALL = "function_call"
+
+ def response_not_finished(self) -> bool:
+ return self in {self.CONTENT_FILTER, self.MAX_TOKENS, self.ERROR}
+
+
+class TokenProbability(SerializableModel):
+ token: str
+ log_probability: float
+ is_stop: bool
+
+ @property
+ def probability(self) -> float:
+ return math.exp(self.log_probability)
+
+
+class ModelResponse(ABC):
+ pass
+
+
+class ThoughtResponse(SerializableModel, ModelResponse):
+ text: str
+ completion_tokens: int
+
+
+@attr.s(auto_attribs=True, frozen=True)
+class LanguageModelResponse(Serializable, ModelResponse):
+ text: str
+ token_count: int
+ stop_reason: ResponseStopReason
+ network_failure_count: int
+
+ def get_token_probability_sequence(
+ self,
+ ) -> tuple[tuple[TokenProbability, ...], ...] | None:
+ return None
+
+
+@attr.s(auto_attribs=True, frozen=True)
+class LanguageModelResponseWithThoughts(LanguageModelResponse):
+ thoughts: ThoughtResponse | None = None
+
+
+@attr.s(auto_attribs=True, frozen=True)
+class LanguageModelResponseWithLogits(LanguageModelResponse):
+ # guarantees that the first in each sequence was the one that was selected.
+ # the inner sequence are *not* guaranteed to be the same length, nor are they guaranteed to be sorted
+ token_probabilities: tuple[tuple[TokenProbability, ...], ...]
+
+ def get_token_probability_sequence(
+ self,
+ ) -> tuple[tuple[TokenProbability, ...], ...] | None:
+ return self.token_probabilities
+
+
+@attr.s(auto_attribs=True, frozen=True)
+class CountTokensResponse(Serializable, ModelResponse):
+ input_tokens: int
+ cached_content_token_count: int | None = None
+
+
+class CachingInfo(SerializableModel):
+ read_from_cache: int
+
+ # this should contain info that's not the same between providers. e.g. anthropic requires explicit cache writes with 5m or 1h duration,
+ # whereas openai does automatic prompt caching at no extra cost; so, we store cache write info here
+ provider_specific_data: ProviderSpecificCachingInfoUnion | None = None
+
+
+class LanguageModelResponseUsage(SerializableModel):
+ prompt_tokens_used: int
+ completion_tokens_used: int
+ dollars_used: float
+ caching_info: CachingInfo | None = None
+
+
+@attr.s(auto_attribs=True, frozen=True)
+class CostedLanguageModelResponse(Serializable, ModelResponse):
+ usage: LanguageModelResponseUsage
+ responses: tuple[LanguageModelResponse, ...]
+
+
+class ThinkConfig(SerializableModel):
+ # watch out: at least for gemini, this is a soft limit!
+ max_tokens: int | None = None
+ output_thinking: bool = False
+
+
+class LanguageModelGenerationParams(SerializableModel):
+ """Parameters for a single API call to an LLM. Excludes things that you don't want a default for, e.g. the prompt."""
+
+ temperature: float = 0.2
+ count: int = 1
+ max_tokens: int | None = None
+ stop: str | None = None
+ # specifically to allow generating new responses even when using caching
+ seed: int | None = None
+ thinking: ThinkConfig | None = None
+
+
+class ModelInputs(SerializableModel, ABC):
+ """Base class for inputs to an LLM API call."""
+
+
+class LanguageModelCompleteInputs(ModelInputs):
+ """Used to serialize the inputs for an LLM complete call."""
+
+ prompt: str
+ params: LanguageModelGenerationParams
+ network_failure_count: int
+
+
+class LanguageModelStreamInputs(ModelInputs):
+ """Used to serialize the inputs for an LLM stream call."""
+
+ prompt: str
+ params: LanguageModelGenerationParams
+
+
+class CountTokensInputs(ModelInputs):
+ """Used to serialize the inputs for a token count call."""
+
+ model: str
+ prompt: str
+
+
+InputsT = TypeVar("InputsT", bound=ModelInputs)
+ModelResponseT = TypeVar("ModelResponseT", bound=ModelResponse)
+
+
+@attr.s(auto_attribs=True, frozen=True)
+class CachedCostedModelResponse(Serializable, Generic[InputsT, ModelResponseT]):
+ response: ModelResponseT | None = None
+ error: str | None = None
+
+ # The timestamp is used to order cache entries when checking them in unit tests.
+ timestamp: datetime.datetime = attr.ib(factory=get_current_time)
+
+ # Cache entries are keyed based on an MD5 hash of the inputs to prevent the cache from growing too large.
+ # Here, we optionally store the inputs to the request.
+ # This is useful for unit tests to highlight changes in the prompt and other parts of the request.
+ # But since it can grow very large, we don't always store this information.
+ inputs: InputsT | None = None
+
+ @field_validator("response", "error")
+ def validate_response_or_error(cls, v: Any, info: ValidationInfo) -> Any:
+ if "response" in info.data and "error" in info.data:
+ if not ((info.data["response"] is None) ^ (info.data["error"] is None)):
+ raise ValueError("Must provide exactly one of response or error")
+ return v
+
+
+class CachedCostedLanguageModelResponse(
+ CachedCostedModelResponse[
+ LanguageModelCompleteInputs | LanguageModelStreamInputs,
+ CostedLanguageModelResponse,
+ ]
+):
+ pass
+
+
+class CachedCountTokensResponse(CachedCostedModelResponse[CountTokensInputs, CountTokensResponse]):
+ pass
+
+
+# to allow type checking to work when model names are passed as strings
+ModelStr = str
diff --git a/imbue_core/imbue_core/agents/llm_apis/errors.py b/imbue_core/imbue_core/agents/llm_apis/errors.py
@@ -0,0 +1,111 @@
+import abc
+from typing import Self
+
+
+class CachedException(Exception, abc.ABC):
+ """An exception that is stored in an LLM API cache.
+
+ Provides convenience methods for storing and loading str representation for
+ more efficient caching.
+ """
+
+ @classmethod
+ @abc.abstractmethod
+ def from_string(cls, data: str) -> Self: ...
+
+ @abc.abstractmethod
+ def to_string(self) -> str: ...
+
+
+SPLIT_TOKEN: str = "|"
+
+
+class PromptTooLongError(CachedException):
+ """Exception raised when prompt too long for model context window size.
+
+ We should cache these since no point trying same prompt again.
+ """
+
+ def __init__(self, prompt_len: int, max_prompt_len: int) -> None:
+ self.prompt_len = prompt_len
+ self.max_prompt_len = max_prompt_len
+
+ @classmethod
+ def from_string(cls, data: str) -> Self:
+ prompt_len, max_prompt_len = data.split(SPLIT_TOKEN)[1:3]
+ return cls(int(prompt_len), int(max_prompt_len))
+
+ def to_string(self) -> str:
+ string = SPLIT_TOKEN.join([self.__class__.__name__, str(self.prompt_len), str(self.max_prompt_len)])
+ return string
+
+ @property
+ def required_reduction_fraction(self) -> float:
+ return self.max_prompt_len / self.prompt_len
+
+
+class BadAPIRequestError(CachedException):
+ """Exception raised when request invalid (e.g. bad formatting, too long).
+
+ Basically all miscellaneous errors due to input. We should cache these since no point trying same prompt again.
+ """
+
+ def __init__(self, error_message: str) -> None:
+ self.error_message = error_message
+
+ @classmethod
+ def from_string(cls, data: str) -> Self:
+ error_message = data.split(SPLIT_TOKEN)[1]
+ return cls(error_message)
+
+ def to_string(self) -> str:
+ return SPLIT_TOKEN.join([self.__class__.__name__, self.error_message])
+
+
+class UnsetCachePathError(Exception):
+ def __init__(self) -> None:
+ super().__init__(
+ "Cache path must be specified in model config if you want to use model prompt-response caching. Caching can be disabled by setting `is_caching_enabled=False` when calling the LanguageModelAPI."
+ )
+
+
+class LanguageModelError(Exception):
+ pass
+
+
+class MissingAPIKeyError(LanguageModelError):
+ pass
+
+
+class RetriableLanguageModelError(LanguageModelError):
+ pass
+
+
+class TransientLanguageModelError(RetriableLanguageModelError):
+ pass
+
+
+class SafelyRetriableTransientLanguageModelError(TransientLanguageModelError):
+ pass
+
+
+class NewSeedRetriableLanguageModelError(RetriableLanguageModelError):
+ pass
+
+
+class LanguageModelRetryLimitError(Exception):
+ pass
+
+
+class LanguageModelInvalidModelNameError(ValueError):
+ """Exception raised when an invalid model name is provided to a language model API."""
+
+ def __init__(self, model_name: str, api_class_name: str, available_models: list[str]) -> None:
+ self.model_name = model_name
+ self.api_class_name = api_class_name
+ self.available_models = available_models
+
+ message = (
+ f"Model with name={model_name} not available for {api_class_name}. Available models: {available_models}."
+ )
+ super().__init__(message)
diff --git a/imbue_core/imbue_core/agents/llm_apis/gemini_api.py b/imbue_core/imbue_core/agents/llm_apis/gemini_api.py
@@ -0,0 +1,526 @@
+import enum
+import inspect
+from contextlib import contextmanager
+from pathlib import Path
+from types import FrameType
+from typing import AsyncGenerator
+from typing import Callable
+from typing import Final
+from typing import Iterable
+from typing import Iterator
+from typing import TypeVar
+
+import google.genai as genai
+import httpx
+from google.genai.errors import APIError
+from google.genai.types import BlockedReason
+from google.genai.types import ContentListUnion
+from google.genai.types import ContentUnion
+from google.genai.types import FinishReason
+from google.genai.types import GenerateContentConfig
+from google.genai.types import GenerateContentResponse
+from google.genai.types import HarmProbability
+from google.genai.types import ModelContent
+from google.genai.types import Part
+from google.genai.types import ThinkingConfig
+from google.genai.types import UserContent
+from loguru import logger
+from pydantic.functional_validators import field_validator
+
+from imbue_core.agents.llm_apis.api_utils import convert_prompt_to_messages
+from imbue_core.agents.llm_apis.api_utils import (
+ create_costed_language_model_response_for_single_result,
+)
+from imbue_core.agents.llm_apis.data_types import CachedCountTokensResponse
+from imbue_core.agents.llm_apis.data_types import CostedLanguageModelResponse
+from imbue_core.agents.llm_apis.data_types import CountTokensInputs
+from imbue_core.agents.llm_apis.data_types import CountTokensResponse
+from imbue_core.agents.llm_apis.data_types import LanguageModelGenerationParams
+from imbue_core.agents.llm_apis.data_types import ResponseStopReason
+from imbue_core.agents.llm_apis.data_types import ThoughtResponse
+from imbue_core.agents.llm_apis.errors import BadAPIRequestError
+from imbue_core.agents.llm_apis.errors import LanguageModelInvalidModelNameError
+from imbue_core.agents.llm_apis.errors import MissingAPIKeyError
+from imbue_core.agents.llm_apis.errors import TransientLanguageModelError
+from imbue_core.agents.llm_apis.errors import UnsetCachePathError
+from imbue_core.agents.llm_apis.language_model_api import LanguageModelAPI
+from imbue_core.agents.llm_apis.models import ModelInfo
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamEvent
+from imbue_core.async_monkey_patches import log_exception
+from imbue_core.caching import AsyncCache
+from imbue_core.frozen_utils import FrozenDict
+from imbue_core.frozen_utils import FrozenMapping
+from imbue_core.itertools import only
+from imbue_core.secrets_utils import get_secret
+
+
+class GeminiModelName(enum.StrEnum):
+ GEMINI_1_0_PRO = "models/gemini-1.0-pro-001"
+ GEMINI_1_5_FLASH = "models/gemini-1.5-flash-001"
+ GEMINI_1_5_PRO = "models/gemini-1.5-pro-001"
+ GEMINI_1_5_PRO_2 = "models/gemini-1.5-pro-002"
+ GEMINI_1_5_FLASH_2 = "models/gemini-1.5-flash-002"
+ GEMINI_2_0_FLASH = "models/gemini-2.0-flash-001"
+ GEMINI_2_5_FLASH = "models/gemini-2.5-flash"
+ GEMINI_2_5_FLASH_LITE_PREVIEW = "models/gemini-2.5-flash-lite-preview-06-17"
+
+
+# Rate limits for Google Gemini models based on published API documentation
+# Reference: https://ai.google.dev/gemini-api/docs/rate-limits#tier-3
+# Using Tier 3 rate limits
+
+GEMINI_MODEL_INFO_BY_NAME: FrozenMapping[GeminiModelName, ModelInfo] = FrozenDict(
+ {
+ # https://ai.google.dev/gemini-api/docs/models/gemini
+ # https://ai.google.dev/pricing
+ # For pricing there are different rates depending on context/prompt size, so below we use the most
+ # expensive value. Note that this only kicks in at 128k tokens, the cost for most prompts is 2x lower
+ GeminiModelName.GEMINI_1_0_PRO: ModelInfo(
+ model_name="models/gemini-1.0-pro-001",
+ cost_per_input_token=0.5 / 1_000_000,
+ cost_per_output_token=1.5 / 1_000_000,
+ max_input_tokens=30_720,
+ max_output_tokens=2048,
+ rate_limit_req=2000 / 60, # 2000 RPM = 33.33 RPS
+ ),
+ GeminiModelName.GEMINI_1_5_FLASH: ModelInfo(
+ model_name="models/gemini-1.5-flash-001",
+ cost_per_input_token=0.15 / 1_000_000,
+ cost_per_output_token=0.60 / 1_000_000,
+ max_input_tokens=1_048_576,
+ max_output_tokens=8192,
+ rate_limit_req=30000 / 60, # 30000 RPM = 500.00 RPS
+ ),
+ GeminiModelName.GEMINI_1_5_FLASH_2: ModelInfo(
+ model_name="models/gemini-1.5-flash-002",
+ cost_per_input_token=0.15 / 1_000_000,
+ cost_per_output_token=0.60 / 1_000_000,
+ max_input_tokens=1_048_576,
+ max_output_tokens=8192,
+ rate_limit_req=30000 / 60, # 30000 RPM = 500.00 RPS
+ ),
+ GeminiModelName.GEMINI_1_5_PRO: ModelInfo(
+ model_name="models/gemini-1.5-pro-001",
+ cost_per_input_token=2.5 / 1_000_000,
+ cost_per_output_token=10.0 / 1_000_000,
+ max_input_tokens=2_097_152,
+ max_output_tokens=8192,
+ rate_limit_req=4000 / 60, # 4000 RPM = 66.67 RPS
+ ),
+ GeminiModelName.GEMINI_1_5_PRO_2: ModelInfo(
+ model_name="models/gemini-1.5-pro-002",
+ cost_per_input_token=2.5 / 1_000_000,
+ cost_per_output_token=10.0 / 1_000_000,
+ max_input_tokens=2_097_152,
+ max_output_tokens=8192,
+ rate_limit_req=4000 / 60, # 4000 RPM = 66.67 RPS
+ ),
+ GeminiModelName.GEMINI_2_0_FLASH: ModelInfo(
+ model_name="models/gemini-2.0-flash-001",
+ cost_per_input_token=0.1 / 1_000_000,
+ cost_per_output_token=0.4 / 1_000_000,
+ max_input_tokens=1_048_576,
+ max_output_tokens=8192,
+ rate_limit_req=30000 / 60, # 30000 RPM = 500.00 RPS
+ ),
+ GeminiModelName.GEMINI_2_5_FLASH: ModelInfo(
+ model_name="models/gemini-2.5-flash",
+ cost_per_input_token=0.3 / 1_000_000,
+ cost_per_output_token=2.5 / 1_000_000,
+ max_input_tokens=1_048_576,
+ max_output_tokens=65536,
+ rate_limit_req=10_000 / 60, # 10000 RPM = 166.67 RPS
+ rate_limit_tok=8_000_000 / 60, # 8,000,000 TPM = 133,333.33 TPS
+ max_thinking_budget=24576,
+ ),
+ GeminiModelName.GEMINI_2_5_FLASH_LITE_PREVIEW: ModelInfo(
+ model_name="models/gemini-2.5-flash-lite-preview-06-17",
+ cost_per_input_token=0.1 / 1_000_000,
+ cost_per_output_token=0.4 / 1_000_000,
+ max_input_tokens=1_000_000,
+ max_output_tokens=64_000,
+ # these are the tier 2 rate limits. the above claims that we're on tier 3, but i've never actually seen that
+ rate_limit_req=10_000 / 60,
+ rate_limit_tok=10_000_000 / 60,
+ # rate_limit_req=30_000 / 60, # 30000 RPM = 500.00 RPS
+ # rate_limit_tok=30_000_000 / 60, # 30,000,000 TPM = 500,000 TPS
+ max_thinking_budget=24_576,
+ ),
+ }
+)
+
+
+_ROLE_TO_GEMINI_ROLE: Final[FrozenMapping[str, str]] = FrozenDict(
+ {
+ "HUMAN": "user",
+ "ASSISTANT": "model",
+ "USER": "user",
+ "SYSTEM": "user",
+ }
+)
+
+NO_SIMPLE_TEXT_ERROR = "".join(
+ [
+ "The `response.text` quick accessor only works for ",
+ "simple (single-`Part`) text responses. This response is not simple text.",
+ "Use the `result.parts` accessor or the full ",
+ "`result.candidates[index].content.parts` lookup ",
+ "instead.",
+ ]
+)
+
+_GEMINI_STOP_REASON_TO_STOP_REASON: Final[FrozenMapping[FinishReason, ResponseStopReason]] = FrozenDict(
+ {
+ # Gemini treats stop due to natural stop point and provided stop sequence the same
+ FinishReason.STOP: ResponseStopReason.END_TURN,
+ FinishReason.MAX_TOKENS: ResponseStopReason.MAX_TOKENS,
+ FinishReason.SAFETY: ResponseStopReason.CONTENT_FILTER,
+ # Recitation means the content was flagged for being memorized, i.e. the LLM just
+ # copied data from the training data (@johnny at least that's how I understood the docs)
+ # https://ai.google.dev/api/generate-content#FinishReason
+ FinishReason.RECITATION: ResponseStopReason.CONTENT_FILTER,
+ FinishReason.OTHER: ResponseStopReason.NONE,
+ FinishReason.FINISH_REASON_UNSPECIFIED: ResponseStopReason.NONE,
+ }
+)
+
+T = TypeVar("T")
+
+
+def only_and_not_none(iterable: Iterable[T] | None) -> T:
+ in_value = iterable if iterable is not None else []
+ return only(in_value)
+
+
+def _is_flagged_as_unsafe(api_result: GenerateContentResponse) -> bool:
+ if api_result.prompt_feedback is None:
+ return False
+ block_reason = api_result.prompt_feedback.block_reason
+ if block_reason == BlockedReason.SAFETY:
+ return True
+ candidate = only_and_not_none(api_result.candidates)
+ if candidate.finish_reason == FinishReason.SAFETY:
+ return True
+ if candidate.finish_reason == FinishReason.OTHER and any(
+ rating.probability != HarmProbability.NEGLIGIBLE for rating in (candidate.safety_ratings or [])
+ ):
+ return True
+ return False
+
+
+def _is_flagged_as_recitation(api_result: GenerateContentResponse) -> bool:
+ candidate = only_and_not_none(api_result.candidates)
+ finish_reason = candidate.finish_reason
+ if finish_reason == FinishReason.RECITATION:
+ return True
+ return False
+
+
+def role_to_content(role: str, parts: list[Part]) -> ContentUnion:
+ match role:
+ case "user":
+ return UserContent(parts=parts)
+ case "model":
+ return ModelContent(parts=parts)
+ case _:
+ raise BadAPIRequestError(f"Invalid role: {role}")
+
+
+def convert_prompt_to_gemini_messages(prompt: str) -> ContentListUnion:
+ messages: list[ContentUnion] = []
+ parts = []
+ last_role = None
+ for message in convert_prompt_to_messages(prompt):
+ role = _ROLE_TO_GEMINI_ROLE[message.role]
+ parts.append(Part(text=f"\n{message.content}"))
+ if last_role != role and last_role is not None:
+ messages.append(role_to_content(last_role, parts))
+ parts = []
+ last_role = role
+ if len(parts) > 0:
+ assert last_role is not None
+ messages.append(role_to_content(last_role, parts))
+ return messages
+
+
+@contextmanager
+def _gemini_exception_manager() -> Iterator[None]:
+ """Simple context manager for parsing gemini API exceptions."""
+ # TODO probably some exceptions missing here. The google.ai docs/code is annoying to parse
+ try:
+ yield
+ except AssertionError as e:
+ logger.info("The Gemini prompt is invalid.")
+ raise BadAPIRequestError(str(e)) from e
+ except APIError as e:
+ logger.info("Gemini failed to generate content.")
+ raise BadAPIRequestError(str(e)) from e
+ except ValueError as e:
+ logger.info("Gemini did not return a simple text response.")
+ raise BadAPIRequestError(str(e)) from e
+ except AttributeError as e:
+ logger.info("There is an error with the Gemini prompt or processing code: {}.", str(e))
+ raise BadAPIRequestError(str(e)) from e
+ except httpx.RemoteProtocolError as e:
+ logger.info(str(e))
+ raise TransientLanguageModelError("httpx.RemoteProtocolError") from e
+ except (BadAPIRequestError, TransientLanguageModelError, MissingAPIKeyError):
+ # we already raised this error ourselves earlier, so we don't need to mark it as unknown
+ raise
+ except Exception as e:
+ # we catch TransientLanguageModelError later to retry it, but we still want to log it so it's not silent
+ log_exception(
+ e,
+ "Failed to generate output from Gemini, unknown error of type {type_name}",
+ type_name=type(e).__name__,
+ )
+ raise TransientLanguageModelError("Unknown error") from e
+
+
+R = TypeVar("R")
+
+
+def fmap(fn: Callable[[T], R], values: T | None) -> R | None:
+ if values is None:
+ return None
+ return fn(values)
+
+
+class GeminiAPI(LanguageModelAPI):
+ model_name: GeminiModelName = GeminiModelName.GEMINI_1_5_FLASH
+ is_conversational: bool = True
+
+ count_tokens_cache_path: Path | None = None
+
+ @field_validator("model_name") # pyre-ignore[56]: pyre doesn't understand pydantic
+ @classmethod
+ def validate_model_name(cls, v: str) -> str:
+ if v not in GEMINI_MODEL_INFO_BY_NAME:
+ raise LanguageModelInvalidModelNameError(v, cls.__name__, list(GEMINI_MODEL_INFO_BY_NAME))
+ return v
+
+ @property
+ def model_info(self) -> ModelInfo:
+ return GEMINI_MODEL_INFO_BY_NAME[self.model_name]
+
+ def _get_client(self) -> genai.Client:
+ api_key = get_secret("GOOGLE_API_KEY")
+ if not api_key:
+ raise MissingAPIKeyError("GOOGLE_API_KEY environment variable is not set")
+ return genai.Client(api_key=api_key)
+
+ async def _call_api(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ network_failure_count: int = 0,
+ ) -> CostedLanguageModelResponse:
+ # TODO: check if this is still true
+ assert params.count == 1, "Gemini only supports a single completion"
+ messages = convert_prompt_to_gemini_messages(prompt)
+ with _gemini_exception_manager():
+ client = self._get_client()
+ generation_config = GenerateContentConfig(
+ temperature=params.temperature,
+ candidate_count=params.count,
+ stop_sequences=fmap(lambda x: [x], params.stop),
+ max_output_tokens=params.max_tokens,
+ thinking_config=fmap(
+ lambda thinking: ThinkingConfig(
+ thinking_budget=thinking.max_tokens,
+ include_thoughts=thinking.output_thinking,
+ ),
+ params.thinking,
+ ),
+ )
+
+ api_result: GenerateContentResponse = await client.aio.models.generate_content(
+ model=self.model_info.model_name,
+ contents=messages,
+ config=generation_config,
+ )
+
+ prompt_tokens = self.count_tokens(prompt)
+
+ if (
+ api_result.prompt_feedback is not None
+ and api_result.prompt_feedback.block_reason is not None
+ and api_result.prompt_feedback.block_reason != BlockedReason.BLOCKED_REASON_UNSPECIFIED
+ ):
+ logger.info(
+ f"Gemini blocked output: {messages=}, {api_result.prompt_feedback.block_reason=}, {api_result.prompt_feedback.safety_ratings=}"
+ )
+ return create_costed_language_model_response_for_single_result(
+ text="",
+ prompt_tokens=prompt_tokens,
+ completion_tokens=0,
+ stop_reason=ResponseStopReason.NONE,
+ network_failure_count=network_failure_count,
+ dollars_used=self.calculate_cost(prompt_tokens, 0), # guestimate of cost,
+ )
+
+ if _is_flagged_as_unsafe(api_result) or _is_flagged_as_recitation(api_result):
+ block_reason = fmap(lambda x: x.block_reason, api_result.prompt_feedback)
+ safety_ratings = (
+ api_result.prompt_feedback.safety_ratings if api_result.prompt_feedback is not None else None
+ )
+ logger.info(
+ "Gemini flagged output: block_reason={block_reason}, safety_ratings={safety_ratings}",
+ block_reason=block_reason,
+ safety_ratings=safety_ratings,
+ )
+ return create_costed_language_model_response_for_single_result(
+ text="",
+ prompt_tokens=prompt_tokens,
+ completion_tokens=0,
+ stop_reason=ResponseStopReason.CONTENT_FILTER,
+ network_failure_count=network_failure_count,
+ dollars_used=self.calculate_cost(prompt_tokens, 0),
+ )
+
+ candidate = only_and_not_none(api_result.candidates)
+ finish_reason = candidate.finish_reason
+ parsed_finish_reason = (
+ _GEMINI_STOP_REASON_TO_STOP_REASON[finish_reason]
+ if finish_reason is not None
+ else ResponseStopReason.NONE
+ )
+
+ if finish_reason not in [FinishReason.MAX_TOKENS, FinishReason.STOP]:
+ block_reason = fmap(lambda x: x.block_reason, api_result.prompt_feedback)
+ safety_ratings = fmap(lambda x: x.safety_ratings, api_result.prompt_feedback)
+ logger.info(
+ f"Gemini did not return a simple text response, {block_reason=}, {safety_ratings=}, {finish_reason=}, {candidate.safety_ratings=}"
+ )
+ return create_costed_language_model_response_for_single_result(
+ text="",
+ prompt_tokens=prompt_tokens,
+ completion_tokens=0,
+ stop_reason=parsed_finish_reason,
+ network_failure_count=network_failure_count,
+ dollars_used=self.calculate_cost(prompt_tokens, 0),
+ )
+
+ text = api_result.text
+
+ thoughts_list = fmap(
+ lambda content: fmap(
+ lambda parts: [part.text for part in parts if part.thought],
+ content.parts,
+ ),
+ candidate.content,
+ )
+ if not thoughts_list:
+ thoughts = None
+ else:
+ thoughts = only(thoughts_list)
+
+ if text is None:
+ if finish_reason == FinishReason.MAX_TOKENS and generation_config.thinking_config is not None:
+ raise BadAPIRequestError(
+ "Gemini ran out of tokens while thinking and did not return a text response"
+ )
+ logger.info("Non-simple-text response: {}", api_result)
+ raise BadAPIRequestError("Gemini did not return a simple text response (text is None)")
+
+ prompt_tokens = (
+ api_result.usage_metadata.prompt_token_count
+ if api_result.usage_metadata is not None and api_result.usage_metadata.prompt_token_count is not None
+ else self.count_tokens(prompt)
+ )
+
+ thought_tokens = (
+ api_result.usage_metadata.thoughts_token_count
+ if api_result.usage_metadata is not None and api_result.usage_metadata.thoughts_token_count is not None
+ else 0
+ )
+
+ output_tokens = (
+ api_result.usage_metadata.candidates_token_count
+ if api_result.usage_metadata is not None
+ and api_result.usage_metadata.candidates_token_count is not None
+ else self.count_tokens(text)
+ )
+
+ completion_tokens = output_tokens + thought_tokens
+
+ dollars_used = self.calculate_cost(prompt_tokens, completion_tokens)
+ logger.trace(text)
+ logger.trace("Dollars used: {}", dollars_used)
+ return create_costed_language_model_response_for_single_result(
+ text=text,
+ prompt_tokens=prompt_tokens,
+ completion_tokens=completion_tokens,
+ stop_reason=parsed_finish_reason,
+ network_failure_count=network_failure_count,
+ dollars_used=dollars_used,
+ thoughts=fmap(
+ lambda x: ThoughtResponse(text=x, completion_tokens=thought_tokens),
+ thoughts,
+ ),
+ )
+
+ def _get_api_stream(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ ) -> AsyncGenerator[LanguageModelStreamEvent, None]:
+ # TODO Implement streaming support (?)
+ raise NotImplementedError()
+
+ # TODO: these are the same as in anthropic_api.py. it might be good to refactor so that both AnthropicAPI and GeminiAPI inherit from a class LanguageModelAPIWithCountTokens which has these methods
+ def get_count_tokens_response_cache(self) -> AsyncCache[CachedCountTokensResponse]:
+ if self.count_tokens_cache_path is None:
+ raise UnsetCachePathError()
+ return AsyncCache(self.count_tokens_cache_path, CachedCountTokensResponse)
+
+ async def check_count_tokens_cache(self, cache_key: str) -> CountTokensResponse | None:
+ return await self.check_cache_core(self.get_count_tokens_response_cache, cache_key)
+
+ async def _get_from_count_tokens_cache(
+ self, frame: FrameType | None
+ ) -> tuple[str | None, CountTokensResponse | None]:
+ return await self._get_from_cache_core(frame, lambda cr: cr, self.check_count_tokens_cache)
+
+ async def count_tokens_api(self, text: str, is_caching_enabled: bool) -> int | None:
+ """Call the count_tokens api to get a definitive token count. May be fragile and is definitely slow."""
+
+ self.assert_caching_enabled_if_offline(is_caching_enabled)
+
+ frame: FrameType | None = None
+ if is_caching_enabled:
+ frame = inspect.currentframe()
+
+ cache_key: str | None = None
+ if is_caching_enabled:
+ cache_key, cached_response = await self._get_from_count_tokens_cache(frame)
+
+ if cached_response is not None:
+ return cached_response.input_tokens
+
+ self.assert_not_offline_if_cache_miss(text)
+
+ with _gemini_exception_manager():
+ client = self._get_client()
+ response = client.models.count_tokens(model=self.model_info.model_name, contents=text)
+
+ total_tokens = response.total_tokens
+ if total_tokens is None:
+ raise TransientLanguageModelError("Gemini did not return a valid token count")
+
+ result = CachedCountTokensResponse(
+ response=CountTokensResponse(
+ input_tokens=total_tokens,
+ cached_content_token_count=response.cached_content_token_count,
+ ),
+ inputs=(
+ CountTokensInputs(model=self.model_info.model_name, prompt=text) if self.is_caching_inputs else None
+ ),
+ )
+
+ if is_caching_enabled:
+ assert cache_key is not None
+ async with self.get_count_tokens_response_cache() as cache:
+ await cache.set(cache_key, result)
+
+ return total_tokens
diff --git a/imbue_core/imbue_core/agents/llm_apis/groq_api.py b/imbue_core/imbue_core/agents/llm_apis/groq_api.py
@@ -0,0 +1,357 @@
+import asyncio
+import enum
+import math
+from contextlib import contextmanager
+from typing import AsyncGenerator
+from typing import Final
+from typing import Iterator
+from typing import Mapping
+
+import httpx
+from groq import APIConnectionError
+from groq import APIError
+from groq import AsyncGroq
+from groq import AsyncStream
+from groq import BadRequestError
+from groq import RateLimitError
+from groq.types.chat import ChatCompletion
+from loguru import logger
+from pydantic.functional_validators import field_validator
+
+from imbue_core.agents.llm_apis.api_utils import convert_prompt_to_openai_messages
+from imbue_core.agents.llm_apis.data_types import CostedLanguageModelResponse
+from imbue_core.agents.llm_apis.data_types import LanguageModelGenerationParams
+from imbue_core.agents.llm_apis.data_types import LanguageModelResponse
+from imbue_core.agents.llm_apis.data_types import LanguageModelResponseUsage
+from imbue_core.agents.llm_apis.data_types import ResponseStopReason
+from imbue_core.agents.llm_apis.errors import BadAPIRequestError
+from imbue_core.agents.llm_apis.errors import LanguageModelInvalidModelNameError
+from imbue_core.agents.llm_apis.errors import MissingAPIKeyError
+from imbue_core.agents.llm_apis.errors import PromptTooLongError
+from imbue_core.agents.llm_apis.errors import TransientLanguageModelError
+from imbue_core.agents.llm_apis.language_model_api import LanguageModelAPI
+from imbue_core.agents.llm_apis.models import ModelInfo
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamDeltaEvent
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamEndEvent
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamEvent
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamStartEvent
+from imbue_core.frozen_utils import FrozenDict
+from imbue_core.frozen_utils import FrozenMapping
+from imbue_core.itertools import only
+from imbue_core.secrets_utils import get_secret
+
+# note: we require that these model versions are explicit, just like the rest of our dependencies
+# the reason is that these models are actually now mostly deterministic, and it is much easier to debug if we know what model was used
+# also, there's no need to troll yourself by wondering why results have improved (or gotten worse) when you dont realized that the version has shifted under you
+# if you want to use an upgraded model, just upgrade the model to the key displayed on the website
+# please do NOT set these back to the generic model names! Josh will be very annoyed
+
+
+# TODO: there are likely more models to add
+class GroqSupportedModelName(enum.StrEnum):
+ GROQ_GEMMA2_9B_IT = "groq/gemma2-9b-it"
+ GROQ_LLAMA3_70B_8192 = "groq/llama3-70b-8192"
+ GROQ_LLAMA3_8B_8192 = "groq/llama3-8b-8192"
+ GROQ_LLAMA_3_3_70B_SPECDEC = "groq/llama-3.3-70b-specdec"
+ GROQ_MIXTRAL_8X7B_32768 = "groq/mixtral-8x7b-32768"
+ GROQ_LLAMA_3_3_70B_VERSATILE = "groq/llama-3.3-70b-versatile"
+ GROQ_LLAMA_3_1_8B_INSTANT = "groq/llama-3.1-8b-instant"
+ GROQ_LLAMA_3_2_1B_PREVIEW = "groq/llama-3.2-1b-preview"
+ GROQ_LLAMA_3_2_3B_PREVIEW = "groq/llama-3.2-3b-preview"
+
+
+# Rate limits for Groq models based on custom rate limits for our organization.
+# See here https://console.groq.com/dashboard/limits (requires login, use your Google account)
+
+GROQ_MODEL_INFO_BY_NAME: FrozenMapping[GroqSupportedModelName, ModelInfo] = FrozenDict(
+ {
+ GroqSupportedModelName.GROQ_GEMMA2_9B_IT: ModelInfo(
+ model_name=str(GroqSupportedModelName.GROQ_GEMMA2_9B_IT),
+ cost_per_input_token=0.20 / 1_000_000,
+ cost_per_output_token=0.20 / 1_000_000,
+ max_input_tokens=8192,
+ max_output_tokens=None,
+ rate_limit_req=30 / 60, # 30 RPM = 0.50 RPS
+ ),
+ GroqSupportedModelName.GROQ_LLAMA3_70B_8192: ModelInfo(
+ model_name=str(GroqSupportedModelName.GROQ_LLAMA3_70B_8192),
+ cost_per_input_token=0.59 / 1_000_000,
+ cost_per_output_token=0.79 / 1_000_000,
+ max_input_tokens=8192,
+ max_output_tokens=None,
+ rate_limit_req=30 / 60, # 30 RPM = 0.50 RPS
+ ),
+ GroqSupportedModelName.GROQ_LLAMA3_8B_8192: ModelInfo(
+ model_name=str(GroqSupportedModelName.GROQ_LLAMA3_8B_8192),
+ cost_per_input_token=0.05 / 1_000_000,
+ cost_per_output_token=0.08 / 1_000_000,
+ max_input_tokens=8192,
+ max_output_tokens=None,
+ rate_limit_req=30 / 60, # 30 RPM = 0.50 RPS
+ ),
+ GroqSupportedModelName.GROQ_LLAMA_3_3_70B_SPECDEC: ModelInfo(
+ model_name=str(GroqSupportedModelName.GROQ_LLAMA_3_3_70B_SPECDEC),
+ cost_per_input_token=0.59 / 1_000_000,
+ cost_per_output_token=0.99 / 1_000_000,
+ max_input_tokens=8192,
+ max_output_tokens=None,
+ rate_limit_req=30 / 60, # 30 RPM = 0.50 RPS
+ ),
+ GroqSupportedModelName.GROQ_MIXTRAL_8X7B_32768: ModelInfo(
+ model_name=str(GroqSupportedModelName.GROQ_MIXTRAL_8X7B_32768),
+ cost_per_input_token=0.24 / 1_000_000,
+ cost_per_output_token=0.24 / 1_000_000,
+ max_input_tokens=32768,
+ max_output_tokens=None,
+ rate_limit_req=30 / 60, # 30 RPM = 0.50 RPS
+ ),
+ GroqSupportedModelName.GROQ_LLAMA_3_3_70B_VERSATILE: ModelInfo(
+ model_name=str(GroqSupportedModelName.GROQ_LLAMA_3_3_70B_VERSATILE),
+ cost_per_input_token=0.59 / 1_000_000,
+ cost_per_output_token=0.79 / 1_000_000,
+ max_input_tokens=128_000,
+ max_output_tokens=None,
+ rate_limit_req=30 / 60, # 30 RPM = 0.50 RPS
+ ),
+ GroqSupportedModelName.GROQ_LLAMA_3_1_8B_INSTANT: ModelInfo(
+ model_name=str(GroqSupportedModelName.GROQ_LLAMA_3_1_8B_INSTANT),
+ cost_per_input_token=0.05 / 1_000_000,
+ cost_per_output_token=0.08 / 1_000_000,
+ max_input_tokens=128_000,
+ max_output_tokens=None,
+ rate_limit_req=30 / 60, # 30 RPM = 0.50 RPS
+ ),
+ GroqSupportedModelName.GROQ_LLAMA_3_2_1B_PREVIEW: ModelInfo(
+ model_name=str(GroqSupportedModelName.GROQ_LLAMA_3_2_1B_PREVIEW),
+ cost_per_input_token=0.04 / 1_000_000,
+ cost_per_output_token=0.04 / 1_000_000,
+ max_input_tokens=128_000,
+ max_output_tokens=None,
+ rate_limit_req=30 / 60, # 30 RPM = 0.50 RPS
+ ),
+ GroqSupportedModelName.GROQ_LLAMA_3_2_3B_PREVIEW: ModelInfo(
+ model_name=str(GroqSupportedModelName.GROQ_LLAMA_3_2_3B_PREVIEW),
+ cost_per_input_token=0.06 / 1_000_000,
+ cost_per_output_token=0.06 / 1_000_000,
+ max_input_tokens=128_000,
+ max_output_tokens=None,
+ rate_limit_req=30 / 60, # 30 RPM = 0.50 RPS
+ ),
+ }
+)
+
+
+def get_model_info(model_name: GroqSupportedModelName) -> ModelInfo:
+ return GROQ_MODEL_INFO_BY_NAME[model_name]
+
+
+_CAPACITY_SEMAPHOR_BY_MODEL_NAME: Mapping[str, asyncio.Semaphore] = {
+ GroqSupportedModelName.GROQ_GEMMA2_9B_IT: asyncio.Semaphore(100),
+ GroqSupportedModelName.GROQ_LLAMA3_70B_8192: asyncio.Semaphore(100),
+ GroqSupportedModelName.GROQ_LLAMA3_8B_8192: asyncio.Semaphore(100),
+ GroqSupportedModelName.GROQ_LLAMA_3_3_70B_SPECDEC: asyncio.Semaphore(100),
+ GroqSupportedModelName.GROQ_MIXTRAL_8X7B_32768: asyncio.Semaphore(100),
+ GroqSupportedModelName.GROQ_LLAMA_3_3_70B_VERSATILE: asyncio.Semaphore(100),
+ GroqSupportedModelName.GROQ_LLAMA_3_1_8B_INSTANT: asyncio.Semaphore(100),
+ GroqSupportedModelName.GROQ_LLAMA_3_2_1B_PREVIEW: asyncio.Semaphore(100),
+ GroqSupportedModelName.GROQ_LLAMA_3_2_3B_PREVIEW: asyncio.Semaphore(100),
+}
+
+
+def _get_capacity_semaphor(model_name: str) -> asyncio.Semaphore:
+ return _CAPACITY_SEMAPHOR_BY_MODEL_NAME[model_name]
+
+
+# ref: https://github.com/groq/groq-python/blob/b74ce9e301115520c744e18425653a4c783cb6f5/src/groq/types/chat/chat_completion_chunk.py#L86
+_GROQ_STOP_REASON_TO_STOP_REASON: Final[FrozenMapping[str, ResponseStopReason]] = FrozenDict(
+ {
+ # Groq copies OpenAI and treats stop due to natural stop point and provided stop sequence the same
+ "stop": ResponseStopReason.END_TURN,
+ "length": ResponseStopReason.MAX_TOKENS,
+ "tool_calls": ResponseStopReason.TOOL_CALLS,
+ "function_call": ResponseStopReason.FUNCTION_CALL,
+ "content_filter": ResponseStopReason.CONTENT_FILTER,
+ }
+)
+
+
+@contextmanager
+def _groq_exception_manager() -> Iterator[None]:
+ """Simple context manager for parsing groq exceptions mostly based on how we parse OpenAI API exceptions."""
+ try:
+ yield
+ except BadRequestError as e:
+ logger.info("BadAPIRequestError {}", e)
+ raise BadAPIRequestError(str(e)) from e
+ except APIConnectionError as e:
+ logger.info("Rate limited? Received APIConnectionError {}", e)
+ raise TransientLanguageModelError("APIConnectionError") from e
+ except RateLimitError as e:
+ logger.info("Rate limited? {}", e)
+ raise TransientLanguageModelError("RateLimitError") from e
+ except httpx.RemoteProtocolError as e:
+ logger.info("{}", e)
+ raise TransientLanguageModelError("httpx.RemoteProtocolError") from e
+ except APIError as e:
+ if e.body["code"] == "context_length_exceeded": # type: ignore
+ # TODO: eventually fix elsewhere, since this doesn't actually give you any information in the body...
+ raise PromptTooLongError(prompt_len=1, max_prompt_len=1)
+ raise TransientLanguageModelError("APIError") from e
+
+
+class GroqChatAPI(LanguageModelAPI):
+ model_name: GroqSupportedModelName = GroqSupportedModelName.GROQ_LLAMA3_8B_8192
+ is_conversational: bool = True
+ presence_penalty: float = 0.0
+ # this shouldn't really ever even be used, but just in case
+ stop_token_log_probability: float = math.log(0.9999)
+
+ @field_validator("model_name") # pyre-ignore[56]: pyre doesn't understand pydantic
+ @classmethod
+ def validate_model_name(cls, v: str) -> str:
+ if v not in GROQ_MODEL_INFO_BY_NAME:
+ raise LanguageModelInvalidModelNameError(v, cls.__name__, list(GROQ_MODEL_INFO_BY_NAME))
+ return v
+
+ @property
+ def model_info(self) -> ModelInfo:
+ return GROQ_MODEL_INFO_BY_NAME[self.model_name]
+
+ @property
+ def external_model_name(self) -> str:
+ return self.model_name.replace("groq/", "")
+
+ def _get_client(self) -> AsyncGroq:
+ api_key = get_secret("GROQ_API_KEY")
+ if not api_key:
+ raise MissingAPIKeyError("GROQ_API_KEY environment variable is not set")
+ return AsyncGroq(api_key=api_key)
+
+ async def _call_api(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ network_failure_count: int = 0,
+ ) -> CostedLanguageModelResponse:
+ with _groq_exception_manager():
+ messages = convert_prompt_to_openai_messages(prompt)
+ client = self._get_client()
+ async with _get_capacity_semaphor(self.model_name):
+ # logger.info("Open requests: {}", semaphor._value)
+ api_result = await client.chat.completions.create(
+ model=self.external_model_name,
+ messages=messages, # type: ignore
+ max_tokens=params.max_tokens,
+ n=params.count,
+ temperature=params.temperature,
+ stop=params.stop,
+ logprobs=False,
+ seed=params.seed,
+ stream=False,
+ presence_penalty=self.presence_penalty,
+ )
+ assert isinstance(api_result, ChatCompletion)
+
+ results = []
+ for data in api_result.choices:
+ assert data.message.content is not None
+
+ assert data.logprobs is not None and data.logprobs.content is not None
+ text = data.message.content
+
+ stop_reason = _GROQ_STOP_REASON_TO_STOP_REASON[str(data.finish_reason)]
+
+ # Note, like OpenAI, Groq treats end turn and stop sequence the same
+ # Here we assume it is stop sequence if user has specified a stop sequence
+ if params.stop is not None and stop_reason == ResponseStopReason.END_TURN:
+ text += params.stop
+ result = LanguageModelResponse(
+ text=text,
+ token_count=0,
+ stop_reason=stop_reason,
+ network_failure_count=network_failure_count,
+ )
+ results.append(result)
+
+ logger.trace("text: " + results[0].text)
+ if api_result.usage is not None:
+ completion_tokens = api_result.usage.completion_tokens
+ prompt_tokens = api_result.usage.prompt_tokens
+ else:
+ completion_tokens = 0
+ prompt_tokens = 0
+ dollars_used = self.calculate_cost(prompt_tokens, completion_tokens)
+ logger.trace("dollars used: {}", dollars_used)
+ return CostedLanguageModelResponse(
+ usage=LanguageModelResponseUsage(
+ prompt_tokens_used=prompt_tokens,
+ completion_tokens_used=completion_tokens,
+ dollars_used=dollars_used,
+ ),
+ responses=tuple(results),
+ )
+
+ async def _get_api_stream(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ ) -> AsyncGenerator[LanguageModelStreamEvent, None]:
+ with _groq_exception_manager():
+ messages = convert_prompt_to_openai_messages(prompt)
+ client = self._get_client()
+ async with _get_capacity_semaphor(self.model_name):
+ api_result = await client.chat.completions.create(
+ model=self.external_model_name,
+ messages=messages, # type: ignore
+ max_tokens=params.max_tokens,
+ n=1,
+ temperature=params.temperature,
+ stop=params.stop,
+ logprobs=False,
+ seed=params.seed,
+ stream=True,
+ # This field is currently unsupported by the groq API
+ # stream_options={"include_usage": True},
+ presence_penalty=self.presence_penalty,
+ )
+ assert isinstance(api_result, AsyncStream)
+ logger.info("API response status code: {}", api_result.response.status_code)
+
+ yield LanguageModelStreamStartEvent()
+
+ usage = None
+ finish_reason: str | None = None
+ async for chunk in api_result:
+ if chunk.choices:
+ assert len(chunk.choices) == 1, "Currently only count=1 supported for streaming API."
+ data = only(chunk.choices)
+ delta = data.delta.content
+ if delta is not None:
+ yield LanguageModelStreamDeltaEvent(delta=delta)
+ if data.finish_reason:
+ finish_reason = str(data.finish_reason)
+
+ stop_reason = _GROQ_STOP_REASON_TO_STOP_REASON[str(finish_reason)]
+ # Note, Open API treats end turn and stop sequence the same TODO: check if groq is the same
+ # Here we assume it is stop sequence if user has specified a stop sequence
+ if params.stop is not None and stop_reason == ResponseStopReason.END_TURN:
+ yield LanguageModelStreamDeltaEvent(delta=params.stop)
+
+ if usage is not None:
+ completion_tokens = usage.completion_tokens
+ prompt_tokens = usage.prompt_tokens
+ dollars_used = self.calculate_cost(prompt_tokens, completion_tokens)
+ else:
+ completion_tokens = -1
+ prompt_tokens = -1
+ dollars_used = -1
+ logger.trace("dollars used: {}", dollars_used)
+
+ yield LanguageModelStreamEndEvent(
+ usage=LanguageModelResponseUsage(
+ prompt_tokens_used=prompt_tokens,
+ completion_tokens_used=completion_tokens,
+ dollars_used=dollars_used,
+ ),
+ stop_reason=stop_reason,
+ )
diff --git a/imbue_core/imbue_core/agents/llm_apis/language_model_api.py b/imbue_core/imbue_core/agents/llm_apis/language_model_api.py
@@ -0,0 +1,547 @@
+import abc
+import asyncio
+import contextvars
+import hashlib
+import inspect
+import os
+import random
+from pathlib import Path
+from types import FrameType
+from typing import AsyncGenerator
+from typing import Awaitable
+from typing import Callable
+from typing import TypeVar
+from typing import final
+from uuid import UUID
+from uuid import uuid4
+
+import anyio
+from loguru import logger
+
+from imbue_core.agents.llm_apis.constants import approximate_token_count
+from imbue_core.agents.llm_apis.data_types import CachedCostedLanguageModelResponse
+from imbue_core.agents.llm_apis.data_types import CachedCostedModelResponse
+from imbue_core.agents.llm_apis.data_types import CachingInfo
+from imbue_core.agents.llm_apis.data_types import CostedLanguageModelResponse
+from imbue_core.agents.llm_apis.data_types import CountTokensResponse
+from imbue_core.agents.llm_apis.data_types import InputsT
+from imbue_core.agents.llm_apis.data_types import LanguageModelCompleteInputs
+from imbue_core.agents.llm_apis.data_types import LanguageModelGenerationParams
+from imbue_core.agents.llm_apis.data_types import LanguageModelResponse
+from imbue_core.agents.llm_apis.data_types import LanguageModelStreamInputs
+from imbue_core.agents.llm_apis.data_types import ModelResponseT
+from imbue_core.agents.llm_apis.errors import LanguageModelRetryLimitError
+from imbue_core.agents.llm_apis.errors import PromptTooLongError
+from imbue_core.agents.llm_apis.errors import TransientLanguageModelError
+from imbue_core.agents.llm_apis.errors import UnsetCachePathError
+from imbue_core.agents.llm_apis.models import ModelInfo
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamCallback
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamEvent
+from imbue_core.agents.llm_apis.stream import PromptDebuggingCallback
+from imbue_core.agents.llm_apis.stream import SettleSpendCallback
+from imbue_core.agents.llm_apis.stream import StreamedLanguageModelResponse
+from imbue_core.agents.llm_apis.stream import UpdateCacheCallback
+from imbue_core.agents.llm_apis.stream import get_cached_response_stream
+from imbue_core.agents.primitives.resource_limits import PaymentAuthorization
+from imbue_core.agents.primitives.resource_limits import get_global_resource_limits
+from imbue_core.async_utils import sync
+from imbue_core.caching import AsyncCache
+from imbue_core.cattrs_serialization import serialize_to_json
+from imbue_core.pydantic_serialization import MutableModel
+
+# Context variable to disable caching.
+IS_LLM_CACHING_DISABLED_GLOBALLY = contextvars.ContextVar("is_llm_caching_disabled_globally", default=False)
+# Context variable for injecting a default seed which will become part of the LLM cache key.
+LLM_GLOBAL_DEFAULT_SEED = contextvars.ContextVar("llm_global_default_seed", default=0)
+
+# Maximum number of retries for network failures.
+MAX_RETRIES = 5
+
+
+EXCLUDED_CACHE_KEY_ARGS = ["self", "is_caching_enabled", "call_id"]
+
+
+def _create_base_cache_key_from_frame(frame: FrameType) -> str:
+ """Create a cache key from the args of a function by passing its frame."""
+ args, _, _, values = inspect.getargvalues(frame)
+ return "|".join(f"{arg}={values[arg]}" for arg in args if arg not in EXCLUDED_CACHE_KEY_ARGS)
+
+
+CostedResponseT = TypeVar("CostedResponseT", bound=CostedLanguageModelResponse | CountTokensResponse)
+FinalResponseT = TypeVar(
+ "FinalResponseT",
+ bound=CostedLanguageModelResponse | StreamedLanguageModelResponse | CountTokensResponse,
+)
+
+
+class LanguageModelAPI(abc.ABC, MutableModel):
+ model_name: str
+ cache_path: Path | None
+ is_caching_inputs: bool = False
+ is_running_offline: bool = False
+ is_conversational: bool = False
+ is_using_logprobs: bool = False
+
+ # retry/timeout values
+ retry_sleep_time: float = 2.0
+ retry_backoff_factor: float = 3.0
+ retry_jitter_factor: float = 0.5
+
+ # TODO: Consider storing the model_config here as well.
+
+ @property
+ @abc.abstractmethod
+ def model_info(self) -> ModelInfo: ...
+
+ def get_response_cache(self) -> AsyncCache[CachedCostedLanguageModelResponse]:
+ if self.cache_path is None:
+ raise UnsetCachePathError()
+ return AsyncCache(self.cache_path, CachedCostedLanguageModelResponse)
+
+ def _create_cache_key(self, base_key: str) -> str:
+ object_cache_attributes = self.model_dump(
+ exclude={
+ "cache_path",
+ "count_tokens_cache_path",
+ "base_url",
+ "api_key_env",
+ "context_window",
+ "max_output_tokens",
+ }
+ )
+ # have to reset the offline key to the same value so that that doesnt invalidate the cache
+ object_cache_attributes["is_running_offline"] = True
+ object_cache_attributes["__name__"] = self.__class__.__name__
+ base_key_md5 = hashlib.md5(base_key.encode()).hexdigest()
+ object_cache_attributes["__request_key_md5__"] = base_key_md5
+ return serialize_to_json(object_cache_attributes)
+
+ async def check_cache_core(
+ self,
+ cache_getter: Callable[[], AsyncCache[CachedCostedModelResponse[InputsT, ModelResponseT]]],
+ cache_key: str,
+ ) -> ModelResponseT | None:
+ async with cache_getter() as cache:
+ cached_result = await cache.get(cache_key)
+
+ if cached_result is not None:
+ if cached_result.error:
+ if cached_result.error.startswith(PromptTooLongError.__name__):
+ raise PromptTooLongError.from_string(cached_result.error)
+ raise Exception(f"Unknown cached result error type: {cached_result.error}")
+ assert cached_result.response is not None
+ return cached_result.response
+ return None
+
+ async def check_cache(self, cache_key: str) -> CostedLanguageModelResponse | None:
+ return await self.check_cache_core(self.get_response_cache, cache_key)
+
+ async def _get_auth(self, prompt: str, max_tokens: int | None) -> PaymentAuthorization | None:
+ global_resource_limits = get_global_resource_limits()
+ if global_resource_limits is not None:
+ prompt_tokens = self.count_tokens(prompt)
+ completion_tokens = max_tokens if max_tokens is not None else self.get_max_completion_size_in_tokens()
+ upper_bound_cost_estimate = self.estimate_cost(prompt_tokens, completion_tokens)
+ assert global_resource_limits is not None
+ auth: PaymentAuthorization = await global_resource_limits.authorize_spend(
+ upper_bound_cost_estimate,
+ debug_info={
+ "model_name": self.model_name,
+ "prompt_tokens": prompt_tokens,
+ "completion_tokens": completion_tokens,
+ },
+ )
+ return auth
+
+ if "PYTEST_CURRENT_TEST" not in os.environ:
+ logger.warning(
+ "You are trying to call a language model from outside of a hammer with no global resource limits set. That is a bad idea because the spend will not be restricted, and you may end up accidentally spending much more than you expected."
+ )
+ return None
+
+ async def _settle_spend(self, auth: PaymentAuthorization, dollars_used: float) -> None:
+ global_resource_limits = get_global_resource_limits()
+ assert global_resource_limits is not None
+ await global_resource_limits.settle_spend(auth, dollars_used)
+ return None
+
+ def assert_caching_enabled_if_offline(self, is_caching_enabled: bool) -> None:
+ if self.is_running_offline:
+ assert is_caching_enabled, "Caching must be enabled when running offline"
+
+ def assert_not_offline_if_cache_miss(self, prompt: str) -> None:
+ max_n_chars = 50
+ prompt_stub = prompt[:max_n_chars] + ("..." if len(prompt) > max_n_chars else "")
+ assert (
+ not self.is_running_offline
+ ), f"Running offline but did not have a cached response for this query! Prompt: {prompt_stub}"
+
+ async def complete(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ is_caching_enabled: bool = True,
+ ) -> tuple[LanguageModelResponse, ...]:
+ call_id = uuid4()
+ logger.trace(
+ "[{call_id}] Calling complete with params: {params} and is_caching_enabled={is_caching_enabled} and {prompt}",
+ call_id=call_id,
+ params=params,
+ is_caching_enabled=is_caching_enabled,
+ prompt=prompt[:40],
+ )
+ if _complete_concurrency_hook_fn is not None:
+ await _complete_concurrency_hook_fn(self)
+
+ is_caching_enabled_with_override = is_caching_enabled and not IS_LLM_CACHING_DISABLED_GLOBALLY.get()
+ if params.seed is None:
+ params = params.evolve(params.ref().seed, LLM_GLOBAL_DEFAULT_SEED.get())
+
+ return await self._complete(
+ prompt,
+ params,
+ is_caching_enabled=is_caching_enabled_with_override,
+ call_id=call_id,
+ )
+
+ complete_sync = sync(complete)
+
+ async def complete_with_usage(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ is_caching_enabled: bool = True,
+ ) -> CostedLanguageModelResponse:
+ call_id = uuid4()
+ if _complete_concurrency_hook_fn is not None:
+ await _complete_concurrency_hook_fn(self)
+
+ is_caching_enabled_with_override = is_caching_enabled and not IS_LLM_CACHING_DISABLED_GLOBALLY.get()
+ if params.seed is None:
+ params = params.evolve(params.ref().seed, LLM_GLOBAL_DEFAULT_SEED.get())
+
+ return await self._complete_with_usage(
+ prompt,
+ params,
+ is_caching_enabled=is_caching_enabled_with_override,
+ call_id=call_id,
+ )
+
+ complete_with_usage_sync = sync(complete_with_usage)
+
+ async def _complete_with_usage(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ is_caching_enabled: bool,
+ call_id: UUID,
+ ) -> CostedLanguageModelResponse:
+ self._warn_if_no_stop_condition_and_not_conversational(params)
+ self.assert_caching_enabled_if_offline(is_caching_enabled)
+
+ frame: FrameType | None = None
+ if is_caching_enabled:
+ frame = inspect.currentframe()
+
+ costed_response_to_output: Callable[[CostedLanguageModelResponse], CostedLanguageModelResponse] = lambda cr: cr
+
+ cache_key: str | None = None
+ if is_caching_enabled:
+ cache_key, cached_response = await self._get_from_cache(frame, costed_response_to_output)
+
+ if cached_response is not None:
+ return cached_response
+
+ self.assert_not_offline_if_cache_miss(prompt)
+
+ auth = await self._get_auth(prompt, params.max_tokens)
+
+ sleep_time = self.retry_sleep_time
+ last_error_msg: str | None = None
+ for network_failure_count in range(MAX_RETRIES):
+ try:
+ api_inputs = LanguageModelCompleteInputs(
+ prompt=prompt,
+ params=params,
+ network_failure_count=network_failure_count,
+ )
+ response = await self._call_api_one_arg(api_inputs)
+ if is_caching_enabled:
+ assert cache_key is not None
+ result = CachedCostedLanguageModelResponse(
+ response=response,
+ inputs=api_inputs if self.is_caching_inputs else None,
+ )
+ async with self.get_response_cache() as cache:
+ await cache.set(cache_key, result)
+
+ if auth is not None:
+ await self._settle_spend(auth, response.usage.dollars_used)
+
+ return response
+
+ except PromptTooLongError as e:
+ logger.trace(
+ "[{call_id}] Prompt too long error in model {model_name}",
+ call_id=call_id,
+ model_name=self.model_name,
+ )
+ if is_caching_enabled:
+ assert cache_key is not None
+ async with self.get_response_cache() as cache:
+ await cache.set(
+ cache_key,
+ CachedCostedLanguageModelResponse(error=e.to_string()),
+ )
+ raise
+ except TransientLanguageModelError as e:
+ last_error_msg = str(e)
+ if network_failure_count < MAX_RETRIES - 1:
+ if self.retry_jitter_factor > 0:
+ max_jitter = sleep_time * self.retry_jitter_factor
+ sleep_time += random.uniform(-max_jitter / 2, max_jitter / 2)
+ logger.debug(
+ f"Transient language model error ({str(e)}) in model {self.model_name}, retrying with sleep time {sleep_time} seconds..."
+ )
+ await asyncio.sleep(sleep_time)
+ sleep_time *= self.retry_backoff_factor
+ raise LanguageModelRetryLimitError(last_error_msg or "Unknown error (this should not happen)")
+
+ async def _complete(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ is_caching_enabled: bool,
+ call_id: UUID,
+ ) -> tuple[LanguageModelResponse, ...]:
+ # Delegate to _complete_with_usage and extract just the responses
+ # May have more than count responses cached, so just return first count responses
+ costed_response = await self._complete_with_usage(prompt, params, is_caching_enabled, call_id)
+ return costed_response.responses[: params.count]
+
+ @final
+ async def _call_api_one_arg(self, api_inputs: LanguageModelCompleteInputs) -> CostedLanguageModelResponse:
+ """Delegates to the abstract method _call_api, which must be implemented by subclasses."""
+ return await self._call_api(
+ prompt=api_inputs.prompt,
+ params=api_inputs.params,
+ network_failure_count=api_inputs.network_failure_count,
+ )
+
+ @abc.abstractmethod
+ async def _call_api(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ # this is used to track how many times we've retried due to network failures, since we want the return type to contain that information
+ network_failure_count: int = 0,
+ ) -> CostedLanguageModelResponse:
+ """If defined, the stop sequence should be part of the sequence (if it was actually generated)"""
+
+ async def stream(
+ self,
+ prompt: str,
+ is_caching_enabled: bool = True,
+ params: LanguageModelGenerationParams = LanguageModelGenerationParams(),
+ ) -> StreamedLanguageModelResponse:
+ if params.seed is None:
+ params = params.evolve(params.ref().seed, LLM_GLOBAL_DEFAULT_SEED.get())
+
+ is_caching_enabled_with_override = is_caching_enabled and not IS_LLM_CACHING_DISABLED_GLOBALLY.get()
+
+ return await self._stream(
+ prompt=prompt,
+ is_caching_enabled=is_caching_enabled_with_override,
+ params=params,
+ )
+
+ async def _stream(
+ self,
+ prompt: str,
+ is_caching_enabled: bool,
+ params: LanguageModelGenerationParams,
+ ) -> StreamedLanguageModelResponse:
+ assert params.count == 1, "Stream API currently only supports count=1 due to limitations of some APIs."
+
+ self._warn_if_no_stop_condition_and_not_conversational(params)
+ self.assert_caching_enabled_if_offline(is_caching_enabled)
+
+ frame: FrameType | None = None
+ if is_caching_enabled:
+ frame = inspect.currentframe()
+
+ # Note it's technically possible multiple responses cached for given prompt (e.g. from call to complete())
+ # for now we just return first one
+ costed_response_to_output = lambda cr: StreamedLanguageModelResponse(
+ get_cached_response_stream(cr),
+ network_failure_count=0,
+ completion_callbacks=(),
+ )
+ cache_key: str | None = None
+ if is_caching_enabled:
+ cache_key, cached_response = await self._get_from_cache(frame, costed_response_to_output)
+
+ if cached_response is not None:
+ return cached_response
+
+ self.assert_not_offline_if_cache_miss(prompt)
+
+ auth = await self._get_auth(prompt, params.max_tokens)
+
+ sleep_time = self.retry_sleep_time
+ last_error_msg: str | None = None
+ for network_failure_count in range(MAX_RETRIES):
+ # Loop until success or an exception is raised
+ try:
+ api_inputs = LanguageModelStreamInputs(prompt=prompt, params=params)
+ api_stream = await self._get_api_stream_one_arg(api_inputs)
+ callbacks: list[LanguageModelStreamCallback] = []
+ if is_caching_enabled:
+ assert cache_key is not None
+ cache = self.get_response_cache()
+ callbacks.append(
+ UpdateCacheCallback(
+ key=cache_key,
+ cache=cache,
+ api_inputs=api_inputs if self.is_caching_inputs else None,
+ )
+ )
+ llm_debug_output_folder = os.getenv("LLM_DEBUG_PATH", None)
+ if llm_debug_output_folder is not None:
+ output_path = anyio.Path(llm_debug_output_folder) / f"{uuid4()}.txt"
+ # write out the prompt (helps with debugging so we can see when things blow up)
+ await output_path.write_text(prompt)
+ # overwrite the file with the prompt and completion when done
+ callbacks.append(PromptDebuggingCallback(prompt=prompt, output_path=output_path))
+
+ if auth is not None:
+ callbacks.append(SettleSpendCallback(auth=auth))
+
+ return StreamedLanguageModelResponse(
+ api_stream,
+ network_failure_count=network_failure_count,
+ completion_callbacks=callbacks,
+ )
+
+ except PromptTooLongError as e:
+ if is_caching_enabled:
+ assert cache_key is not None
+ async with self.get_response_cache() as cache:
+ await cache.set(
+ cache_key,
+ CachedCostedLanguageModelResponse(error=e.to_string()),
+ )
+ raise
+ except TransientLanguageModelError as e:
+ last_error_msg = str(e)
+ if network_failure_count < MAX_RETRIES - 1:
+ if self.retry_jitter_factor > 0:
+ sleep_time += random.uniform(0, sleep_time * self.retry_jitter_factor)
+ logger.debug(
+ f"Transient language model error ({str(e)}) in model {self.model_name}, retrying with sleep time {sleep_time} seconds..."
+ )
+ await asyncio.sleep(sleep_time)
+ sleep_time *= self.retry_backoff_factor
+ raise LanguageModelRetryLimitError(last_error_msg or "Unknown error (this should not happen)")
+
+ @final
+ async def _get_api_stream_one_arg(
+ self, api_inputs: LanguageModelStreamInputs
+ ) -> AsyncGenerator[LanguageModelStreamEvent, None]:
+ """Delegates to the abstract method _get_api_stream, which must be implemented by subclasses."""
+ return self._get_api_stream(prompt=api_inputs.prompt, params=api_inputs.params)
+
+ @abc.abstractmethod
+ def _get_api_stream(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ ) -> AsyncGenerator[LanguageModelStreamEvent, None]:
+ """If defined, the stop sequence should be part of the sequence (if it was actually generated)"""
+
+ def _warn_if_no_stop_condition_and_not_conversational(self, params: LanguageModelGenerationParams) -> None:
+ if (params.stop is None and params.max_tokens is None) and not self.is_conversational:
+ logger.debug(
+ "Did not specify either `max_tokens` or `stop`, and this is not a conversational model. The completion will go until the entire context window is filled. Preferably you don't do this, because it is fairly inefficient."
+ )
+
+ async def _get_from_cache_core(
+ self,
+ frame: FrameType | None,
+ costed_response_to_output: Callable[[CostedResponseT], FinalResponseT],
+ cache_checker: Callable[[str], Awaitable[CostedResponseT | None]],
+ ) -> tuple[str | None, FinalResponseT | None]:
+ cache_key: str | None
+
+ cache_key, costed_response = await self._get_costed_response_from_frame_core(cache_checker, frame)
+
+ if costed_response is not None:
+ return cache_key, costed_response_to_output(costed_response)
+ return cache_key, None
+
+ async def _get_from_cache(
+ self,
+ frame: FrameType | None,
+ costed_response_to_output: Callable[[CostedLanguageModelResponse], FinalResponseT],
+ ) -> tuple[str | None, FinalResponseT | None]:
+ return await self._get_from_cache_core(frame, costed_response_to_output, self.check_cache)
+
+ async def _get_costed_response_from_frame_core(
+ self,
+ cache_checker: Callable[[str], Awaitable[CostedResponseT | None]],
+ frame: FrameType | None,
+ ) -> tuple[str, CostedResponseT | None]:
+ assert frame is not None
+ cache_key = self._create_cache_key(_create_base_cache_key_from_frame(frame))
+ costed_response = await cache_checker(cache_key)
+ return cache_key, costed_response
+
+ def count_tokens(self, text: str) -> int:
+ # this is VERY approximate, but many of the child models have nothing, so...
+ return approximate_token_count(text)
+
+ def basic_calculate_cost(self, prompt_tokens: int, completion_tokens: int) -> float:
+ return (
+ prompt_tokens * self.model_info.cost_per_input_token
+ + completion_tokens * self.model_info.cost_per_output_token
+ )
+
+ def estimate_cost(self, prompt_tokens: int, completion_tokens: int) -> float:
+ """Estimate the cost of a request before it has been made. Doesn't use any caching info."""
+ return self.basic_calculate_cost(prompt_tokens, completion_tokens)
+
+ def calculate_cost(
+ self,
+ prompt_tokens: int,
+ completion_tokens: int,
+ caching_info: CachingInfo | None = None,
+ ) -> float:
+ """Overridden by subclasses which have more complex cost calculations, such as if caching is used."""
+ logger.info(
+ f"no calculate_cost implemented for {self.model_name}; using basic_calculate_cost",
+ model_name=self.model_name,
+ )
+ return self.basic_calculate_cost(prompt_tokens, completion_tokens)
+
+ def get_max_completion_size_in_tokens(self) -> int:
+ if self.model_info.max_output_tokens is not None:
+ return self.model_info.max_output_tokens
+ # assume max output is just the context window size
+ return self.model_info.max_input_tokens
+
+ def get_max_prompt_size_in_tokens(self) -> int:
+ return self.model_info.max_input_tokens
+
+ def get_context_window_size_in_tokens(self) -> int:
+ return self.get_max_completion_size_in_tokens() + self.get_max_prompt_size_in_tokens()
+
+
+COMPLETE_CONCURRENCY_HOOK_FN = Callable[[LanguageModelAPI], Awaitable[None]] | None
+_complete_concurrency_hook_fn: COMPLETE_CONCURRENCY_HOOK_FN = None
+
+
+def set_language_model_api_complete_concurrency_hook(
+ hook_fn: COMPLETE_CONCURRENCY_HOOK_FN,
+) -> None:
+ global _complete_concurrency_hook_fn
+ _complete_concurrency_hook_fn = hook_fn
diff --git a/imbue_core/imbue_core/agents/llm_apis/llm_testing_utils.py b/imbue_core/imbue_core/agents/llm_apis/llm_testing_utils.py
@@ -0,0 +1,86 @@
+from google.genai.types import CountTokensResponse
+from syrupy.assertion import SnapshotAssertion
+from syrupy.extensions.single_file import SingleFileAmberSnapshotExtension
+from syrupy.extensions.single_file import SingleFileSnapshotExtension
+
+from imbue_core.agents.llm_apis.data_types import CachedCostedModelResponse
+from imbue_core.agents.llm_apis.data_types import CostedLanguageModelResponse
+from imbue_core.caching import AsyncCache
+from imbue_core.frozen_utils import FrozenMapping
+
+
+async def check_llm_responses_in_cache(snapshot: SnapshotAssertion, temp_cache: AsyncCache, suffix: str = "") -> None:
+ """Runs as the test fixture completes to check that the LLM inputs and outputs stay the same, in a human-readable format."""
+
+ async with temp_cache as cache:
+ all_keys: tuple[str, ...] = await cache.get_all_keys() # Contains both the streaming and non-streaming keys?
+ value_by_key: FrozenMapping[str, CachedCostedModelResponse | None] = await cache.get_all(all_keys)
+
+ cache_items: list[tuple[str, CachedCostedModelResponse]] = [
+ (k, v) for k, v in value_by_key.items() if v is not None
+ ]
+ cache_items.sort(key=lambda x: x[1].timestamp)
+ for cache_index, (cache_key, cached_response) in enumerate(cache_items):
+ prompt: bytes = b""
+ joined_responses: bytes = b""
+ metadata_lines: list[str] = []
+
+ metadata_lines.append(f"{cache_index=} (when cache is sorted by timestamp)")
+ metadata_lines.append(f"{cache_key=}") # Keys must be stable and not too big.
+ if cached_response.inputs is not None:
+ prompt = cached_response.inputs.prompt.encode("utf-8")
+ metadata_lines.append(f"request metdata ({type(cached_response.inputs)})")
+ for field, field_value in cached_response.inputs.__dict__.items():
+ if field != "prompt": # print the prompt separately below.
+ metadata_lines.append(f" {field}: {field_value}")
+
+ metadata_lines.append("cached_response metadata:")
+ for field, field_value in cached_response.__dict__.items():
+ if field not in ("inputs", "response"): # already printed above
+ metadata_lines.append(f" {field}: {field_value}")
+
+ if cached_response.response is not None:
+ match cached_response.response:
+ case CostedLanguageModelResponse():
+ joined_responses = "".join([r.text for r in cached_response.response.responses]).encode("utf-8")
+ for response_index, response in enumerate(cached_response.response.responses):
+ metadata_lines.append(f"response[{response_index}] metadata:")
+ for (
+ field,
+ field_value,
+ ) in cached_response.response.__dict__.items():
+ if field != "responses": # already printed the responses above
+ metadata_lines.append(f" {field}: {field_value}")
+ case CountTokensResponse():
+ metadata_lines.append("response metadata:")
+ for field, field_value in cached_response.response.__dict__.items():
+ metadata_lines.append(f" {field}: {field_value}")
+
+ snapshotted_prompt = snapshot(
+ extension_class=SingleFileSnapshotExtension,
+ name=f"{cache_index:03d}_inputs{suffix}",
+ )
+
+ # TODO nasty syrupy hacking
+ snapshot_contents, _ = snapshotted_prompt._recall_data(snapshotted_prompt.index)
+
+ assert (
+ snapshotted_prompt == prompt
+ ), f"Your prompt changed, did you mean for this to happen?\nExpected prompt: {snapshot_contents!r}\nPrompt: {prompt!r}"
+
+ snapshotted_response = snapshot(
+ extension_class=SingleFileSnapshotExtension,
+ name=f"{cache_index:03d}_response{suffix}",
+ )
+
+ assert (
+ snapshotted_response == joined_responses
+ ), "Your response changed; maybe you aren't actually hitting the cache?"
+
+ snapshotted_metadata = snapshot(
+ extension_class=SingleFileAmberSnapshotExtension,
+ name=f"{cache_index:03d}_metadata{suffix}",
+ )
+ assert snapshotted_metadata == "\n".join(
+ metadata_lines
+ ), "Metadata changed; maybe you aren't actually hitting the cache?"
diff --git a/imbue_core/imbue_core/agents/llm_apis/mock_api.py b/imbue_core/imbue_core/agents/llm_apis/mock_api.py
@@ -0,0 +1,193 @@
+import asyncio
+import enum
+from pathlib import Path
+from typing import AsyncGenerator
+
+import toml
+from loguru import logger
+from pydantic.fields import Field
+from pydantic.functional_validators import field_validator
+
+from imbue_core.agents.llm_apis.data_types import CostedLanguageModelResponse
+from imbue_core.agents.llm_apis.data_types import LanguageModelGenerationParams
+from imbue_core.agents.llm_apis.data_types import LanguageModelResponse
+from imbue_core.agents.llm_apis.data_types import LanguageModelResponseUsage
+from imbue_core.agents.llm_apis.data_types import LanguageModelResponseWithLogits
+from imbue_core.agents.llm_apis.data_types import ResponseStopReason
+from imbue_core.agents.llm_apis.data_types import TokenProbability
+from imbue_core.agents.llm_apis.language_model_api import LanguageModelAPI
+from imbue_core.agents.llm_apis.models import ModelInfo
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamDeltaEvent
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamEndEvent
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamEvent
+from imbue_core.itertools import only
+from imbue_core.pydantic_serialization import MutableModel
+
+
+class MockModelName(enum.StrEnum):
+ MOCK_MODEL = "my-mock-model"
+
+
+MY_MOCK_MODEL_INFO = ModelInfo(
+ model_name=MockModelName.MOCK_MODEL,
+ cost_per_input_token=0.0 / 1_000_000,
+ cost_per_output_token=0.0 / 1_000_000,
+ max_input_tokens=32_768,
+ max_output_tokens=None,
+)
+
+
+class Stats(MutableModel):
+ complete_calls: int = 0
+
+
+class LanguageModelMock(LanguageModelAPI):
+ model_name: str = MY_MOCK_MODEL_INFO.model_name
+ cache_path: Path | None = None
+ # FIXME: can't have a mutable class inside a frozen pydantic model
+ stats: Stats = Field(default_factory=Stats)
+
+ @property
+ def model_info(self) -> ModelInfo:
+ return MY_MOCK_MODEL_INFO
+
+ async def complete(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ is_caching_enabled: bool = True,
+ ) -> tuple[LanguageModelResponse, ...]:
+ raise NotImplementedError()
+
+ def _get_token_probabilities(self, response_text: str) -> tuple[tuple[TokenProbability, ...], ...]:
+ return tuple(
+ (TokenProbability(token=pseudo_token, log_probability=0.0, is_stop=False),)
+ for pseudo_token in response_text.split()
+ )
+
+ async def _call_api(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ network_failure_count: int = 0,
+ ) -> CostedLanguageModelResponse:
+ raise NotImplementedError()
+
+ def _get_api_stream(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ ) -> AsyncGenerator[LanguageModelStreamEvent, None]:
+ # TODO Implement streaming support (?)
+ raise NotImplementedError()
+
+
+MOCK_STREAM_SLEEP_TIME = 5.0
+
+
+class FileBasedLanguageModelMock(LanguageModelMock):
+ """
+ A mock LLM API that reads responses from a toml file.
+ The response can either be identified using the toml key or a prompt which is part of the toml dictionary.
+ """
+
+ calls: int = 0
+
+ @field_validator("cache_path") # pyre-ignore[56]: pyre doesn't understand pydantic
+ @classmethod
+ def validate_cache_path(cls, v: Path | None) -> Path | None:
+ if v is None:
+ raise ValueError("Mock responses file path is not set.")
+ if not v.exists():
+ raise ValueError(f"Mock responses file {v} does not exist.")
+ if not v.suffix == ".toml":
+ raise ValueError(f"Mock responses file {v} is not a toml file.")
+ return v
+
+ def _get_user_message_from_prompt(self, prompt: str) -> str:
+ user_prompt = prompt.rsplit("[ROLE=USER]", 1)[-1].strip()
+ return user_prompt
+
+ def get_single_response(self, prompt: str) -> str:
+ return only(self.get_parts_of_response(prompt))
+
+ def get_parts_of_response(self, prompt: str) -> tuple[str, ...]:
+ """
+ Support both of the following possible identifiers:
+ [identifier]
+ prompt = "user message here"
+ [[identifier.responses]]
+ text = "response"
+ [[identifier.responses]]
+ text = "response2"
+
+ [identifier]
+ prompt = "user message here"
+ response = "response"
+ """
+ # this is checked during validation but i guess the type checker doesn't see it
+ assert self.cache_path is not None
+ # TODO: should we try something that is not toml? toml formatting is a little annoying
+ toml_dict = toml.load(self.cache_path)
+ # TODO: currently the identifier is the last user message, because the entire prompt is really long
+ # if we need to support the same user message with different responses, expand this, maybe chat history?
+ identifier = self._get_user_message_from_prompt(prompt)
+ logger.info("Getting response for identifier: {} from {}", identifier, toml_dict)
+ toml_item = toml_dict.get(identifier, None)
+ if toml_item is None:
+ for toml_key, response in toml_dict.items():
+ if "prompt" in response:
+ if response["prompt"] == identifier:
+ toml_item = response
+ break
+ if toml_item is None:
+ raise KeyError(f"No response found for the given identifier {identifier}")
+
+ if "responses" in toml_item:
+ responses = toml_item["responses"]
+ if isinstance(responses, list):
+ return tuple(r["text"] for r in responses if isinstance(r, dict) and "text" in r)
+ raise ValueError(f"Expected 'responses' to be a list of tables in section '{identifier}'")
+
+ if "response" in toml_item:
+ return (str(toml_item["response"]),)
+
+ raise ValueError(f"No valid response or responses found for identifier '{identifier}'")
+
+ async def complete(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ is_caching_enabled: bool = True,
+ ) -> tuple[LanguageModelResponse, ...]:
+ response = self.get_single_response(prompt)
+ self.stats.complete_calls += 1
+ token_probabilities = self._get_token_probabilities(response)
+ return (
+ LanguageModelResponseWithLogits(
+ text=response,
+ token_count=len(token_probabilities),
+ stop_reason=ResponseStopReason.NONE,
+ network_failure_count=0,
+ token_probabilities=token_probabilities,
+ ),
+ )
+
+ async def _get_api_stream(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ ) -> AsyncGenerator[LanguageModelStreamEvent, None]:
+ responses = self.get_parts_of_response(prompt)
+ self.stats.complete_calls += 1
+ if len(responses) == 1:
+ response = responses[0]
+ yield LanguageModelStreamDeltaEvent(delta=response)
+ else:
+ for response in responses:
+ yield LanguageModelStreamDeltaEvent(delta=response)
+ await asyncio.sleep(MOCK_STREAM_SLEEP_TIME)
+ yield LanguageModelStreamEndEvent(
+ usage=LanguageModelResponseUsage(prompt_tokens_used=0, completion_tokens_used=0, dollars_used=0),
+ stop_reason=ResponseStopReason.NONE,
+ )
diff --git a/imbue_core/imbue_core/agents/llm_apis/models.py b/imbue_core/imbue_core/agents/llm_apis/models.py
@@ -0,0 +1,17 @@
+from imbue_core.agents.llm_apis.union_data_types import ProviderSpecificModelInfoUnion
+from imbue_core.pydantic_serialization import SerializableModel
+
+
+class ModelInfo(SerializableModel):
+ model_name: str
+ cost_per_input_token: float
+ cost_per_output_token: float
+ max_input_tokens: int
+ max_output_tokens: int | None
+ # requests per second
+ rate_limit_req: float | None = None
+ # tokens per second
+ rate_limit_tok: float | None = None
+ rate_limit_output_tok: float | None = None
+ max_thinking_budget: int | None = None
+ provider_specific_info: ProviderSpecificModelInfoUnion | None = None
diff --git a/imbue_core/imbue_core/agents/llm_apis/openai_api.py b/imbue_core/imbue_core/agents/llm_apis/openai_api.py
@@ -0,0 +1,654 @@
+import asyncio
+import enum
+import re
+from collections import defaultdict
+from contextlib import contextmanager
+from functools import lru_cache
+from typing import AsyncGenerator
+from typing import Iterator
+from typing import Mapping
+
+import httpx
+import tiktoken
+from loguru import logger
+from openai import AsyncStream
+from openai import InternalServerError
+from openai import NOT_GIVEN
+from openai import NotGiven
+from openai._client import AsyncOpenAI
+from openai._exceptions import APIConnectionError
+from openai._exceptions import BadRequestError
+from openai._exceptions import RateLimitError
+from openai.types.chat import ChatCompletion
+from pydantic.functional_validators import field_validator
+
+from imbue_core.agents.llm_apis.api_utils import convert_prompt_to_openai_messages
+from imbue_core.agents.llm_apis.data_types import CachingInfo
+from imbue_core.agents.llm_apis.data_types import CostedLanguageModelResponse
+from imbue_core.agents.llm_apis.data_types import LanguageModelGenerationParams
+from imbue_core.agents.llm_apis.data_types import LanguageModelResponse
+from imbue_core.agents.llm_apis.data_types import LanguageModelResponseUsage
+from imbue_core.agents.llm_apis.data_types import LanguageModelResponseWithLogits
+from imbue_core.agents.llm_apis.data_types import ResponseStopReason
+from imbue_core.agents.llm_apis.data_types import TokenProbability
+from imbue_core.agents.llm_apis.errors import BadAPIRequestError
+from imbue_core.agents.llm_apis.errors import LanguageModelInvalidModelNameError
+from imbue_core.agents.llm_apis.errors import MissingAPIKeyError
+from imbue_core.agents.llm_apis.errors import PromptTooLongError
+from imbue_core.agents.llm_apis.errors import TransientLanguageModelError
+from imbue_core.agents.llm_apis.models import ModelInfo
+from imbue_core.agents.llm_apis.openai_compatible_api import OpenAICompatibleAPI
+from imbue_core.agents.llm_apis.openai_compatible_api import (
+ _OPENAI_COMPATIBLE_STOP_REASON_TO_STOP_REASON,
+)
+from imbue_core.agents.llm_apis.openai_data_types import OpenAICachingInfo
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamDeltaEvent
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamEndEvent
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamEvent
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamStartEvent
+from imbue_core.frozen_utils import FrozenDict
+from imbue_core.frozen_utils import FrozenMapping
+from imbue_core.itertools import only
+from imbue_core.secrets_utils import get_secret
+
+# note: we require that these model versions are explicit, just like the rest of our dependencies
+# the reason is that these models are actually now mostly deterministic, and it is much easier to debug if we know what model was used
+# also, there's no need to troll yourself by wondering why results have improved (or gotten worse) when you dont realized that the version has shifted under you
+# if you want to use an upgraded model, just upgrade the model to the key displayed here: https://platform.openai.com/docs/models/overview
+# please do NOT set these back to the generic model names! Josh will be very annoyed
+
+FINE_TUNED_GPT4O_MINI_2024_07_18_PREFIX = "ft:gpt-4o-mini-2024-07-18"
+FINE_TUNED_GPT4O_2024_08_06_PREFIX = "ft:gpt-4o-2024-08-06"
+
+
+class OpenAIModelName(enum.StrEnum):
+ GPT_3_5_TURBO = "gpt-3.5-turbo-0125"
+ GPT_4_0613 = "gpt-4-0613"
+ GPT_4_1106_PREVIEW = "gpt-4-1106-preview"
+ GPT_4_0125_PREVIEW = "gpt-4-0125-preview"
+ GPT_4_TURBO_2024_04_09 = "gpt-4-turbo-2024-04-09"
+ GPT_4O_2024_05_13 = "gpt-4o-2024-05-13"
+ GPT_4O_2024_08_06 = "gpt-4o-2024-08-06"
+ GPT_4O_MINI_2024_07_18 = "gpt-4o-mini-2024-07-18"
+ O1_2024_12_17 = "o1-2024-12-17"
+ GPT_4_1_2025_04_14 = "gpt-4.1-2025-04-14"
+ GPT_4_1_MINI_2025_04_14 = "gpt-4.1-mini-2025-04-14"
+ GPT_4_1_NANO_2025_04_14 = "gpt-4.1-nano-2025-04-14"
+ O3_2025_04_16 = "o3-2025-04-16"
+ O3_MINI_2025_01_31 = "o3-mini-2025-01-31"
+ O4_MINI_2025_04_16 = "o4-mini-2025-04-16"
+ GPT_5_2025_08_07 = "gpt-5-2025-08-07"
+ GPT_5_MINI_2025_08_07 = "gpt-5-mini-2025-08-07"
+ GPT_5_NANO_2025_08_07 = "gpt-5-nano-2025-08-07"
+ GPT_5_1_2025_11_13 = "gpt-5.1-2025-11-13"
+
+
+# Using Tier 5 rate limits
+# https://platform.openai.com/settings/organization/limits
+
+OPENAI_MODEL_INFO_BY_NAME: FrozenMapping[OpenAIModelName, ModelInfo] = FrozenDict(
+ {
+ OpenAIModelName.GPT_3_5_TURBO: ModelInfo(
+ model_name=str(OpenAIModelName.GPT_3_5_TURBO),
+ cost_per_input_token=0.5 / 1_000_000,
+ cost_per_output_token=1.5 / 1_000_000,
+ max_input_tokens=16_385,
+ max_output_tokens=4096,
+ rate_limit_req=10000 / 60, # 10000 RPM = 166.67 RPS
+ ),
+ OpenAIModelName.GPT_4_0613: ModelInfo(
+ model_name=str(OpenAIModelName.GPT_4_0613),
+ cost_per_input_token=30.0 / 1_000_000,
+ cost_per_output_token=60.0 / 1_000_000,
+ max_input_tokens=8192,
+ max_output_tokens=8192,
+ rate_limit_req=10000 / 60, # 10000 RPM = 166.67 RPS
+ ),
+ OpenAIModelName.GPT_4_1106_PREVIEW: ModelInfo( # Cannot find this model
+ model_name=str(OpenAIModelName.GPT_4_1106_PREVIEW),
+ cost_per_input_token=10.0 / 1_000_000,
+ cost_per_output_token=30.0 / 1_000_000,
+ max_input_tokens=128_000,
+ max_output_tokens=4096,
+ rate_limit_req=10000 / 60, # 10000 RPM = 166.67 RPS
+ ),
+ OpenAIModelName.GPT_4_0125_PREVIEW: ModelInfo(
+ model_name=str(OpenAIModelName.GPT_4_0125_PREVIEW),
+ cost_per_input_token=10.0 / 1_000_000,
+ cost_per_output_token=30.0 / 1_000_000,
+ max_input_tokens=128_000,
+ max_output_tokens=4096,
+ rate_limit_req=10000 / 60, # 10000 RPM = 166.67 RPS
+ ),
+ OpenAIModelName.GPT_4_TURBO_2024_04_09: ModelInfo(
+ model_name=str(OpenAIModelName.GPT_4_TURBO_2024_04_09),
+ cost_per_input_token=10.0 / 1_000_000,
+ cost_per_output_token=30.0 / 1_000_000,
+ max_input_tokens=128_000,
+ max_output_tokens=4096,
+ rate_limit_req=10000 / 60, # 10000 RPM = 166.67 RPS
+ ),
+ OpenAIModelName.GPT_4O_2024_05_13: ModelInfo(
+ model_name=str(OpenAIModelName.GPT_4O_2024_05_13),
+ cost_per_input_token=5.0 / 1_000_000,
+ cost_per_output_token=15.0 / 1_000_000,
+ max_input_tokens=128_000,
+ max_output_tokens=4096,
+ rate_limit_req=10000 / 60, # 10000 RPM = 166.67 RPS
+ ),
+ OpenAIModelName.GPT_4O_2024_08_06: ModelInfo(
+ model_name=str(OpenAIModelName.GPT_4O_2024_08_06),
+ cost_per_input_token=2.5 / 1_000_000,
+ cost_per_output_token=10.0 / 1_000_000,
+ max_input_tokens=128_000,
+ max_output_tokens=16_384,
+ rate_limit_req=10000 / 60, # 10000 RPM = 166.67 RPS
+ ),
+ OpenAIModelName.GPT_4O_MINI_2024_07_18: ModelInfo(
+ model_name=str(OpenAIModelName.GPT_4O_MINI_2024_07_18),
+ cost_per_input_token=0.15 / 1_000_000,
+ cost_per_output_token=0.60 / 1_000_000,
+ max_input_tokens=128_000,
+ max_output_tokens=16_384,
+ rate_limit_req=30000 / 60, # 30000 RPM = 500 RPS
+ ),
+ OpenAIModelName.O1_2024_12_17: ModelInfo(
+ model_name=str(OpenAIModelName.O1_2024_12_17),
+ cost_per_input_token=15 / 1_000_000,
+ cost_per_output_token=60 / 1_000_000,
+ max_input_tokens=200_000,
+ max_output_tokens=100_000,
+ rate_limit_req=10000 / 60, # 10000 RPM = 166.67 RPS
+ ),
+ OpenAIModelName.GPT_4_1_2025_04_14: ModelInfo(
+ model_name=str(OpenAIModelName.GPT_4_1_2025_04_14),
+ cost_per_input_token=2 / 1_000_000,
+ cost_per_output_token=8 / 1_000_000,
+ max_input_tokens=1_047_576,
+ max_output_tokens=32_768,
+ rate_limit_req=10000 / 60, # 10000 RPM = 166.67 RPS
+ ),
+ OpenAIModelName.GPT_4_1_MINI_2025_04_14: ModelInfo(
+ model_name=str(OpenAIModelName.GPT_4_1_MINI_2025_04_14),
+ cost_per_input_token=0.4 / 1_000_000,
+ cost_per_output_token=1.6 / 1_000_000,
+ max_input_tokens=1_047_576,
+ max_output_tokens=32_768,
+ rate_limit_req=30000 / 60, # 30000 RPM = 500 RPS
+ ),
+ OpenAIModelName.GPT_4_1_NANO_2025_04_14: ModelInfo(
+ model_name=str(OpenAIModelName.GPT_4_1_NANO_2025_04_14),
+ cost_per_input_token=0.1 / 1_000_000,
+ cost_per_output_token=0.4 / 1_000_000,
+ max_input_tokens=1_047_576,
+ max_output_tokens=32_768,
+ rate_limit_req=30000 / 60, # 30000 RPM = 500 RPS
+ ),
+ OpenAIModelName.O4_MINI_2025_04_16: ModelInfo(
+ model_name=str(OpenAIModelName.O4_MINI_2025_04_16),
+ cost_per_input_token=1.1 / 1_000_000,
+ cost_per_output_token=4.4 / 1_000_000,
+ max_input_tokens=200_000,
+ max_output_tokens=100_000,
+ rate_limit_req=30000 / 60, # 30000 RPM = 500 RPS
+ ),
+ OpenAIModelName.O3_2025_04_16: ModelInfo(
+ model_name=str(OpenAIModelName.O3_2025_04_16),
+ cost_per_input_token=10 / 1_000_000,
+ cost_per_output_token=40 / 1_000_000,
+ max_input_tokens=200_000,
+ max_output_tokens=100_000,
+ rate_limit_req=10000 / 60, # 10000 RPM = 166.67 RPS
+ ),
+ OpenAIModelName.O3_MINI_2025_01_31: ModelInfo(
+ model_name=str(OpenAIModelName.O3_MINI_2025_01_31),
+ cost_per_input_token=1.1 / 1_000_000,
+ cost_per_output_token=4.4 / 1_000_000,
+ max_input_tokens=200_000,
+ max_output_tokens=100_000,
+ rate_limit_req=30000 / 60, # 30000 RPM = 500 RPS
+ ),
+ OpenAIModelName.GPT_5_2025_08_07: ModelInfo(
+ model_name=str(OpenAIModelName.GPT_5_2025_08_07),
+ cost_per_input_token=1.25 / 1_000_000,
+ cost_per_output_token=10 / 1_000_000,
+ max_input_tokens=400_000,
+ max_output_tokens=128_000,
+ rate_limit_req=15000 / 60, # 15000 RPM = 250 RPS
+ ),
+ OpenAIModelName.GPT_5_MINI_2025_08_07: ModelInfo(
+ model_name=str(OpenAIModelName.GPT_5_MINI_2025_08_07),
+ cost_per_input_token=0.25 / 1_000_000,
+ cost_per_output_token=2.00 / 1_000_000,
+ max_input_tokens=400_000,
+ max_output_tokens=128_000,
+ rate_limit_req=30000 / 60, # 30000 RPM = 500 RPS
+ ),
+ OpenAIModelName.GPT_5_NANO_2025_08_07: ModelInfo(
+ model_name=str(OpenAIModelName.GPT_5_NANO_2025_08_07),
+ cost_per_input_token=0.05 / 1_000_000,
+ cost_per_output_token=0.40 / 1_000_000,
+ max_input_tokens=400_000,
+ max_output_tokens=128_000,
+ rate_limit_req=30000 / 60, # 30000 RPM = 500 RPS
+ ),
+ OpenAIModelName.GPT_5_1_2025_11_13: ModelInfo(
+ model_name=str(OpenAIModelName.GPT_5_1_2025_11_13),
+ cost_per_input_token=1.25 / 1_000_000,
+ cost_per_output_token=10 / 1_000_000,
+ max_input_tokens=400_000,
+ max_output_tokens=128_000,
+ rate_limit_req=15000 / 60, # 15000 RPM = 250 RPS
+ ),
+ }
+)
+
+
+# Pricing for fine-tuned models taken from here: https://platform.openai.com/docs/pricing
+def get_model_info(model_name: OpenAIModelName) -> ModelInfo:
+ # Check for the family of fine-tuned models.
+ if model_name.startswith(FINE_TUNED_GPT4O_MINI_2024_07_18_PREFIX):
+ return ModelInfo(
+ model_name=str(model_name),
+ cost_per_input_token=0.3 / 1_000_000,
+ cost_per_output_token=1.2 / 1_000_000,
+ max_input_tokens=128_000,
+ max_output_tokens=16_384,
+ rate_limit_req=30000 / 60, # 30000 RPM = 500 RPS (same as base model)
+ )
+ if model_name.startswith(FINE_TUNED_GPT4O_2024_08_06_PREFIX):
+ return ModelInfo(
+ model_name=str(model_name),
+ cost_per_input_token=3.75 / 1_000_000,
+ cost_per_output_token=15.0 / 1_000_000,
+ max_input_tokens=128_000,
+ max_output_tokens=16_384,
+ rate_limit_req=10000 / 60, # 10000 RPM = 166.67 RPS (same as base model)
+ )
+ # Otherwise, return the model info for the base model.
+ return OPENAI_MODEL_INFO_BY_NAME[model_name]
+
+
+_CAPACITY_SEMAPHOR_BY_MODEL_NAME: Mapping[OpenAIModelName, asyncio.Semaphore] = defaultdict(
+ lambda: asyncio.Semaphore(20),
+ {
+ OpenAIModelName.GPT_3_5_TURBO: asyncio.Semaphore(100),
+ OpenAIModelName.GPT_4_0613: asyncio.Semaphore(60),
+ OpenAIModelName.GPT_4_1_NANO_2025_04_14: asyncio.Semaphore(80),
+ },
+)
+
+
+def _get_capacity_semaphor(model_name: OpenAIModelName) -> asyncio.Semaphore:
+ # Fine-tuned models share rate limits with the base model.
+ if model_name.startswith(FINE_TUNED_GPT4O_MINI_2024_07_18_PREFIX):
+ model_name = OpenAIModelName.GPT_4O_MINI_2024_07_18
+ elif model_name.startswith(FINE_TUNED_GPT4O_2024_08_06_PREFIX):
+ model_name = OpenAIModelName.GPT_4O_2024_08_06
+ return _CAPACITY_SEMAPHOR_BY_MODEL_NAME[model_name]
+
+
+def is_openai_reasoning_model(model_name: str) -> bool:
+ return model_name in (
+ OpenAIModelName.O1_2024_12_17,
+ OpenAIModelName.O4_MINI_2025_04_16,
+ OpenAIModelName.O3_2025_04_16,
+ OpenAIModelName.O3_MINI_2025_01_31,
+ OpenAIModelName.GPT_5_2025_08_07,
+ OpenAIModelName.GPT_5_MINI_2025_08_07,
+ OpenAIModelName.GPT_5_NANO_2025_08_07,
+ )
+
+
+def is_fine_tuned_openai_model(model_name: OpenAIModelName) -> bool:
+ return model_name.value.startswith(FINE_TUNED_GPT4O_MINI_2024_07_18_PREFIX) or model_name.value.startswith(
+ FINE_TUNED_GPT4O_2024_08_06_PREFIX
+ )
+
+
+_OPENAI_COMPLETION_ERROR_PATTERN = re.compile(
+ r".*This model's maximum context length is (\d+) tokens, however you requested (\d+) tokens \((\d+) in your prompt; (\d+) for the completion\). Please reduce your prompt; or completion length.*"
+)
+
+_OPENAI_STOP_REASON_TO_STOP_REASON = _OPENAI_COMPATIBLE_STOP_REASON_TO_STOP_REASON
+
+
+@lru_cache(maxsize=1)
+def get_openai_tokenizer(model_name: str) -> tiktoken.Encoding:
+ """Get the appropriate tiktoken tokenizer for an OpenAI model.
+
+ Args:
+ model_name: The OpenAI model name (e.g., "gpt-4o-2024-08-06").
+
+ Returns:
+ The tiktoken Encoding for the model.
+ """
+ if model_name.startswith("gpt-4"):
+ fixed_model_name = "gpt-4"
+ elif model_name.startswith("gpt-3.5"):
+ fixed_model_name = "gpt-3.5"
+ else:
+ # Just default to `gpt-4o` for now, since this seems to be the most recent tokenizer
+ # and we are only using it for estimating token usage
+ fixed_model_name = "gpt-4o"
+ return tiktoken.encoding_for_model(fixed_model_name)
+
+
+def count_openai_tokens(text: str, model_name: str) -> int:
+ return len(get_openai_tokenizer(model_name).encode(text))
+
+
+@contextmanager
+def _openai_exception_manager() -> Iterator[None]:
+ """Simple context manager for parsing OpenAI API exceptions."""
+ try:
+ yield
+ except BadRequestError as e:
+ error_text_match = _OPENAI_COMPLETION_ERROR_PATTERN.search(str(e))
+ if error_text_match is not None:
+ max_prompt_len = int(error_text_match.group(1))
+ prompt_len = int(error_text_match.group(2))
+ logger.debug(
+ "PromptTooLongError max_prompt_len={max_prompt_len} prompt_len={prompt_len}",
+ max_prompt_len=max_prompt_len,
+ prompt_len=prompt_len,
+ )
+ raise PromptTooLongError(prompt_len, max_prompt_len) from e
+ logger.debug("BadAPIRequestError {e}", e=e)
+ raise BadAPIRequestError(str(e)) from e
+ except APIConnectionError as e:
+ logger.debug("Rate limited? Received APIConnectionError {e}", e=e)
+ raise TransientLanguageModelError("APIConnectionError") from e
+ except RateLimitError as e:
+ if e.code == "insufficient_quota":
+ raise
+ logger.debug("Rate limited? {e}", e=e)
+ raise TransientLanguageModelError("RateLimitError") from e
+ except httpx.RemoteProtocolError as e:
+ logger.debug("httpx.RemoteProtocolError {e}", e=e)
+ raise TransientLanguageModelError("httpx.RemoteProtocolError") from e
+ except InternalServerError as e:
+ logger.debug("InternalServerError {e}", e=e)
+ raise TransientLanguageModelError("InternalServerError") from e
+
+
+class OpenAIChatAPI(OpenAICompatibleAPI):
+ model_name: OpenAIModelName = OpenAIModelName.GPT_4O_MINI_2024_07_18
+
+ @field_validator("model_name") # pyre-ignore[56]: pyre doesn't understand pydantic
+ @classmethod
+ def validate_model_name(cls, v: str) -> str:
+ if v not in OPENAI_MODEL_INFO_BY_NAME:
+ raise LanguageModelInvalidModelNameError(v, cls.__name__, list(OPENAI_MODEL_INFO_BY_NAME))
+ return v
+
+ @property
+ def model_info(self) -> ModelInfo:
+ return get_model_info(self.model_name)
+
+ def _get_client(self) -> AsyncOpenAI:
+ api_key = get_secret("OPENAI_API_KEY")
+ if not api_key:
+ raise MissingAPIKeyError("OPENAI_API_KEY environment variable is not set")
+ return AsyncOpenAI( # pyre-ignore[16]: pyre doesn't understand the auto-generated openai._client
+ api_key=api_key
+ )
+
+ async def _call_api(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ network_failure_count: int = 0,
+ ) -> CostedLanguageModelResponse:
+ messages = convert_prompt_to_openai_messages(prompt)
+ with _openai_exception_manager():
+ client = self._get_client()
+
+ is_reasoning_model = is_openai_reasoning_model(self.model_name)
+
+ top_logprobs: NotGiven | int
+ if self.is_using_logprobs:
+ assert not is_reasoning_model, "Logprobs are not supported for reasoning models."
+ top_logprobs = 5
+ else:
+ top_logprobs = NOT_GIVEN
+
+ temperature: NotGiven | float = NOT_GIVEN if is_reasoning_model else params.temperature
+
+ async with _get_capacity_semaphor(self.model_name):
+ api_result = await client.chat.completions.create(
+ model=self.model_name,
+ messages=messages, # type: ignore
+ max_completion_tokens=params.max_tokens,
+ n=params.count,
+ temperature=temperature,
+ stream=False,
+ seed=params.seed,
+ stop=params.stop,
+ presence_penalty=self.presence_penalty,
+ logprobs=self.is_using_logprobs,
+ top_logprobs=top_logprobs,
+ )
+ assert isinstance(api_result, ChatCompletion)
+
+ usage = api_result.usage
+ if usage is not None:
+ completion_tokens = usage.completion_tokens
+ prompt_tokens = usage.prompt_tokens
+ cached_tokens = (
+ usage.prompt_tokens_details.cached_tokens if usage.prompt_tokens_details is not None else 0
+ ) or 0
+ caching_info = CachingInfo(
+ read_from_cache=cached_tokens,
+ provider_specific_data=OpenAICachingInfo(),
+ )
+ else:
+ completion_tokens = 0
+ prompt_tokens = self.count_tokens(prompt)
+ cached_tokens = None
+ caching_info = None
+
+ results: tuple[LanguageModelResponse | LanguageModelResponseWithLogits, ...]
+ if self.is_using_logprobs:
+ results = self._parse_response_with_logprobs(
+ api_result,
+ prompt_tokens=prompt_tokens,
+ stop=params.stop,
+ network_failure_count=network_failure_count,
+ )
+ else:
+ results = self._parse_response_without_logprobs(
+ api_result,
+ prompt_tokens=prompt_tokens,
+ stop=params.stop,
+ network_failure_count=network_failure_count,
+ )
+
+ logger.trace("text: {text}", text=results[0].text)
+ dollars_used = self.calculate_cost(prompt_tokens, completion_tokens)
+ logger.trace("dollars used: {dollars_used}", dollars_used=dollars_used)
+ return CostedLanguageModelResponse(
+ usage=LanguageModelResponseUsage(
+ prompt_tokens_used=prompt_tokens,
+ completion_tokens_used=completion_tokens,
+ dollars_used=dollars_used,
+ caching_info=caching_info,
+ ),
+ responses=tuple(results),
+ )
+
+ async def _get_api_stream(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ ) -> AsyncGenerator[LanguageModelStreamEvent, None]:
+ messages = convert_prompt_to_openai_messages(prompt)
+ with _openai_exception_manager():
+ client = self._get_client()
+
+ is_reasoning_model = is_openai_reasoning_model(self.model_name)
+ temperature: NotGiven | float = NOT_GIVEN if is_reasoning_model else params.temperature
+
+ async with _get_capacity_semaphor(self.model_name):
+ api_result = await client.chat.completions.create(
+ model=self.model_name,
+ messages=messages, # type: ignore
+ max_completion_tokens=params.max_tokens,
+ n=1,
+ temperature=temperature,
+ stop=params.stop,
+ seed=params.seed,
+ stream=True,
+ stream_options={"include_usage": True},
+ presence_penalty=self.presence_penalty,
+ logprobs=False, # not used when streaming
+ top_logprobs=NOT_GIVEN, # only allowed when logprobs=True
+ )
+ assert isinstance(api_result, AsyncStream)
+
+ yield LanguageModelStreamStartEvent()
+
+ usage = None
+ finish_reason: str | None = None
+ async for chunk in api_result:
+ if hasattr(chunk, "usage") and chunk.usage is not None:
+ # final chunk containing usage info after all streaming is done
+ usage = chunk.usage
+ continue
+
+ if chunk.choices:
+ assert len(chunk.choices) == 1, "Currently only count=1 supported for streaming API."
+ data = only(chunk.choices)
+ delta = data.delta.content
+ if delta is not None:
+ yield LanguageModelStreamDeltaEvent(delta=delta)
+ if data.finish_reason:
+ finish_reason = str(data.finish_reason)
+
+ stop_reason = _OPENAI_STOP_REASON_TO_STOP_REASON[str(finish_reason)]
+ # Note, OpenAI API treats end turn and stop sequence the same
+ # Here we assume it is stop sequence if user has specified a stop sequence
+ if params.stop is not None and stop_reason == ResponseStopReason.END_TURN:
+ yield LanguageModelStreamDeltaEvent(delta=params.stop)
+
+ if usage is not None:
+ completion_tokens = usage.completion_tokens
+ prompt_tokens = usage.prompt_tokens
+ dollars_used = self.calculate_cost(prompt_tokens, completion_tokens)
+ cached_tokens = usage.prompt_tokens_details.cached_tokens
+ logger.trace(
+ "Used this many cached read tokens: {cached_tokens}",
+ cached_tokens=cached_tokens,
+ )
+ caching_info = CachingInfo(
+ read_from_cache=cached_tokens,
+ provider_specific_data=OpenAICachingInfo(),
+ )
+ else:
+ completion_tokens = -1
+ prompt_tokens = -1
+ dollars_used = -1
+ caching_info = None
+ logger.trace("dollars used: {dollars_used}", dollars_used=dollars_used)
+
+ yield LanguageModelStreamEndEvent(
+ usage=LanguageModelResponseUsage(
+ prompt_tokens_used=prompt_tokens,
+ completion_tokens_used=completion_tokens,
+ dollars_used=dollars_used,
+ caching_info=caching_info,
+ ),
+ stop_reason=stop_reason,
+ )
+
+ def count_tokens(self, text: str) -> int:
+ return count_openai_tokens(text, self.model_name)
+
+ def _parse_response_without_logprobs(
+ self,
+ response: ChatCompletion,
+ prompt_tokens: int,
+ stop: str | None,
+ network_failure_count: int,
+ ) -> tuple[LanguageModelResponse, ...]:
+ results = []
+ for data in response.choices:
+ assert data.message.content is not None
+ text = data.message.content
+ token_count = self.count_tokens(text) + prompt_tokens
+ stop_reason = _OPENAI_STOP_REASON_TO_STOP_REASON[str(data.finish_reason)]
+ # Note, OpenAI API treats end turn and stop sequence the same
+ # Here we assume it is stop sequence if user has specified a stop sequence
+ if stop is not None and stop_reason == ResponseStopReason.END_TURN:
+ text += stop
+ result = LanguageModelResponse(
+ text=text,
+ token_count=token_count,
+ stop_reason=stop_reason,
+ network_failure_count=network_failure_count,
+ )
+ results.append(result)
+ return tuple(results)
+
+ def _parse_response_with_logprobs(
+ self,
+ response: ChatCompletion,
+ prompt_tokens: int,
+ stop: str | None,
+ network_failure_count: int,
+ ) -> tuple[LanguageModelResponseWithLogits, ...]:
+ results = []
+ for data in response.choices:
+ assert data.message.content is not None
+ logprobs = data.logprobs
+ assert logprobs is not None
+ logprobs_content = logprobs.content
+ assert logprobs_content is not None
+ text = data.message.content
+
+ token_probabilities = []
+ for logprob_token_entry in logprobs_content:
+ top_logprobs = logprob_token_entry.top_logprobs
+ top_entries = [
+ TokenProbability(
+ token=top_logprob_obj.token,
+ log_probability=top_logprob_obj.logprob,
+ is_stop=False,
+ )
+ for top_logprob_obj in top_logprobs
+ ]
+ selected_entry = TokenProbability(
+ token=logprob_token_entry.token,
+ log_probability=logprob_token_entry.logprob,
+ is_stop=False,
+ )
+ if selected_entry in top_entries:
+ top_entries.remove(selected_entry)
+ token_probabilities.append(tuple([selected_entry] + top_entries))
+
+ stop_reason = _OPENAI_STOP_REASON_TO_STOP_REASON[str(data.finish_reason)]
+
+ # Note, OpenAI API treats end turn and stop sequence the same
+ # Here we assume it is stop sequence if user has specified a stop sequence
+ if stop is not None and stop_reason == ResponseStopReason.END_TURN:
+ text += stop
+ token_probabilities.append(
+ tuple(
+ [
+ TokenProbability(
+ token=stop,
+ log_probability=self.stop_token_log_probability,
+ is_stop=True,
+ )
+ ]
+ )
+ )
+ result = LanguageModelResponseWithLogits(
+ text=text,
+ token_probabilities=tuple(token_probabilities),
+ token_count=len(logprobs_content) + prompt_tokens,
+ stop_reason=stop_reason,
+ network_failure_count=network_failure_count,
+ )
+ results.append(result)
+ return tuple(results)
diff --git a/imbue_core/imbue_core/agents/llm_apis/openai_compatible_api.py b/imbue_core/imbue_core/agents/llm_apis/openai_compatible_api.py
@@ -0,0 +1,286 @@
+import math
+from contextlib import contextmanager
+from typing import AsyncGenerator
+from typing import Iterator
+
+import httpx
+from loguru import logger
+from openai import AsyncOpenAI
+from openai import AsyncStream
+from openai import InternalServerError
+from openai import NotGiven
+from openai._exceptions import APIConnectionError
+from openai._exceptions import BadRequestError
+from openai._exceptions import RateLimitError
+from openai.types.chat import ChatCompletion
+
+from imbue_core.agents.llm_apis.api_utils import convert_prompt_to_openai_messages
+from imbue_core.agents.llm_apis.constants import approximate_token_count
+from imbue_core.agents.llm_apis.data_types import CachingInfo
+from imbue_core.agents.llm_apis.data_types import CostedLanguageModelResponse
+from imbue_core.agents.llm_apis.data_types import LanguageModelGenerationParams
+from imbue_core.agents.llm_apis.data_types import LanguageModelResponse
+from imbue_core.agents.llm_apis.data_types import LanguageModelResponseUsage
+from imbue_core.agents.llm_apis.data_types import ResponseStopReason
+from imbue_core.agents.llm_apis.errors import BadAPIRequestError
+from imbue_core.agents.llm_apis.errors import PromptTooLongError
+from imbue_core.agents.llm_apis.errors import TransientLanguageModelError
+from imbue_core.agents.llm_apis.language_model_api import LanguageModelAPI
+from imbue_core.agents.llm_apis.models import ModelInfo
+from imbue_core.agents.llm_apis.openai_data_types import OpenAICachingInfo
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamDeltaEvent
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamEndEvent
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamEvent
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamStartEvent
+from imbue_core.frozen_utils import FrozenDict
+from imbue_core.frozen_utils import FrozenMapping
+from imbue_core.itertools import only
+from imbue_core.secrets_utils import get_secret
+
+_OPENAI_COMPATIBLE_STOP_REASON_TO_STOP_REASON: FrozenMapping[str, ResponseStopReason] = FrozenDict(
+ {
+ "stop": ResponseStopReason.END_TURN,
+ "length": ResponseStopReason.MAX_TOKENS,
+ "tool_calls": ResponseStopReason.TOOL_CALLS,
+ "function_call": ResponseStopReason.FUNCTION_CALL,
+ "content_filter": ResponseStopReason.CONTENT_FILTER,
+ "None": ResponseStopReason.NONE,
+ }
+)
+
+
+# TODO: Should the pre-defined OpenAI model class inherit from this?
+class OpenAICompatibleAPI(LanguageModelAPI):
+ model_name: str
+ base_url: str = "https://api.openai.com/v1"
+ api_key_env: str = "OPENAI_API_KEY"
+ context_window: int | None = None
+ max_output_tokens: int | None = None
+ is_conversational: bool = True
+ presence_penalty: float = 0.0
+ # this shouldn't really ever even be used, but just in case
+ stop_token_log_probability: float = math.log(0.9999)
+
+ @property
+ def model_info(self) -> ModelInfo:
+ if self.context_window is None or self.max_output_tokens is None:
+ raise ValueError("Must provide context_window and max_output_tokens, or subclass must override model_info")
+ return ModelInfo(
+ model_name=self.model_name,
+ cost_per_input_token=0.0,
+ cost_per_output_token=0.0,
+ max_input_tokens=self.context_window,
+ max_output_tokens=self.max_output_tokens,
+ rate_limit_req=None,
+ )
+
+ def _get_client(self) -> AsyncOpenAI:
+ api_key = get_secret(self.api_key_env) if self.api_key_env else ""
+ if not api_key:
+ api_key = "not-required"
+ logger.debug("API key not set, attempting to use API without key.")
+
+ return AsyncOpenAI(
+ api_key=api_key,
+ base_url=self.base_url,
+ )
+
+ @contextmanager
+ def _exception_handler(self, prompt: str) -> Iterator[None]:
+ try:
+ yield
+ except BadRequestError as e:
+ if e.code == "context_length_exceeded":
+ prompt_len = self.count_tokens(prompt)
+ max_prompt_len = self.model_info.max_input_tokens
+ logger.debug(
+ "PromptTooLongError max_prompt_len={max_prompt_len} prompt_len={prompt_len}",
+ max_prompt_len=max_prompt_len,
+ prompt_len=prompt_len,
+ )
+ raise PromptTooLongError(prompt_len, max_prompt_len) from e
+ logger.debug("BadAPIRequestError {e}", e=e)
+ raise BadAPIRequestError(str(e)) from e
+ except APIConnectionError as e:
+ logger.debug("API connection error: {e}", e=e)
+ raise TransientLanguageModelError("APIConnectionError") from e
+ except RateLimitError as e:
+ if e.code == "insufficient_quota":
+ raise
+ logger.debug("Rate limited: {e}", e=e)
+ raise TransientLanguageModelError("RateLimitError") from e
+ except httpx.RemoteProtocolError as e:
+ logger.debug("httpx.RemoteProtocolError {e}", e=e)
+ raise TransientLanguageModelError("httpx.RemoteProtocolError") from e
+ except InternalServerError as e:
+ logger.debug("InternalServerError {e}", e=e)
+ raise TransientLanguageModelError("InternalServerError") from e
+
+ async def _call_api(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ network_failure_count: int = 0,
+ ) -> CostedLanguageModelResponse:
+ messages = convert_prompt_to_openai_messages(prompt)
+
+ with self._exception_handler(prompt):
+ client = self._get_client()
+
+ temperature: NotGiven | float = params.temperature
+
+ api_result = await client.chat.completions.create(
+ model=self.model_name,
+ messages=messages,
+ max_completion_tokens=params.max_tokens,
+ n=params.count,
+ temperature=temperature,
+ stream=False,
+ seed=params.seed,
+ stop=params.stop,
+ presence_penalty=self.presence_penalty,
+ )
+ assert isinstance(api_result, ChatCompletion)
+
+ usage = api_result.usage
+ if usage is not None:
+ completion_tokens = usage.completion_tokens
+ prompt_tokens = usage.prompt_tokens
+ cached_tokens = (
+ usage.prompt_tokens_details.cached_tokens if usage.prompt_tokens_details is not None else 0
+ ) or 0
+ caching_info = CachingInfo(
+ read_from_cache=cached_tokens,
+ provider_specific_data=OpenAICachingInfo(),
+ )
+ else:
+ completion_tokens = 0
+ prompt_tokens = self.count_tokens(prompt)
+ cached_tokens = None
+ caching_info = None
+
+ results = self._parse_response(
+ api_result,
+ prompt_tokens=prompt_tokens,
+ stop=params.stop,
+ network_failure_count=network_failure_count,
+ )
+
+ logger.trace("text: {text}", text=results[0].text)
+ dollars_used = self.calculate_cost(prompt_tokens, completion_tokens)
+ logger.trace("dollars used: {dollars_used}", dollars_used=dollars_used)
+
+ return CostedLanguageModelResponse(
+ usage=LanguageModelResponseUsage(
+ prompt_tokens_used=prompt_tokens,
+ completion_tokens_used=completion_tokens,
+ dollars_used=dollars_used,
+ caching_info=caching_info,
+ ),
+ responses=tuple(results),
+ )
+
+ async def _get_api_stream(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ ) -> AsyncGenerator[LanguageModelStreamEvent, None]:
+ messages = convert_prompt_to_openai_messages(prompt)
+
+ with self._exception_handler(prompt):
+ client = self._get_client()
+
+ temperature: NotGiven | float = params.temperature
+
+ api_result = await client.chat.completions.create(
+ model=self.model_name,
+ messages=messages,
+ max_completion_tokens=params.max_tokens,
+ n=1,
+ temperature=temperature,
+ stop=params.stop,
+ seed=params.seed,
+ stream=True,
+ stream_options={"include_usage": True},
+ presence_penalty=self.presence_penalty,
+ )
+ assert isinstance(api_result, AsyncStream)
+
+ yield LanguageModelStreamStartEvent()
+
+ usage = None
+ finish_reason: str | None = None
+ async for chunk in api_result:
+ if hasattr(chunk, "usage") and chunk.usage is not None:
+ usage = chunk.usage
+ continue
+
+ if chunk.choices:
+ assert len(chunk.choices) == 1, "Currently only count=1 supported for streaming API."
+ data = only(chunk.choices)
+ delta = data.delta.content
+ if delta is not None:
+ yield LanguageModelStreamDeltaEvent(delta=delta)
+ if data.finish_reason:
+ finish_reason = str(data.finish_reason)
+
+ stop_reason = _OPENAI_COMPATIBLE_STOP_REASON_TO_STOP_REASON.get(str(finish_reason), ResponseStopReason.NONE)
+ if params.stop is not None and stop_reason == ResponseStopReason.END_TURN:
+ yield LanguageModelStreamDeltaEvent(delta=params.stop)
+
+ if usage is not None:
+ completion_tokens = usage.completion_tokens
+ prompt_tokens = usage.prompt_tokens
+ dollars_used = self.calculate_cost(prompt_tokens, completion_tokens)
+ cached_tokens = (
+ usage.prompt_tokens_details.cached_tokens if usage.prompt_tokens_details is not None else 0
+ ) or 0
+ caching_info = CachingInfo(
+ read_from_cache=cached_tokens,
+ provider_specific_data=OpenAICachingInfo(),
+ )
+ else:
+ completion_tokens = -1
+ prompt_tokens = -1
+ dollars_used = -1
+ caching_info = None
+ logger.trace("dollars used: {dollars_used}", dollars_used=dollars_used)
+
+ yield LanguageModelStreamEndEvent(
+ usage=LanguageModelResponseUsage(
+ prompt_tokens_used=prompt_tokens,
+ completion_tokens_used=completion_tokens,
+ dollars_used=dollars_used,
+ caching_info=caching_info,
+ ),
+ stop_reason=stop_reason,
+ )
+
+ def count_tokens(self, text: str) -> int:
+ return approximate_token_count(text)
+
+ def _parse_response(
+ self,
+ response: ChatCompletion,
+ prompt_tokens: int,
+ stop: str | None,
+ network_failure_count: int,
+ ) -> tuple[LanguageModelResponse, ...]:
+ results = []
+ for data in response.choices:
+ assert data.message.content is not None
+ text = data.message.content
+ token_count = self.count_tokens(text) + prompt_tokens
+ stop_reason = _OPENAI_COMPATIBLE_STOP_REASON_TO_STOP_REASON.get(
+ str(data.finish_reason), ResponseStopReason.NONE
+ )
+ if stop is not None and stop_reason == ResponseStopReason.END_TURN:
+ text += stop
+ result = LanguageModelResponse(
+ text=text,
+ token_count=token_count,
+ stop_reason=stop_reason,
+ network_failure_count=network_failure_count,
+ )
+ results.append(result)
+ return tuple(results)
diff --git a/imbue_core/imbue_core/agents/llm_apis/openai_data_types.py b/imbue_core/imbue_core/agents/llm_apis/openai_data_types.py
@@ -0,0 +1,13 @@
+from imbue_core.pydantic_serialization import SerializableModel
+
+
+class OpenAIModelInfo(SerializableModel):
+ """Currently there isn't any model info specific to OpenAI"""
+
+ object_type: str = "OpenAIModelInfo"
+
+
+class OpenAICachingInfo(SerializableModel):
+ """Currently there isn't any caching info specific to OpenAI"""
+
+ object_type: str = "OpenAICachingInfo"
diff --git a/imbue_core/imbue_core/agents/llm_apis/stream.py b/imbue_core/imbue_core/agents/llm_apis/stream.py
@@ -0,0 +1,168 @@
+import abc
+import asyncio
+from collections.abc import AsyncIterator
+from contextlib import aclosing
+from typing import Any
+from typing import AsyncGenerator
+from typing import Sequence
+
+import anyio
+
+from imbue_core.agents.llm_apis.data_types import CachedCostedLanguageModelResponse
+from imbue_core.agents.llm_apis.data_types import CostedLanguageModelResponse
+from imbue_core.agents.llm_apis.data_types import LanguageModelResponse
+from imbue_core.agents.llm_apis.data_types import LanguageModelResponseUsage
+from imbue_core.agents.llm_apis.data_types import LanguageModelStreamInputs
+from imbue_core.agents.llm_apis.data_types import ModelResponse
+from imbue_core.agents.llm_apis.data_types import ResponseStopReason
+from imbue_core.agents.primitives.resource_limits import PaymentAuthorization
+from imbue_core.agents.primitives.resource_limits import get_global_resource_limits
+from imbue_core.caching import AsyncCache
+from imbue_core.pydantic_serialization import SerializableModel
+
+
+class LanguageModelStreamStartEvent(SerializableModel):
+ pass
+
+
+class LanguageModelStreamDeltaEvent(SerializableModel):
+ delta: str
+ # TODO add per delta token count (if there is a demand)
+ # TODO add per delta logprobs (if there is a demand)
+
+
+class LanguageModelStreamEndEvent(SerializableModel):
+ usage: LanguageModelResponseUsage
+ stop_reason: ResponseStopReason
+
+
+LanguageModelStreamEvent = LanguageModelStreamStartEvent | LanguageModelStreamDeltaEvent | LanguageModelStreamEndEvent
+
+
+class LanguageModelStreamCallback(abc.ABC, SerializableModel):
+ @abc.abstractmethod
+ async def __call__(self, response: CostedLanguageModelResponse) -> None: ...
+
+
+class UpdateCacheCallback(LanguageModelStreamCallback):
+ key: str
+ cache: AsyncCache[CachedCostedLanguageModelResponse]
+ api_inputs: LanguageModelStreamInputs | None
+
+ async def __call__(self, response: CostedLanguageModelResponse) -> None:
+ async with self.cache:
+ await self.cache.set(
+ self.key,
+ CachedCostedLanguageModelResponse(response=response, inputs=self.api_inputs),
+ )
+
+
+class PromptDebuggingCallback(LanguageModelStreamCallback):
+ prompt: str
+ output_path: anyio.Path
+
+ async def __call__(self, response: CostedLanguageModelResponse) -> None:
+ await self.output_path.write_text(self.prompt + response.responses[0].text)
+
+
+class SettleSpendCallback(LanguageModelStreamCallback):
+ auth: PaymentAuthorization
+
+ async def __call__(self, response: CostedLanguageModelResponse) -> None:
+ dollars_used = response.usage.dollars_used
+ global_resource_limits = get_global_resource_limits()
+ assert global_resource_limits is not None
+ await global_resource_limits.settle_spend(self.auth, dollars_used)
+ return None
+
+
+async def consume_async_iterator(iterator: AsyncIterator[Any]) -> None:
+ async for _ in iterator:
+ ...
+
+
+async def get_cached_response_stream(
+ response: CostedLanguageModelResponse,
+) -> AsyncGenerator[LanguageModelStreamEvent, None]:
+ """Simple stream that return cached response in a single go.
+
+ Implemented here so user get's a consistent interface from stream().
+ """
+ yield LanguageModelStreamStartEvent()
+ yield LanguageModelStreamDeltaEvent(delta=response.responses[0].text)
+ yield LanguageModelStreamEndEvent(usage=response.usage, stop_reason=response.responses[0].stop_reason)
+
+
+class StreamedLanguageModelResponse(ModelResponse):
+ """A stream of LanguageModel API events."""
+
+ text_stream: AsyncIterator[str]
+
+ def __init__(
+ self,
+ # Note event_stream is AsyncGenerator as it supports aclose for better cleanup (unlike AsyncIterator)
+ event_stream: AsyncGenerator[LanguageModelStreamEvent, None],
+ network_failure_count: int,
+ completion_callbacks: Sequence[LanguageModelStreamCallback] = (),
+ ) -> None:
+ # the underlying stream coming from the API
+ self._event_stream = event_stream
+ self.text_stream = self._stream_text()
+ self._final_message_snapshot: LanguageModelResponse | None = None
+
+ self._completion_callbacks = completion_callbacks
+ # this is propogated to final message
+ self._network_failure_count = network_failure_count
+ self.stop_reason: ResponseStopReason | None = None
+
+ async def get_final_message(self) -> LanguageModelResponse:
+ # wait until final message
+ await consume_async_iterator(self._event_stream)
+ assert self._final_message_snapshot is not None
+ return self._final_message_snapshot
+
+ async def __aiter__(self) -> AsyncGenerator[LanguageModelStreamEvent, None]:
+ # iterator of events, with handling of shutdown
+ async with aclosing(self._event_stream) as event_stream:
+ deltas: list[str] = []
+ async for event in event_stream:
+ if isinstance(event, LanguageModelStreamStartEvent):
+ # Need nested if statement here for outer if-elif-else to correctly filter for unknown event types
+ if len(deltas) > 0 or self._final_message_snapshot is not None:
+ raise RuntimeError("Start event should be the first event in stream.")
+ elif isinstance(event, LanguageModelStreamDeltaEvent):
+ deltas.append(event.delta)
+ elif isinstance(event, LanguageModelStreamEndEvent):
+ self.stop_reason = event.stop_reason
+ self._final_message_snapshot = LanguageModelResponse(
+ text="".join(deltas),
+ token_count=(0 if event.usage is None else event.usage.completion_tokens_used),
+ stop_reason=event.stop_reason,
+ network_failure_count=self._network_failure_count,
+ )
+ if self._completion_callbacks is not None:
+ costed_response = CostedLanguageModelResponse(
+ usage=event.usage, responses=(self._final_message_snapshot,)
+ )
+ await asyncio.gather(*[callback(costed_response) for callback in self._completion_callbacks])
+ else:
+ raise ValueError(f"Unknown or Unexpected StreamEvent type {type(event)}.")
+
+ yield event
+
+ async def _stream_text(self) -> AsyncIterator[str]:
+ # iterator of text delta
+ async for event in self:
+ if isinstance(event, LanguageModelStreamDeltaEvent):
+ yield event.delta
+
+ async def __aenter__(self) -> "StreamedLanguageModelResponse":
+ return self
+
+ async def __aexit__(self, *exc: Any) -> None:
+ await self.aclose()
+
+ async def aclose(self) -> None:
+ """Close iterator."""
+ # clean up underlying stream (currently not sure how to do this/if we need to do this)
+ await self._event_stream.aclose()
diff --git a/imbue_core/imbue_core/agents/llm_apis/together_api.py b/imbue_core/imbue_core/agents/llm_apis/together_api.py
@@ -0,0 +1,611 @@
+import asyncio
+import enum
+import math
+from collections import defaultdict
+from contextlib import contextmanager
+from typing import AsyncGenerator
+from typing import Final
+from typing import Iterable
+from typing import Iterator
+from typing import Mapping
+from typing import cast
+
+from loguru import logger
+from pydantic.functional_validators import field_validator
+from together import AsyncTogether
+from together.abstract.api_requestor import APIRequestor
+from together.abstract.api_requestor import AioHTTPSession
+from together.error import APIConnectionError
+from together.error import APIError
+from together.error import AuthenticationError
+from together.error import InvalidRequestError
+from together.error import RateLimitError
+from together.error import ServiceUnavailableError
+from together.together_response import TogetherResponse
+from together.types import TogetherRequest
+from together.types.chat_completions import ChatCompletionResponse
+
+from imbue_core.agents.llm_apis.data_types import CostedLanguageModelResponse
+from imbue_core.agents.llm_apis.data_types import LanguageModelGenerationParams
+from imbue_core.agents.llm_apis.data_types import LanguageModelResponseUsage
+from imbue_core.agents.llm_apis.data_types import LanguageModelResponseWithLogits
+from imbue_core.agents.llm_apis.data_types import ResponseStopReason
+from imbue_core.agents.llm_apis.data_types import TokenProbability
+from imbue_core.agents.llm_apis.errors import BadAPIRequestError
+from imbue_core.agents.llm_apis.errors import LanguageModelInvalidModelNameError
+from imbue_core.agents.llm_apis.errors import MissingAPIKeyError
+from imbue_core.agents.llm_apis.errors import TransientLanguageModelError
+from imbue_core.agents.llm_apis.language_model_api import LanguageModelAPI
+from imbue_core.agents.llm_apis.models import ModelInfo
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamDeltaEvent
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamEndEvent
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamEvent
+from imbue_core.agents.llm_apis.stream import LanguageModelStreamStartEvent
+from imbue_core.frozen_utils import FrozenDict
+from imbue_core.frozen_utils import FrozenMapping
+from imbue_core.itertools import only
+from imbue_core.secrets_utils import get_secret
+
+
+# This function is monkeypatched as the original method does not catch BaseExceptions and asyncio.CancelledErrors are BaseExceptions
+async def arequest(
+ self: APIRequestor,
+ options: TogetherRequest,
+ stream: bool = False,
+ request_timeout: float | tuple[float, float] | None = None,
+) -> tuple[TogetherResponse | AsyncGenerator[TogetherResponse, None], bool, str | None]:
+ ctx = AioHTTPSession()
+ session = await ctx.__aenter__()
+ result = None
+ try:
+ result = await self.arequest_raw(
+ options,
+ session,
+ request_timeout=request_timeout,
+ )
+ resp, got_stream = await self._interpret_async_response(result, stream)
+ except BaseException:
+ # Close the request before exiting session context.
+ if result is not None:
+ result.release()
+ await ctx.__aexit__(None, None, None)
+ raise
+ if got_stream:
+
+ async def wrap_resp() -> AsyncGenerator[TogetherResponse, None]:
+ assert isinstance(resp, AsyncGenerator)
+ try:
+ async for r in resp:
+ yield r
+ finally:
+ # Close the request before exiting session context. Important to do it here
+ # as if stream is not fully exhausted, we need to close the request nevertheless.
+ result.release()
+ await ctx.__aexit__(None, None, None)
+
+ return wrap_resp(), got_stream, self.api_key
+ else:
+ # Close the request before exiting session context.
+ result.release()
+ await ctx.__aexit__(None, None, None)
+ return resp, got_stream, self.api_key
+
+
+APIRequestor.arequest = arequest # pyre-fixme[8]: pyre is confused about this
+
+
+class TogetherAIModelName(enum.StrEnum):
+ GOOGLE_GEMMA_2_27B_IT = "together/google/gemma-2-27b-it"
+ GOOGLE_GEMMA_2_9B_IT = "together/google/gemma-2-9b-it"
+ GOOGLE_GEMMA_2B_IT = "together/google/gemma-2b-it"
+ META_LLAMA_3_2_3B_INSTRUCT_TURBO = "together/meta-llama/Llama-3.2-3B-Instruct-Turbo"
+ META_LLAMA_3_3_70B_INSTRUCT_TURBO = "together/meta-llama/Llama-3.3-70B-Instruct-Turbo"
+ META_LLAMA_3_70B_CHAT_HF = "together/meta-llama/Llama-3-70b-chat-hf"
+ META_LLAMA_3_8B_CHAT_HF = "together/meta-llama/Llama-3-8b-chat-hf"
+ META_LLAMA_3_1_405B_INSTRUCT_TURBO = "together/meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo"
+ META_LLAMA_3_1_70B_INSTRUCT_TURBO = "together/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo"
+ META_LLAMA_3_1_8B_INSTRUCT_TURBO = "together/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo"
+ META_LLAMA_3_70B_INSTRUCT_LITE = "together/meta-llama/Meta-Llama-3-70B-Instruct-Lite"
+ META_LLAMA_3_70B_INSTRUCT_TURBO = "together/meta-llama/Meta-Llama-3-70B-Instruct-Turbo"
+ META_LLAMA_3_8B_INSTRUCT_LITE = "together/meta-llama/Meta-Llama-3-8B-Instruct-Lite"
+ META_LLAMA_3_8B_INSTRUCT_TURBO = "together/meta-llama/Meta-Llama-3-8B-Instruct-Turbo"
+ MISTRALAI_MISTRAL_7B_INSTRUCT_V0_1 = "together/mistralai/Mistral-7B-Instruct-v0.1"
+ MISTRALAI_MISTRAL_7B_INSTRUCT_V0_2 = "together/mistralai/Mistral-7B-Instruct-v0.2"
+ MISTRALAI_MISTRAL_7B_INSTRUCT_V0_3 = "together/mistralai/Mistral-7B-Instruct-v0.3"
+ MISTRALAI_MIXTRAL_8X22B_INSTRUCT_V0_1 = "together/mistralai/Mixtral-8x22B-Instruct-v0.1"
+ MISTRALAI_MIXTRAL_8X7B_INSTRUCT_V0_1 = "together/mistralai/Mixtral-8x7B-Instruct-v0.1"
+ NOUSRESEARCH_NOUS_HERMES_2_MIXTRAL_8X7B_DPO = "together/NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO"
+ DEEPSEEK_R1 = "together/deepseek-ai/DeepSeek-R1"
+ OPENAI_GPT_OSS_20B = "together/openai/gpt-oss-20b"
+ OPENAI_GPT_OSS_120B = "together/openai/gpt-oss-120b"
+ # TOGETHERCOMPUTER_LLAMA_3_8B_CHAT_HF_INT4 = "together/togethercomputer/Llama-3-8b-chat-hf-int4"
+ # TOGETHERCOMPUTER_LLAMA_3_8B_CHAT_HF_INT8 = "together/togethercomputer/Llama-3-8b-chat-hf-int8"
+
+
+# Rate limits for Together AI models based on published API documentation
+# Reference: https://docs.together.ai/docs/rate-limits
+# Using Tier 5 rate limits (6,000 RPM)
+
+TOGETHERAI_MODEL_INFO_BY_NAME: FrozenMapping[TogetherAIModelName, ModelInfo] = FrozenDict(
+ {
+ # ref https://docs.together.ai/docs/chat-models
+ # pricing ref https://www.together.ai/pricing
+ TogetherAIModelName.GOOGLE_GEMMA_2_27B_IT: ModelInfo(
+ model_name=str(TogetherAIModelName.GOOGLE_GEMMA_2_27B_IT),
+ cost_per_input_token=0.8 / 1_000_000,
+ cost_per_output_token=0.8 / 1_000_000,
+ max_input_tokens=8192,
+ max_output_tokens=None,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.GOOGLE_GEMMA_2_9B_IT: ModelInfo(
+ model_name=str(TogetherAIModelName.GOOGLE_GEMMA_2_9B_IT),
+ cost_per_input_token=0.3 / 1_000_000,
+ cost_per_output_token=0.3 / 1_000_000,
+ max_input_tokens=8192,
+ max_output_tokens=None,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.GOOGLE_GEMMA_2B_IT: ModelInfo(
+ model_name=str(TogetherAIModelName.GOOGLE_GEMMA_2B_IT),
+ cost_per_input_token=0.1 / 1_000_000,
+ cost_per_output_token=0.1 / 1_000_000,
+ max_input_tokens=8192,
+ max_output_tokens=None,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.META_LLAMA_3_2_3B_INSTRUCT_TURBO: ModelInfo(
+ model_name=str(TogetherAIModelName.META_LLAMA_3_2_3B_INSTRUCT_TURBO),
+ cost_per_input_token=0.06 / 1_000_000,
+ cost_per_output_token=0.06 / 1_000_000,
+ max_input_tokens=131072,
+ max_output_tokens=None,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.META_LLAMA_3_3_70B_INSTRUCT_TURBO: ModelInfo(
+ model_name=str(TogetherAIModelName.META_LLAMA_3_3_70B_INSTRUCT_TURBO),
+ cost_per_input_token=0.88 / 1_000_000,
+ cost_per_output_token=0.88 / 1_000_000,
+ max_input_tokens=131072,
+ max_output_tokens=None,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.META_LLAMA_3_70B_CHAT_HF: ModelInfo(
+ model_name=str(TogetherAIModelName.META_LLAMA_3_70B_CHAT_HF),
+ cost_per_input_token=0.88 / 1_000_000,
+ cost_per_output_token=0.88 / 1_000_000,
+ max_input_tokens=8192,
+ max_output_tokens=None,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.META_LLAMA_3_8B_CHAT_HF: ModelInfo(
+ model_name=str(TogetherAIModelName.META_LLAMA_3_8B_CHAT_HF),
+ cost_per_input_token=0.2 / 1_000_000,
+ cost_per_output_token=0.2 / 1_000_000,
+ max_input_tokens=8192,
+ max_output_tokens=None,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.META_LLAMA_3_1_405B_INSTRUCT_TURBO: ModelInfo(
+ model_name=str(TogetherAIModelName.META_LLAMA_3_1_405B_INSTRUCT_TURBO),
+ cost_per_input_token=3.5 / 1_000_000,
+ cost_per_output_token=3.5 / 1_000_000,
+ max_input_tokens=130815,
+ max_output_tokens=None,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.META_LLAMA_3_1_70B_INSTRUCT_TURBO: ModelInfo(
+ model_name=str(TogetherAIModelName.META_LLAMA_3_1_70B_INSTRUCT_TURBO),
+ cost_per_input_token=0.88 / 1_000_000,
+ cost_per_output_token=0.88 / 1_000_000,
+ max_input_tokens=131072,
+ max_output_tokens=None,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.META_LLAMA_3_1_8B_INSTRUCT_TURBO: ModelInfo(
+ model_name=str(TogetherAIModelName.META_LLAMA_3_1_8B_INSTRUCT_TURBO),
+ cost_per_input_token=0.18 / 1_000_000,
+ cost_per_output_token=0.18 / 1_000_000,
+ max_input_tokens=131072,
+ max_output_tokens=None,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.META_LLAMA_3_70B_INSTRUCT_LITE: ModelInfo(
+ model_name=str(TogetherAIModelName.META_LLAMA_3_70B_INSTRUCT_LITE),
+ cost_per_input_token=0.54 / 1_000_000,
+ cost_per_output_token=0.54 / 1_000_000,
+ max_input_tokens=8192,
+ max_output_tokens=None,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.META_LLAMA_3_70B_INSTRUCT_TURBO: ModelInfo(
+ model_name=str(TogetherAIModelName.META_LLAMA_3_70B_INSTRUCT_TURBO),
+ cost_per_input_token=0.88 / 1_000_000,
+ cost_per_output_token=0.88 / 1_000_000,
+ max_input_tokens=8192,
+ max_output_tokens=None,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.META_LLAMA_3_8B_INSTRUCT_LITE: ModelInfo(
+ model_name=str(TogetherAIModelName.META_LLAMA_3_8B_INSTRUCT_LITE),
+ cost_per_input_token=0.1 / 1_000_000,
+ cost_per_output_token=0.1 / 1_000_000,
+ max_input_tokens=8192,
+ max_output_tokens=None,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.META_LLAMA_3_8B_INSTRUCT_TURBO: ModelInfo(
+ model_name=str(TogetherAIModelName.META_LLAMA_3_8B_INSTRUCT_TURBO),
+ cost_per_input_token=0.18 / 1_000_000,
+ cost_per_output_token=0.18 / 1_000_000,
+ max_input_tokens=8192,
+ max_output_tokens=None,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.MISTRALAI_MISTRAL_7B_INSTRUCT_V0_1: ModelInfo(
+ model_name=str(TogetherAIModelName.MISTRALAI_MISTRAL_7B_INSTRUCT_V0_1),
+ cost_per_input_token=0.2 / 1_000_000,
+ cost_per_output_token=0.2 / 1_000_000,
+ max_input_tokens=32768,
+ max_output_tokens=None,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.MISTRALAI_MISTRAL_7B_INSTRUCT_V0_2: ModelInfo(
+ model_name=str(TogetherAIModelName.MISTRALAI_MISTRAL_7B_INSTRUCT_V0_2),
+ cost_per_input_token=0.2 / 1_000_000,
+ cost_per_output_token=0.2 / 1_000_000,
+ max_input_tokens=32768,
+ max_output_tokens=None,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.MISTRALAI_MISTRAL_7B_INSTRUCT_V0_3: ModelInfo(
+ model_name=str(TogetherAIModelName.MISTRALAI_MISTRAL_7B_INSTRUCT_V0_3),
+ cost_per_input_token=0.2 / 1_000_000,
+ cost_per_output_token=0.2 / 1_000_000,
+ max_input_tokens=32768,
+ max_output_tokens=None,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.MISTRALAI_MIXTRAL_8X22B_INSTRUCT_V0_1: ModelInfo(
+ model_name=str(TogetherAIModelName.MISTRALAI_MIXTRAL_8X22B_INSTRUCT_V0_1),
+ cost_per_input_token=1.2 / 1_000_000,
+ cost_per_output_token=1.2 / 1_000_000,
+ max_input_tokens=65536,
+ max_output_tokens=None,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.MISTRALAI_MIXTRAL_8X7B_INSTRUCT_V0_1: ModelInfo(
+ model_name=str(TogetherAIModelName.MISTRALAI_MIXTRAL_8X7B_INSTRUCT_V0_1),
+ cost_per_input_token=0.6 / 1_000_000,
+ cost_per_output_token=0.6 / 1_000_000,
+ max_input_tokens=32768,
+ max_output_tokens=None,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.NOUSRESEARCH_NOUS_HERMES_2_MIXTRAL_8X7B_DPO: ModelInfo(
+ model_name=str(TogetherAIModelName.NOUSRESEARCH_NOUS_HERMES_2_MIXTRAL_8X7B_DPO),
+ cost_per_input_token=0.6 / 1_000_000,
+ cost_per_output_token=0.6 / 1_000_000,
+ max_input_tokens=32768,
+ max_output_tokens=None,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.DEEPSEEK_R1: ModelInfo(
+ model_name=str(TogetherAIModelName.DEEPSEEK_R1),
+ cost_per_input_token=3.0 / 1_000_000,
+ cost_per_output_token=7.0 / 1_000_000,
+ max_input_tokens=32768,
+ max_output_tokens=None,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.OPENAI_GPT_OSS_20B: ModelInfo(
+ model_name=str(TogetherAIModelName.OPENAI_GPT_OSS_20B),
+ cost_per_input_token=0.00 / 1_000_000,
+ cost_per_output_token=0.00 / 1_000_000,
+ max_input_tokens=131_072,
+ max_output_tokens=131_072,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ TogetherAIModelName.OPENAI_GPT_OSS_120B: ModelInfo(
+ model_name=str(TogetherAIModelName.OPENAI_GPT_OSS_120B),
+ cost_per_input_token=0.00 / 1_000_000,
+ cost_per_output_token=0.00 / 1_000_000,
+ max_input_tokens=131_072,
+ max_output_tokens=131_072,
+ rate_limit_req=6000 / 60, # 6000 RPM = 100.00 RPS
+ ),
+ }
+)
+
+
+def _default_capacity_semaphor() -> asyncio.Semaphore:
+ return asyncio.Semaphore(100)
+
+
+_CAPACITY_SEMAPHOR_BY_MODEL_NAME: Mapping[str, asyncio.Semaphore] = defaultdict(_default_capacity_semaphor)
+
+
+_ROLE_TO_TOGETHERAI_ROLE: Final[FrozenMapping] = FrozenDict(
+ {
+ "HUMAN": "user",
+ "ASSISTANT": "assistant",
+ "SYSTEM": "system",
+ "SYSTEM_CACHED": "system",
+ "USER": "user",
+ "USER_CACHED": "user",
+ }
+)
+
+# ref: https://github.com/togethercomputer/together-python/blob/main/src/together/types/common.py#L13
+_TOGETHERAI_STOP_REASON_TO_STOP_REASON: Final[FrozenMapping[str, ResponseStopReason]] = FrozenDict(
+ {
+ "length": ResponseStopReason.MAX_TOKENS,
+ # This is a little sketchy, we treat them the same since we don't know which models emit stop sequence reasons
+ # Since we don't want to break downstream applications that may require ending in the stop sequence
+ # This is similar to how openai models are treated
+ "stop": ResponseStopReason.END_TURN,
+ "eos": ResponseStopReason.END_TURN,
+ "tool_calls": ResponseStopReason.TOOL_CALLS,
+ "error": ResponseStopReason.ERROR,
+ "None": ResponseStopReason.NONE,
+ }
+)
+
+
+def convert_prompt_to_together_messages(prompt: str) -> list[dict[str, str]]:
+ prompt = prompt.lstrip()
+ assert prompt.endswith("\n[ROLE=ASSISTANT]\n"), "prompt must end with [ROLE=ASSISTANT], prompt=\n" + prompt
+ prompt = "".join(prompt.rsplit("\n[ROLE=ASSISTANT]\n", 1))
+ assert prompt.startswith("[ROLE=")
+ prompt = prompt.replace("[ROLE=", "", 1)
+ chunks = prompt.split("\n[ROLE=")
+ messages: list[dict[str, str]] = []
+ for chunk in chunks:
+ lines = chunk.split("\n")
+ role = lines[0].strip().rstrip("]")
+ assert role in (
+ "HUMAN",
+ "ASSISTANT",
+ "USER",
+ "SYSTEM",
+ "SYSTEM_CACHED",
+ "USER_CACHED",
+ ), f"Unknown role {role} in prompt {prompt}"
+ lines.pop(0)
+ if len(messages) > 0:
+ messages[-1]["content"] = messages[-1]["content"] + "\n"
+ messages.append({"role": _ROLE_TO_TOGETHERAI_ROLE[role], "content": "\n".join(lines)})
+ return messages
+
+
+@contextmanager
+def _together_exception_manager() -> Iterator[None]:
+ """Simple context manager for parsing TogetherAI API exceptions."""
+ # ref https://github.com/togethercomputer/together-python/blob/main/src/together/abstract/api_requestor.py#L332
+ try:
+ yield
+ except RateLimitError as e:
+ logger.info("Rate limited? {}", e)
+ raise TransientLanguageModelError("RateLimitError") from e
+ except InvalidRequestError as e:
+ logger.info("BadAPIRequestError {}", e)
+ raise BadAPIRequestError(str(e)) from e
+ except AuthenticationError as e:
+ logger.info("API Authentication error {}", e)
+ raise
+ except APIError as e:
+ logger.info("Received APIError {}", e)
+ raise
+ except ServiceUnavailableError as e:
+ logger.info("Received ServiceUnavailableError {}", e)
+ raise TransientLanguageModelError("ServiceUnavailableError") from e
+ except APIConnectionError as e:
+ logger.info("Received APIConnectionError {}", e)
+ raise TransientLanguageModelError("APIConnectionError") from e
+ # Note, the together python SDK uses aiohttp under the hood
+ # but takes care of parsing the main aiohttp exceptions into together API exceptions
+ # ref https://github.com/togethercomputer/together-python/blob/main/src/together/abstract/api_requestor.py#L554
+
+
+class TogetherAPI(LanguageModelAPI):
+ model_name: TogetherAIModelName = TogetherAIModelName.META_LLAMA_3_1_8B_INSTRUCT_TURBO
+ is_conversational: bool = True
+ presence_penalty: float = 0.0
+ # this shouldn't really ever even be used, but just in case
+ stop_token_log_probability: float = math.log(0.9999)
+
+ @field_validator("model_name") # pyre-ignore[56]: pyre doesn't understand pydantic
+ @classmethod
+ def validate_model_name(cls, v: str) -> str:
+ if v not in TOGETHERAI_MODEL_INFO_BY_NAME:
+ raise LanguageModelInvalidModelNameError(v, cls.__name__, list(TOGETHERAI_MODEL_INFO_BY_NAME))
+ return v
+
+ @property
+ def model_info(self) -> ModelInfo:
+ return TOGETHERAI_MODEL_INFO_BY_NAME[self.model_name]
+
+ @property
+ def external_model_name(self) -> str:
+ return self.model_name.replace("together/", "")
+
+ def _get_client(self) -> AsyncTogether:
+ api_key = get_secret("TOGETHER_API_KEY")
+ if not api_key:
+ raise MissingAPIKeyError("TOGETHER_API_KEY environment variable is not set")
+ return AsyncTogether(api_key=api_key)
+
+ async def _call_api(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ network_failure_count: int = 0,
+ ) -> CostedLanguageModelResponse:
+ if params.max_tokens is None:
+ logger.debug(
+ "Togetherai API breaks if `max_tokens` not specified. Defaulting to `max_tokens=512`, make sure to specify this if you want something different."
+ )
+ params.max_tokens = 512
+
+ with _together_exception_manager():
+ messages = convert_prompt_to_together_messages(prompt)
+ client = self._get_client()
+ async with _CAPACITY_SEMAPHOR_BY_MODEL_NAME[self.model_name]:
+ # ref: https://github.com/togethercomputer/together-python/blob/main/src/together/resources/chat/completions.py#L153
+ api_result = await client.chat.completions.create(
+ model=self.external_model_name,
+ messages=messages,
+ max_tokens=params.max_tokens,
+ stop=[params.stop] if params.stop else None,
+ temperature=params.temperature,
+ top_k=5,
+ presence_penalty=self.presence_penalty,
+ stream=False,
+ logprobs=True,
+ n=params.count,
+ # currently don't specify this since tokenizer may change between models
+ logit_bias=None,
+ )
+ assert isinstance(api_result, ChatCompletionResponse)
+
+ results = []
+ choices = api_result.choices
+ assert choices is not None
+ for data in choices:
+ message = data.message
+ assert message is not None
+ text = message.content
+ assert text is not None
+ assert isinstance(text, str) # TODO: this is suspicious
+
+ # TogetherAI only provides the logprob for the selected token
+ logprobs = data.logprobs
+ assert logprobs is not None
+ tokens = logprobs.tokens
+ token_logprobs = logprobs.token_logprobs
+ assert tokens is not None and token_logprobs is not None
+ assert all(token is not None for token in tokens)
+ assert all(logprob is not None for logprob in token_logprobs)
+ tokens = cast(Iterable[str], tokens)
+ token_logprobs = cast(Iterable[float], token_logprobs)
+ token_probabilities = [
+ (TokenProbability(token=token, log_probability=logprob, is_stop=False),)
+ for token, logprob in zip(tokens, token_logprobs)
+ ]
+
+ if data.finish_reason:
+ stop_reason = _TOGETHERAI_STOP_REASON_TO_STOP_REASON[data.finish_reason.value]
+ else:
+ stop_reason = ResponseStopReason.NONE
+ stop = params.stop
+ if stop is not None and stop_reason == ResponseStopReason.END_TURN:
+ text += stop
+ token_probabilities.append(
+ (
+ TokenProbability(
+ token=stop,
+ log_probability=self.stop_token_log_probability,
+ is_stop=True,
+ ),
+ )
+ )
+ result = LanguageModelResponseWithLogits(
+ text=text,
+ token_probabilities=tuple(token_probabilities),
+ token_count=len(token_probabilities),
+ stop_reason=stop_reason,
+ network_failure_count=network_failure_count,
+ )
+ results.append(result)
+
+ logger.trace("text: " + results[0].text)
+ if api_result.usage is not None:
+ completion_tokens = api_result.usage.completion_tokens
+ prompt_tokens = api_result.usage.prompt_tokens
+ else:
+ completion_tokens = 0
+ prompt_tokens = 0
+ dollars_used = self.calculate_cost(prompt_tokens, completion_tokens)
+ logger.trace("dollars used: {}", dollars_used)
+ return CostedLanguageModelResponse(
+ usage=LanguageModelResponseUsage(
+ prompt_tokens_used=prompt_tokens,
+ completion_tokens_used=completion_tokens,
+ dollars_used=dollars_used,
+ ),
+ responses=tuple(results),
+ )
+
+ async def _get_api_stream(
+ self,
+ prompt: str,
+ params: LanguageModelGenerationParams,
+ ) -> AsyncGenerator[LanguageModelStreamEvent, None]:
+ if params.max_tokens is None:
+ logger.debug(
+ "Togetherai API breaks if `max_tokens` not specified. Defaulting to `max_tokens=512`, make sure to specify this if you want something different."
+ )
+ params.max_tokens = 512
+
+ with _together_exception_manager():
+ messages = convert_prompt_to_together_messages(prompt)
+ client = self._get_client()
+ async with _CAPACITY_SEMAPHOR_BY_MODEL_NAME[self.model_name]:
+ # ref: https://github.com/togethercomputer/together-python/blob/main/src/together/resources/chat/completions.py#L153
+ api_result = await client.chat.completions.create(
+ model=self.external_model_name,
+ messages=messages,
+ max_tokens=params.max_tokens,
+ stop=[params.stop] if params.stop else None,
+ temperature=params.temperature,
+ top_k=5,
+ presence_penalty=self.presence_penalty,
+ stream=True,
+ # currently we don't support logprobs with streaming
+ logprobs=False,
+ n=1,
+ # currently don't specify this since tokenizer may change between models
+ logit_bias=None,
+ )
+ assert isinstance(api_result, AsyncGenerator)
+
+ yield LanguageModelStreamStartEvent()
+
+ usage = None
+ finish_reason: str | None = None
+ async for chunk in api_result:
+ if chunk.usage:
+ usage = chunk.usage
+
+ if chunk.finish_reason:
+ finish_reason = chunk.finish_reason.value
+
+ chunk_choices = chunk.choices
+ if chunk_choices:
+ assert len(chunk_choices) == 1, "Currently only count=1 supported for streaming API."
+ delta = only(chunk_choices).delta
+ if delta and delta.content:
+ yield LanguageModelStreamDeltaEvent(delta=delta.content)
+
+ stop_reason = _TOGETHERAI_STOP_REASON_TO_STOP_REASON[str(finish_reason)]
+
+ if params.stop is not None and stop_reason == ResponseStopReason.END_TURN:
+ yield LanguageModelStreamDeltaEvent(delta=params.stop)
+
+ if usage is not None:
+ completion_tokens = usage.completion_tokens
+ prompt_tokens = usage.prompt_tokens
+ else:
+ completion_tokens = 0
+ prompt_tokens = 0
+ dollars_used = self.calculate_cost(prompt_tokens, completion_tokens)
+ logger.trace("dollars used: {}", dollars_used)
+
+ yield LanguageModelStreamEndEvent(
+ usage=LanguageModelResponseUsage(
+ prompt_tokens_used=prompt_tokens,
+ completion_tokens_used=completion_tokens,
+ dollars_used=dollars_used,
+ ),
+ stop_reason=stop_reason,
+ )
diff --git a/imbue_core/imbue_core/agents/llm_apis/union_data_types.py b/imbue_core/imbue_core/agents/llm_apis/union_data_types.py
@@ -0,0 +1,20 @@
+from typing import Annotated
+
+from pydantic import Tag
+
+from imbue_core.agents.llm_apis.anthropic_data_types import AnthropicCachingInfo
+from imbue_core.agents.llm_apis.anthropic_data_types import AnthropicModelInfo
+from imbue_core.agents.llm_apis.openai_data_types import OpenAICachingInfo
+from imbue_core.agents.llm_apis.openai_data_types import OpenAIModelInfo
+from imbue_core.pydantic_serialization import build_discriminator
+
+ProviderSpecificModelInfoUnion = Annotated[
+ Annotated[AnthropicModelInfo, Tag("AnthropicModelInfo")] | Annotated[OpenAIModelInfo, Tag("OpenAIModelInfo")],
+ build_discriminator(),
+]
+
+ProviderSpecificCachingInfoUnion = Annotated[
+ Annotated[AnthropicCachingInfo, Tag("AnthropicCachingInfo")]
+ | Annotated[OpenAICachingInfo, Tag("OpenAICachingInfo")],
+ build_discriminator(),
+]
diff --git a/imbue_core/imbue_core/agents/primitives/errors.py b/imbue_core/imbue_core/agents/primitives/errors.py
@@ -0,0 +1,14 @@
+class SpecialError(BaseException):
+ pass
+
+
+class ResourceLimitExceeded(SpecialError):
+ pass
+
+
+class DollarLimitExceeded(ResourceLimitExceeded):
+ pass
+
+
+class MaximumSpendExceeded(ResourceLimitExceeded):
+ """This happens if you try to make a transation that is larger than your per-hour spend rate"""
diff --git a/imbue_core/imbue_core/agents/primitives/resource_limits.py b/imbue_core/imbue_core/agents/primitives/resource_limits.py
@@ -0,0 +1,485 @@
+import asyncio
+import datetime
+import os
+from asyncio import CancelledError
+from asyncio import Task
+from asyncio import TaskGroup
+from typing import Any
+from typing import Callable
+from typing import Coroutine
+from typing import Optional
+from uuid import uuid4
+
+import attr
+from loguru import logger
+
+from imbue_core.agents.primitives.errors import DollarLimitExceeded
+from imbue_core.agents.primitives.errors import MaximumSpendExceeded
+from imbue_core.async_monkey_patches import safe_cancel
+from imbue_core.itertools import first
+from imbue_core.serialization_types import Serializable
+from imbue_core.time_utils import get_current_time
+
+# TODO: someday in the future we can be smarter about this...
+_AUTH_PAYMENT_TIMEOUT_SECONDS = 60 * 60 * 24
+_ONE_HOUR_IN_SECONDS = 60 * 60
+
+
+class InvalidResourceLimitsError(Exception):
+ pass
+
+
+class AuthorizationInvalidated(Exception):
+ pass
+
+
+@attr.s(auto_attribs=True, frozen=True)
+class PaymentAuthorization:
+ dollars: float
+ authorization_id: str
+ authorized_at: datetime.datetime
+
+
+@attr.s(auto_attribs=True, frozen=True)
+class ResourceLimitState(Serializable):
+ hard_cap_dollars: float
+ hard_cap_seconds: float
+ warn_cap_dollars: float
+ warn_cap_seconds: float
+ dollars_per_hour: float | None
+
+ @classmethod
+ def build_for_increase(
+ cls,
+ hard_cap_dollars: float = 0.001,
+ hard_cap_seconds: float = 0.001,
+ warn_cap_dollars: float = 0.001,
+ warn_cap_seconds: float = 0.001,
+ dollars_per_hour: float | None = None,
+ ) -> "ResourceLimitState":
+ return cls(
+ hard_cap_dollars=hard_cap_dollars,
+ hard_cap_seconds=hard_cap_seconds,
+ warn_cap_dollars=warn_cap_dollars,
+ warn_cap_seconds=warn_cap_seconds,
+ dollars_per_hour=dollars_per_hour,
+ )
+
+
+def _float_or_none(value: str | None) -> float | None:
+ if value is None:
+ return None
+ return float(value)
+
+
+@attr.s(auto_attribs=True)
+class ResourceLimits:
+ hard_cap_dollars: float
+ hard_cap_seconds: float
+ warn_cap_dollars: float
+ warn_cap_seconds: float
+ # note that setting this effectively caps the size of any given authorization request to this quantity as well
+ # (since it is impossible to spend less than $X per hour if you are spending $X+1 in total)
+ # in such a case, MaximumSpendExceeded will be raised
+ dollars_per_hour: float | None = None
+ parent_limits: Optional["ResourceLimits"] = None
+ dollars_spent: float = 0.0
+ save_spend_callback: Callable[[float], Coroutine[Any, Any, None]] | None = None
+ open_authorizations: dict[str, PaymentAuthorization] = attr.ib(factory=dict)
+ recent_spend_events: list[PaymentAuthorization] = attr.ib(factory=list)
+ # ensure that only a single spend is being authorized at once
+ spend_lock: asyncio.Lock = attr.ib(factory=asyncio.Lock)
+ # prevent us from mutating our state from multiple coroutines at once
+ state_lock: asyncio.Lock = attr.ib(factory=asyncio.Lock)
+ # triggered when the limits are updated
+ limits_updated_event: asyncio.Event = attr.ib(factory=asyncio.Event)
+ # triggered when a settlement happens
+ next_settlement_event: asyncio.Event = attr.ib(factory=asyncio.Event)
+
+ @classmethod
+ def build(
+ cls,
+ *,
+ max_dollars: float | None = None,
+ max_seconds: float | None = None,
+ warn_fraction: float | None = None,
+ dollars_per_hour: float | None = None,
+ ) -> "ResourceLimits":
+ if max_dollars is None and "DEFAULT_MAX_HAMMER_DOLLARS" in os.environ:
+ max_dollars = _float_or_none(os.getenv("DEFAULT_MAX_HAMMER_DOLLARS"))
+ assert max_dollars != float("inf"), "max_dollars must be finite"
+ if max_dollars is None:
+ max_dollars = 0.0
+ assert max_dollars >= 0, "max_dollars must be non-negative"
+ if max_seconds is None and "DEFAULT_MAX_HAMMER_SECONDS" in os.environ:
+ max_seconds = _float_or_none(os.getenv("DEFAULT_MAX_HAMMER_SECONDS"))
+ if max_seconds is None:
+ max_seconds = float("inf")
+ assert max_seconds >= 0, "max_seconds must be non-negative"
+ if warn_fraction is None:
+ # TODO: is it DEFAULT_WARN_FRACTION or DEFAULT_HAMMER_WARN_FRACTION?
+ if "DEFAULT_WARN_FRACTION" in os.environ:
+ warn_fraction = _float_or_none(os.getenv("DEFAULT_HAMMER_WARN_FRACTION"))
+ assert warn_fraction is not None
+ else:
+ warn_fraction = 0.25
+ if dollars_per_hour is None and "DEFAULT_DOLLARS_PER_HOUR" in os.environ:
+ dollars_per_hour = _float_or_none(os.getenv("DEFAULT_DOLLARS_PER_HOUR"))
+ result = cls(
+ hard_cap_dollars=max_dollars,
+ hard_cap_seconds=max_seconds,
+ warn_cap_dollars=max_dollars * warn_fraction,
+ warn_cap_seconds=max_seconds * warn_fraction,
+ dollars_per_hour=dollars_per_hour,
+ )
+ # check that we're not currently in a hammer, otherwise should be calling create_restricted_limits instead
+ try:
+ if get_global_resource_limits() is not None:
+ # If you encounter this, it probably means you're trying to mix hammer and non-hammer resource limiting.
+ # For simplicity and correctness, try to avoid that.
+ raise InvalidResourceLimitsError(
+ "Should not create a new ResourceLimits while global limits are in place. Instead, call create_restricted_limits"
+ )
+ except RuntimeError:
+ pass
+ return result
+
+ def create_restricted_limits(
+ self,
+ *,
+ max_dollars: float | None = None,
+ max_seconds: float | None = None,
+ warn_fraction: float | None = None,
+ hard_cap_dollars: float | None = None,
+ hard_cap_seconds: float | None = None,
+ warn_cap_dollars: float | None = None,
+ warn_cap_seconds: float | None = None,
+ dollars_per_hour: float | None = None,
+ ) -> "ResourceLimits":
+ if max_dollars is not None:
+ assert hard_cap_dollars is None, "Cannot specify both max_dollars and hard_cap_dollars"
+ hard_cap_dollars = max_dollars
+ if warn_fraction is not None:
+ assert warn_cap_dollars is None, "Cannot specify both warn_fraction and warn_cap_dollars"
+ warn_cap_dollars = max_dollars * warn_fraction
+
+ if max_seconds is not None:
+ assert hard_cap_seconds is None, "Cannot specify both max_seconds and hard_cap_seconds"
+ hard_cap_seconds = max_seconds
+ if warn_fraction is not None:
+ assert warn_cap_seconds is None, "Cannot specify both warn_fraction and warn_cap_seconds"
+ warn_cap_seconds = max_seconds * warn_fraction
+
+ if hard_cap_dollars is not None:
+ hard_cap_dollars = min(hard_cap_dollars, self.hard_cap_dollars)
+ if hard_cap_seconds is not None:
+ hard_cap_seconds = min(hard_cap_seconds, self.hard_cap_seconds)
+ if warn_cap_dollars is not None:
+ warn_cap_dollars = min(warn_cap_dollars, self.warn_cap_dollars)
+ if warn_cap_seconds is not None:
+ warn_cap_seconds = min(warn_cap_seconds, self.warn_cap_seconds)
+ if dollars_per_hour is not None and self.dollars_per_hour is not None:
+ dollars_per_hour = min(dollars_per_hour, self.dollars_per_hour)
+ return ResourceLimits(
+ hard_cap_dollars=hard_cap_dollars or self.hard_cap_dollars,
+ hard_cap_seconds=hard_cap_seconds or self.hard_cap_seconds,
+ warn_cap_dollars=warn_cap_dollars or self.warn_cap_dollars,
+ warn_cap_seconds=warn_cap_seconds or self.warn_cap_seconds,
+ dollars_per_hour=dollars_per_hour or self.dollars_per_hour,
+ parent_limits=self,
+ )
+
+ def _get_excessive_spend_message(self, dollars: float, debug_info: Any = None) -> str:
+ msg = f"Tried to spend {dollars} but only {self.hard_cap_dollars - self.dollars_spent} left (of {self.hard_cap_dollars})."
+ if self.hard_cap_dollars == 0:
+ msg += " You might want to set DEFAULT_MAX_HAMMER_DOLLARS to something non-zero"
+ if debug_info is not None:
+ msg += f"\nDebug info: {debug_info}"
+ return msg
+
+ async def authorize_spend(self, dollars: float, debug_info: Any | None = None) -> PaymentAuthorization:
+ # note that we purposefully lock here, even though we are potentially waiting inside the loop below.
+ # the reason for this is that otherwise a large transaction could be starved by a series of smaller transactions
+ # this is annoying to reason about, so this makes it FIFO instead (though potentially at the cost of having to
+ # wait for a while if you are near the limit and there are smaller transactions that could have made it through)
+ async with self.spend_lock:
+ await self._clear_old_authorizations()
+
+ if self.dollars_spent + dollars > self.hard_cap_dollars:
+ raise DollarLimitExceeded(self._get_excessive_spend_message(dollars, debug_info))
+
+ # if we have outstanding authorizations that mean that this transaction would put us over the limit, wait
+ while (await self.get_dollars_authorized_and_spent()) + dollars > self.hard_cap_dollars and (
+ await self.get_dollars_currently_authorized()
+ ) > 0:
+ await self.next_settlement_event.wait()
+
+ # now that some authorizations have settled and we're done waiting, have to check again if this would put us over
+ if self.dollars_spent + dollars > self.hard_cap_dollars:
+ raise DollarLimitExceeded(self._get_excessive_spend_message(dollars))
+
+ dollars_per_hour = self.dollars_per_hour
+ if dollars_per_hour is not None:
+ if dollars > dollars_per_hour:
+ raise MaximumSpendExceeded(
+ f"Tried to spend ${dollars} but only ${dollars_per_hour} / hr allowed, which caps the total spend"
+ )
+
+ # wait until the spend rate is low enough
+ while (await self.get_dollars_authorized_and_spent_in_the_last_hour()) + dollars > dollars_per_hour:
+ oldest_event = first(
+ sorted(
+ [x for x in self.recent_spend_events],
+ key=lambda x: x.authorized_at,
+ )
+ )
+ if oldest_event is None:
+ break
+ time_since_oldest_event = (get_current_time() - oldest_event.authorized_at).total_seconds()
+ time_until_next_event_expires = _ONE_HOUR_IN_SECONDS - time_since_oldest_event
+ logger.debug(
+ f"Waiting until spend rate has subsided (currently at {(await self.get_dollars_authorized_and_spent_in_the_last_hour())} / hr)"
+ )
+ waiting_task = asyncio.create_task(self._wait_until_updated())
+ try:
+ await asyncio.wait_for(waiting_task, timeout=time_until_next_event_expires + 0.01)
+ except TimeoutError:
+ pass
+ await self._clear_old_authorizations()
+
+ assert (await self.get_dollars_authorized_and_spent_in_the_last_hour()) + dollars <= dollars_per_hour
+
+ async with self.state_lock:
+ if self.parent_limits is None:
+ auth = PaymentAuthorization(
+ dollars=dollars,
+ authorization_id=uuid4().hex,
+ authorized_at=get_current_time(),
+ )
+ else:
+ auth = await self.parent_limits.authorize_spend(dollars)
+ self.open_authorizations[auth.authorization_id] = auth
+ return auth
+
+ async def settle_spend(self, authorization: PaymentAuthorization, dollars: float) -> None:
+ async with self.state_lock:
+ await self._clear_old_authorizations(_is_already_locked=True)
+
+ if self.parent_limits is not None:
+ await self.parent_limits.settle_spend(authorization, dollars)
+
+ is_threshold_exceeded_by_this_transaction = (
+ self.dollars_spent < self.warn_cap_dollars <= self.dollars_spent + dollars
+ )
+
+ if authorization.authorization_id not in self.open_authorizations:
+ raise AuthorizationInvalidated(
+ f"Authorization {authorization.authorization_id} has timed out or already been settled"
+ )
+ del self.open_authorizations[authorization.authorization_id]
+ self.recent_spend_events.append(authorization)
+ self.dollars_spent += dollars
+ assert self.save_spend_callback is not None, "Should have been initialized by now"
+ await self.save_spend_callback(self.dollars_spent)
+
+ # notify anything waiting on the next settlement
+ self.next_settlement_event.set()
+ self.next_settlement_event.clear()
+
+ logger.trace(
+ "Settled spend of {}, remaining: {}",
+ dollars,
+ self.hard_cap_dollars - self.dollars_spent,
+ )
+
+ if is_threshold_exceeded_by_this_transaction:
+ await self._warn(f"Spent ${self.dollars_spent} already (will be stopped at ${self.hard_cap_dollars})")
+
+ # TODO: make a more configurable warning system, right now just logs
+ async def _warn(self, message: str) -> None:
+ logger.warning(message)
+
+ async def _clear_old_authorizations(self, _is_already_locked: bool = False) -> None:
+ if not _is_already_locked:
+ await self.state_lock.acquire()
+ try:
+ now = get_current_time()
+ self.open_authorizations = {
+ k: v
+ for k, v in self.open_authorizations.items()
+ if (now - v.authorized_at).total_seconds() < _AUTH_PAYMENT_TIMEOUT_SECONDS
+ }
+ self.recent_spend_events = [
+ x for x in self.recent_spend_events if (now - x.authorized_at).total_seconds() < _ONE_HOUR_IN_SECONDS
+ ]
+ finally:
+ if not _is_already_locked:
+ self.state_lock.release()
+
+ async def get_dollars_currently_authorized(self) -> float:
+ async with self.state_lock:
+ return sum(x.dollars for x in self.open_authorizations.values())
+
+ async def get_dollars_authorized_and_spent(self) -> float:
+ async with self.state_lock:
+ dollars_currently_authorized = float(sum(x.dollars for x in self.open_authorizations.values()))
+ return dollars_currently_authorized + self.dollars_spent
+
+ async def get_dollars_authorized_and_spent_in_the_last_hour(self) -> float:
+ async with self.state_lock:
+ dollars_currently_authorized = float(sum(x.dollars for x in self.open_authorizations.values()))
+ return dollars_currently_authorized + sum(x.dollars for x in self.recent_spend_events)
+
+ async def bump_limits(self, limits: ResourceLimitState) -> ResourceLimitState:
+ """
+ Can only raise limits. This makes it easier for hammers to reason about how much they will be able to spend.
+
+ If we were to allow reducing limits, we'd need to be quite careful to update existing timers, and to check
+ conditions at the end of the wait loop in authorize_spend as well.
+ """
+ if self.parent_limits is None:
+ assert limits.hard_cap_dollars < float("inf"), "Cannot unlimit spend for the top-level hammer"
+
+ async with self.state_lock:
+ self.hard_cap_dollars = max(self.hard_cap_dollars, limits.hard_cap_dollars)
+ self.hard_cap_seconds = max(self.hard_cap_seconds, limits.hard_cap_seconds)
+ self.warn_cap_dollars = max(self.warn_cap_dollars, limits.warn_cap_dollars)
+ self.warn_cap_seconds = max(self.warn_cap_seconds, limits.warn_cap_seconds)
+ if self.dollars_per_hour is None:
+ self.dollars_per_hour = limits.dollars_per_hour
+ else:
+ if limits.dollars_per_hour is not None and limits.dollars_per_hour > self.dollars_per_hour:
+ self.dollars_per_hour = limits.dollars_per_hour
+
+ # notify anything waiting in case we just bumped what they were waiting on
+ if self.dollars_per_hour and limits.dollars_per_hour is not None:
+ self.limits_updated_event.set()
+ self.limits_updated_event.clear()
+
+ return ResourceLimitState(
+ hard_cap_dollars=self.hard_cap_dollars,
+ hard_cap_seconds=self.hard_cap_seconds,
+ warn_cap_dollars=self.warn_cap_dollars,
+ warn_cap_seconds=self.warn_cap_seconds,
+ dollars_per_hour=self.dollars_per_hour,
+ )
+
+ def resume(self, dollars: float, limits: ResourceLimitState) -> None:
+ self.hard_cap_dollars = limits.hard_cap_dollars
+ self.hard_cap_seconds = limits.hard_cap_seconds
+ self.warn_cap_dollars = limits.warn_cap_dollars
+ self.warn_cap_seconds = limits.warn_cap_seconds
+ self.dollars_per_hour = limits.dollars_per_hour
+ self.dollars_spent = dollars
+
+ if self.dollars_spent > self.hard_cap_dollars:
+ raise DollarLimitExceeded(
+ f"Have already spent {self.dollars_spent} dollars (more than the hard cap of {self.hard_cap_dollars})"
+ )
+
+ async def _wait_until_updated(self) -> None:
+ await self.limits_updated_event.wait()
+
+
+@attr.s(auto_attribs=True)
+class HammerTimer:
+ limits: ResourceLimits
+ timer_started_at: datetime.datetime
+ timer_task: Task | None = None
+ is_timeout_warning_issued: bool = False
+
+ # TODO: need to save whether or not we warned about the time so that we dont warn again when resuming
+ async def on_hammer_started(self, task_group: TaskGroup, callback: Callable[[], Coroutine[Any, Any, None]]) -> None:
+ seconds_ago = (get_current_time() - self.timer_started_at).total_seconds()
+ possible_wait_times = [x - seconds_ago for x in [self.limits.hard_cap_seconds, self.limits.warn_cap_seconds]]
+ positive_wait_times = [x for x in possible_wait_times if x > 0]
+ if len(positive_wait_times) == 0:
+ await callback()
+ return
+ time_until_limit_check = min(positive_wait_times)
+ self.timer_task = task_group.create_task(self._timeout_after(time_until_limit_check, callback))
+
+ async def on_hammer_stopped(self) -> None:
+ # cancel the timer (if still running
+ timer_task = self.timer_task
+ if timer_task:
+ safe_cancel(timer_task)
+ try:
+ await timer_task
+ except CancelledError:
+ pass
+ self.timer_task = None
+
+ async def _timeout_after(self, seconds: float, callback: Callable[[], Coroutine[Any, Any, None]]) -> None:
+ while True:
+ await asyncio.sleep(seconds)
+
+ # re-schedule ourselves for the next check if the limits have been updated, or we were just here for a warning
+ time_since_started = (get_current_time() - self.timer_started_at).total_seconds()
+ if time_since_started < self.limits.hard_cap_seconds:
+ # emit a warning if necessary
+ if time_since_started > self.limits.warn_cap_seconds and not self.is_timeout_warning_issued:
+ self.is_timeout_warning_issued = True
+ await self.limits._warn(
+ f"Taking longer than expected ({seconds} sec so far, will be killed at {self.limits.hard_cap_seconds}"
+ )
+ # figure out how long to sleep for
+ seconds = min(
+ [
+ self.limits.hard_cap_seconds - time_since_started,
+ self.limits.warn_cap_seconds - time_since_started,
+ ]
+ )
+ continue
+
+ # if we're still here, we've timed out
+ await callback()
+ return
+
+
+# Even if you don't use hammers, you can still benefit from having global resource limits.
+# (When in hammer-less context, this global variable is checked by LanguageModelAPI. Only the financial limits are enforced.)
+
+_GLOBAL_RESOURCE_LIMITS: ResourceLimits | None = None
+
+
+def ensure_global_resource_limits(
+ *,
+ max_dollars: float | None = None,
+ max_seconds: float | None = None,
+ warn_fraction: float | None = None,
+ dollars_per_hour: float | None = None,
+ reset_if_already_set: bool = False,
+) -> None:
+ global _GLOBAL_RESOURCE_LIMITS
+ if _GLOBAL_RESOURCE_LIMITS is None or reset_if_already_set:
+ _GLOBAL_RESOURCE_LIMITS = ResourceLimits.build(
+ max_dollars=max_dollars,
+ max_seconds=max_seconds,
+ warn_fraction=warn_fraction,
+ dollars_per_hour=dollars_per_hour,
+ )
+ _GLOBAL_RESOURCE_LIMITS.save_spend_callback = _dummy_save_spend_callback
+
+
+def get_global_resource_limits() -> ResourceLimits | None:
+ return _GLOBAL_RESOURCE_LIMITS
+
+
+async def _dummy_save_spend_callback(dollars: float) -> None:
+ pass
+
+
+async def get_global_resource_limits_summary() -> str:
+ if _GLOBAL_RESOURCE_LIMITS is None:
+ return "No global resource limits"
+ output = [
+ "Global resource limits summary:",
+ ]
+ max_dollars = _GLOBAL_RESOURCE_LIMITS.hard_cap_dollars
+ amount_spent = await _GLOBAL_RESOURCE_LIMITS.get_dollars_authorized_and_spent()
+ amount_remaining = max_dollars - amount_spent
+ output.append(f"- Max dollars: ${max_dollars:.4f}")
+ output.append(f"- Amount spent: ${amount_spent:.4f}")
+ output.append(f"- Amount remaining: ${amount_remaining:.4f}")
+ return "\n".join(output)
diff --git a/imbue_core/imbue_core/async_monkey_patches.py b/imbue_core/imbue_core/async_monkey_patches.py
@@ -0,0 +1,441 @@
+import asyncio
+import sys
+import traceback
+from asyncio import Future
+from types import TracebackType
+from typing import Any
+from typing import Sequence
+
+import sentry_sdk
+
+from imbue_core.constants import ExceptionPriority
+from imbue_core.error_utils import get_sentry_event_handler
+from imbue_core.error_utils import get_traceback_with_vars
+from imbue_core.errors import ExpectedError
+from imbue_core.s3_uploader import EXTRAS_UPLOADED_FILES_KEY
+from imbue_core.s3_uploader import get_s3_upload_key
+from imbue_core.s3_uploader import get_s3_upload_url
+from imbue_core.s3_uploader import upload_to_s3_with_key
+
+_IS_SHUTTING_DOWN = False
+
+
+# This is the name of the attribute we set on our exceptions to ensure they are logged (esp. to Sentry) at most once.
+EXCEPTION_LOGGED_FLAG = "_was_logged_by_log_exception"
+
+
+def notify_task_groups_of_shutdown() -> None:
+ global _IS_SHUTTING_DOWN
+ _IS_SHUTTING_DOWN = True
+
+
+class PropagatingTaskGroup(asyncio.TaskGroup):
+ """Improves over TaskGroup by ensuring that cancelation messages are actually propagated"""
+
+ def __init__(self) -> None:
+ # deferring the import in case this doesn't get used
+ pass
+
+ python_version: sys._version_info = sys.version_info
+ if python_version[:2] != (3, 11) and python_version[:2] != (3, 12):
+ raise RuntimeError(
+ f"Python version 3.11 or 3.12 is required. You are using {python_version.major}.{python_version.minor}"
+ )
+ super().__init__()
+ self._entered = False
+ self._exiting = False
+ self._aborting = False
+ self._loop = None
+ self._parent_task: asyncio.Task | None = None
+ self._parent_cancel_requested = False
+ self._tasks: set[asyncio.Task] = set()
+ self._errors: list[BaseException] = []
+ self._base_error: BaseException | None = None
+ self._on_completed_fut: Future[Any] | None = None
+ self._original_message: str | None = None
+
+ async def __aexit__(
+ self,
+ et: type[BaseException] | None,
+ exc: BaseException | None,
+ tb: TracebackType | None,
+ ) -> None:
+ try:
+ return await self._aexit(et, exc)
+ finally:
+ # Exceptions are heavy objects that can have object
+ # cycles (bad for GC); let's not keep a reference to
+ # a bunch of them. It would be nicer to use a try/finally
+ # in __aexit__ directly but that introduced some diff noise
+ self._parent_task = None
+ self._errors = [] # Clear the list instead of setting to None
+ self._base_error = None
+ exc = None
+
+ async def _aexit(self, et: type[BaseException] | None, exc: BaseException | None) -> None:
+ self._exiting = True
+
+ if exc is not None and self._is_base_error(exc) and self._base_error is None: # type: ignore
+ self._base_error = exc
+
+ propagate_cancellation_error = exc if et is asyncio.exceptions.CancelledError else None
+ if self._parent_cancel_requested:
+ # If this flag is set we *must* call uncancel().
+ assert self._parent_task
+ if self._parent_task.uncancel() == 0:
+ # If there are no pending cancellations left,
+ # don't propagate CancelledError.
+ propagate_cancellation_error = None
+
+ if et is not None:
+ if not self._aborting:
+ # Our parent task is being cancelled:
+ #
+ # async with TaskGroup() as g:
+ # g.create_task(...)
+ # await ... # <- CancelledError
+ #
+ # or there's an exception in "async with":
+ #
+ # async with TaskGroup() as g:
+ # g.create_task(...)
+ # 1 / 0
+ assert exc is not None
+ self._abort_and_propagate(exc)
+
+ # We use while-loop here because "self._on_completed_fut"
+ # can be cancelled multiple times if our parent task
+ # is being cancelled repeatedly (or even once, when
+ # our own cancellation is already in progress)
+ while self._tasks:
+ if self._on_completed_fut is None:
+ assert self._loop
+ self._on_completed_fut = self._loop.create_future()
+
+ try:
+ await self._on_completed_fut
+ except asyncio.exceptions.CancelledError as ex:
+ if not self._aborting:
+ # Our parent task is being cancelled:
+ #
+ # async def wrapper():
+ # async with TaskGroup() as g:
+ # g.create_task(foo)
+ #
+ # "wrapper" is being cancelled while "foo" is
+ # still running.
+ propagate_cancellation_error = ex
+ self._abort_and_propagate(ex)
+
+ self._on_completed_fut = None
+
+ assert not self._tasks
+
+ if self._base_error is not None:
+ try:
+ raise self._base_error
+ finally:
+ exc = None
+
+ # Propagate CancelledError if there is one, except if there
+ # are other errors -- those have priority.
+ try:
+ if propagate_cancellation_error and not self._errors:
+ try:
+ raise propagate_cancellation_error
+ finally:
+ exc = None
+ finally:
+ propagate_cancellation_error = None
+
+ if et is not None and et is not asyncio.exceptions.CancelledError:
+ assert exc is not None
+ self._errors.append(exc)
+
+ if self._errors:
+ try:
+ raise BaseExceptionGroup(
+ "unhandled errors in a TaskGroup: see earlier in logs for causal error!",
+ self._errors,
+ ) from None
+ finally:
+ exc = None
+
+ def _abort(self) -> None:
+ raise Exception("Please call _abort_and_propagate instead")
+
+ def _abort_and_propagate(self, exc: BaseException) -> None:
+ global _IS_SHUTTING_DOWN
+ self._aborting = True
+
+ if self._original_message is None:
+ if isinstance(exc, asyncio.exceptions.CancelledError) and len(exc.args) > 0:
+ self._original_message = "TaskGroup canceled because:\n" + exc.args[0]
+ else:
+ if not isinstance(exc, asyncio.exceptions.CancelledError):
+ if not _IS_SHUTTING_DOWN and not isinstance(exc, ExpectedError):
+ log_exception(
+ exc,
+ "Emergency print of error that caused task group to die:",
+ )
+ self._original_message = f"TaskGroup died because: {type(exc).__name__}: {exc}\n" + "".join(
+ traceback.extract_tb(exc.__traceback__).format()
+ )
+
+ for t in self._tasks:
+ if not t.done():
+ t.cancel(self._original_message)
+
+ def _on_task_done(self, task: asyncio.Task) -> None:
+ self._tasks.discard(task)
+
+ on_completed_fut = self._on_completed_fut
+ if on_completed_fut is not None and not self._tasks:
+ if not on_completed_fut.done():
+ on_completed_fut.set_result(True)
+
+ if task.cancelled():
+ return
+
+ exc = task.exception()
+ if exc is None:
+ return
+
+ self._errors.append(exc)
+ if self._is_base_error(exc) and self._base_error is None: # type: ignore
+ self._base_error = exc
+
+ parent_task = self._parent_task
+ assert parent_task
+ if parent_task.done():
+ # Not sure if this case is possible, but we want to handle
+ # it anyways.
+ assert self._loop
+ self._loop.call_exception_handler(
+ {
+ "message": f"Task {task!r} has errored out but its parent task {parent_task} is already completed",
+ "exception": exc,
+ "task": task,
+ }
+ )
+ return
+
+ if not self._aborting and not self._parent_cancel_requested:
+ # If parent task *is not* being cancelled, it means that we want
+ # to manually cancel it to abort whatever is being run right now
+ # in the TaskGroup. But we want to mark parent task as
+ # "not cancelled" later in __aexit__. Example situation that
+ # we need to handle:
+ #
+ # async def foo():
+ # try:
+ # async with TaskGroup() as g:
+ # g.create_task(crash_soon())
+ # await something # <- this needs to be canceled
+ # # by the TaskGroup, e.g.
+ # # foo() needs to be cancelled
+ # except Exception:
+ # # Ignore any exceptions raised in the TaskGroup
+ # pass
+ # await something_else # this line has to be called
+ # # after TaskGroup is finished.
+ self._abort_and_propagate(exc)
+ self._parent_cancel_requested = True
+ parent_task.cancel(self._original_message)
+
+
+def safe_cancel(task: asyncio.Task, msg: str | None = None) -> None:
+ """
+ NOTE: this is probably not what you want! See safe_cancel_and_wait_for_cleanup below for the more common use case.
+
+ Cancels a task in a way that preserves information about who canceled it.
+
+ Without using this, it is super obnoxious to figure out why your function is being canceled --
+ you just get a CancelledError with no traceback.
+
+ We try to ensure that *all* of our tasks are canceled in this way, which makes debugging much easier.
+
+ Even safe_cancel_and_wait_for_cleanup will cancel in this way. The only difference is that that function
+ also waits for the task to actually be canceled. Otherwise, cancellation just enqueues a cancellation.
+
+ Note also that cancellation is never guaranteed -- all it does is raise a CancelledError in the task.
+ This is why it is so important to never swallow those errors!
+ """
+ task.is_being_canceled_by_us = True # type: ignore
+ message = f"Task canceled by: \n {''.join(traceback.format_stack()[:-1])}"
+ if msg:
+ message += f"\nOriginal message: {msg}"
+
+ task.cancel(message)
+
+
+async def safe_cancel_and_wait_for_cleanup(
+ task: asyncio.Task,
+ msg: str | None = None,
+ exception_types_to_ignore: Sequence[type[BaseException]] = (),
+) -> None:
+ """
+ Convenience function for calling safe_cancel_multiple_and_wait_for_cleanup with a single task.
+
+ See safe_cancel_multiple_and_wait_for_cleanup for docs.
+ """
+ await safe_cancel_multiple_and_wait_for_cleanup([task], msg, exception_types_to_ignore)
+
+
+async def safe_cancel_multiple_and_wait_for_cleanup(
+ tasks: Sequence[asyncio.Task],
+ msg: str | None = None,
+ exception_types_to_ignore: Sequence[type[BaseException]] = (),
+) -> None:
+ """
+ Calls safe_cancel (see docs above) on each task in tasks, then waits for them to be done.
+
+ Note that you can pass in a list of exception types to ignore.
+ This is important for suppressing exceptions from third party libraries.
+ You should probably make a constant in your project that lists these exceptions.
+
+ We cannot simply suppress all BaseExceptions here because you really don't want to do that for things like signals and OutOfMemoryError
+ """
+ for task in tasks:
+ safe_cancel(task, msg)
+ # if you really want something to be canceled, you need to wait for it to be done
+ # https://docs.python.org/3/library/asyncio-task.html#asyncio.Task.cancel
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+ exceptions = []
+ for result in results:
+ if isinstance(result, BaseException):
+ if isinstance(result, asyncio.CancelledError):
+ pass
+ elif isinstance(result, ExceptionGroup):
+ exceptions.extend(_filter_exception_group(result))
+ else:
+ exceptions.append(result) # type: ignore
+ # cannot do this because the task may have just finished
+ # assert (
+ # False
+ # ), f"While cancelling async task and waiting for cleanup, expected None or CancelledError, got `{type(x)}: {x}`"
+
+ filtered_exceptions = []
+ for exception in exceptions:
+ if not any(isinstance(exception, exception_type) for exception_type in exception_types_to_ignore):
+ filtered_exceptions.append(exception)
+
+ if len(filtered_exceptions) == 1:
+ raise filtered_exceptions[0]
+ elif len(filtered_exceptions) > 1:
+ raise ExceptionGroup("Multiple exceptions in task group while canceling", filtered_exceptions)
+
+
+def _filter_exception_group(exc_group: ExceptionGroup) -> list[Exception]:
+ """Recursively extract exceptions from ExceptionGroups (ignoring canceled errors)."""
+ result = []
+ for exc in exc_group.exceptions:
+ if isinstance(exc, asyncio.CancelledError):
+ continue
+ elif isinstance(exc, ExceptionGroup):
+ result.extend(_filter_exception_group(exc))
+ else:
+ result.append(exc)
+ return result
+
+
+def _upload_traceback(key: str, exception: BaseException) -> None:
+ tb_with_vars = get_traceback_with_vars(exception)
+ if tb_with_vars is not None:
+ upload_to_s3_with_key(key, tb_with_vars.encode())
+
+
+def pre_filter_exception(exc: BaseException, message: str | None = None) -> bool:
+ # deferred import, will have been imported anyway by this point
+ from loguru import logger
+
+ if getattr(exc, EXCEPTION_LOGGED_FLAG, False):
+ logger.info("Skipping duplicate log of exception {} with message {!r}", exc, message)
+ return True
+ try:
+ setattr(exc, EXCEPTION_LOGGED_FLAG, True)
+ except AttributeError:
+ logger.info("Unable to guarantee that {} will not be logged again", exc)
+ return False
+
+
+def inject_exception_and_log(
+ exc: BaseException,
+ message: str,
+ priority: ExceptionPriority | None = None,
+ *args: Any,
+ **kwargs: Any,
+) -> None:
+ # deferred import, will have been imported anyway by this point
+ from loguru import logger
+
+ # inject received exception stack trace into logger error message
+ options = (exc,) + logger._options[1:] # pyre-fixme[16]: pyre doesn't know that _options exists
+ if priority is not None:
+ level = priority.value
+ else:
+ level = "ERROR"
+ logger._log(level, False, options, message, args, kwargs) # pyre-fixme[16]: pyre doesn't know that _log exists
+
+
+def log_exception(
+ exc: BaseException,
+ message: str,
+ priority: ExceptionPriority | None = None,
+ *args: Any,
+ sentry_extra_uploads: dict[str, str] | None = None,
+ **kwargs: Any,
+) -> None:
+ """Josh doesn't like that `loguru.exception()` takes only a message, and grabs the current exception from sys.exc_info().
+
+ So this is a more explicit alternative that takes the exception as an argument.
+ """
+ should_skip = pre_filter_exception(exc, message)
+ if should_skip:
+ return None
+
+ # use a new scope to ensure these attachments don't bleed to other events that might have the same scope
+ # TODO: unify the uploading logic that we have in Sculptor with this, avoid coupling through two global objects (sentry event handler, s3_upload)
+ with sentry_sdk.new_scope() as scope:
+ sentry_event_handler = get_sentry_event_handler()
+ traceback_str = "".join(traceback.format_stack())
+ message = f"{message}\n\nlog_exception CALL SITE TRACEBACK:\n\n{traceback_str}\nORIGINAL EXCEPTION TRACEBACK FOLLOWS:\n"
+ if sentry_event_handler is not None:
+ s3_uploads = []
+ callbacks = []
+ traceback_str_s3_key = get_s3_upload_key("logsite_traceback", ".txt")
+
+ # attach traceback of log_exception callsite
+ logsite_url = get_s3_upload_url(traceback_str_s3_key)
+ s3_uploads.append(logsite_url)
+ callbacks.append(
+ lambda key=traceback_str_s3_key, data=traceback_str: upload_to_s3_with_key(key, data.encode())
+ )
+
+ # for original exception, get traceback with variables and attach
+ traceback_with_variables_s3_key = get_s3_upload_key("original_exc_traceback_with_vars", ".txt")
+ s3_uploads.append(get_s3_upload_url(traceback_with_variables_s3_key))
+ callbacks.append(
+ lambda key=traceback_with_variables_s3_key, exception=exc: _upload_traceback(key, exception)
+ )
+ # upload some extra data if provided
+ if sentry_extra_uploads is not None:
+ for key, value in sentry_extra_uploads.items():
+ key = get_s3_upload_key(key, ".txt")
+ s3_uploads.append(get_s3_upload_url(key))
+ callbacks.append(lambda key=key, data=value: upload_to_s3_with_key(key, data.encode()))
+
+ sentry_event_handler.schedule_callbacks(callbacks)
+
+ # watch out; this will stomp on existing "extras" in the event
+ s3_uploads = [upload for upload in s3_uploads if upload is not None]
+ if s3_uploads:
+ scope.set_extra(EXTRAS_UPLOADED_FILES_KEY, s3_uploads)
+
+ # inject received exception stack trace into logger error message
+ inject_exception_and_log(exc, message, priority, *args, **kwargs)
+
+
+def apply() -> None:
+ asyncio.TaskGroup = PropagatingTaskGroup # type: ignore
+ asyncio.taskgroups.TaskGroup = PropagatingTaskGroup # type: ignore
diff --git a/imbue_core/imbue_core/async_monkey_patches_test.py b/imbue_core/imbue_core/async_monkey_patches_test.py
@@ -0,0 +1,186 @@
+from contextlib import contextmanager
+from typing import Any
+from typing import Callable
+from typing import Generator
+from typing import Iterator
+
+import pytest
+from loguru import logger
+
+from imbue_core.async_monkey_patches import log_exception
+from imbue_core.constants import ExceptionPriority
+
+
+class IncorrectErrorsLoggedDuringTesting(Exception):
+ pass
+
+
+@contextmanager
+def check_logged_errors(check_func: Callable[[list[str]], None]) -> Iterator[None]:
+ """Context manager that monkey patches logger._log to accumulate error messages instead of logging them.
+ Then it runs the check function on the accumulated errors."""
+ original_log_func = logger._log # pyre-fixme[16]: pyre doesn't know that _log exists
+ accumulated_errors: list[str] = []
+
+ error_level_names = (
+ "ERROR",
+ ExceptionPriority.LOW_PRIORITY.value,
+ ExceptionPriority.MEDIUM_PRIORITY.value,
+ ExceptionPriority.HIGH_PRIORITY.value,
+ )
+
+ logger._log = lambda level, flag, options, message, args, kwargs: (
+ (
+ accumulated_errors.append(message) is None
+ and original_log_func(
+ "INFO",
+ flag,
+ options,
+ "CAUGHT ERROR LOG: " + message.splitlines()[0][:100],
+ args,
+ kwargs,
+ )
+ )
+ if level in error_level_names
+ else original_log_func(level, flag, options, message, args, kwargs)
+ )
+ try:
+ yield
+ finally:
+ logger._log = original_log_func
+ check_func(accumulated_errors)
+
+
+def at_least_check_maker(expected_errors_set: set[str]) -> Callable[[list[str]], None]:
+ assert isinstance(expected_errors_set, set), "expected_errors must be a set"
+ expected_errors = list(expected_errors_set)
+
+ def check_func(accumulated_errors: list[str]) -> None:
+ if len(accumulated_errors) < len(expected_errors):
+ raise IncorrectErrorsLoggedDuringTesting(
+ f"{len(accumulated_errors)=} != {len(expected_errors)=}, {accumulated_errors=}"
+ )
+ for expected_error in expected_errors:
+ for accumulated_error in accumulated_errors:
+ if expected_error in accumulated_error:
+ break
+ else:
+ raise IncorrectErrorsLoggedDuringTesting(f"{expected_error=} is not in {accumulated_errors=}")
+
+ return check_func
+
+
+@contextmanager
+def expect_at_least_logged_errors(expected_errors: set[str]) -> Iterator[None]:
+ """Context manager that monkey patches logger._log to accumulate error messages instead of logging them.
+ Checks that all expected errors are in the accumulated errors, in no particular order.
+ """
+ check_func = at_least_check_maker(expected_errors)
+ with check_logged_errors(check_func):
+ yield
+
+
+def exact_check_maker(expected_errors: list[str]) -> Callable[[list[str]], None]:
+ assert isinstance(expected_errors, list), "expected_errors must be a list"
+
+ def check_func(accumulated_errors: list[str]) -> None:
+ if len(accumulated_errors) != len(expected_errors):
+ raise IncorrectErrorsLoggedDuringTesting(
+ f"{len(accumulated_errors)=} != {len(expected_errors)=}, {accumulated_errors=}"
+ )
+ for i, expected_error in enumerate(expected_errors):
+ if expected_error not in accumulated_errors[i]:
+ raise IncorrectErrorsLoggedDuringTesting(
+ f"At position {i=}, {expected_error=} is not in {accumulated_errors[i]=}"
+ )
+
+ return check_func
+
+
+@contextmanager
+def expect_exact_logged_errors(expected_errors: list[str]) -> Iterator[None]:
+ """Context manager that monkey patches logger._log to accumulate error messages instead of logging them.
+ Checks that all expected errors are in the accumulated errors, in the same order."""
+ check_func = exact_check_maker(expected_errors)
+ with check_logged_errors(check_func):
+ yield
+
+
+def test_log_exception() -> None:
+ with expect_exact_logged_errors(["Test log_exception"]):
+ try:
+ x = 1 / 0
+ except Exception as e:
+ log_exception(e, "Test log_exception")
+ assert True # If we reach here, the test passes
+ else:
+ assert False, "log_exception did not raise an exception"
+
+
+def test_log_exception_with_priority() -> None:
+ # ensure_core_log_levels_configured auto-used in conftest
+ with expect_exact_logged_errors(["Test log_exception"]):
+ try:
+ x = 1 / 0
+ except Exception as e:
+ log_exception(e, "Test log_exception", priority=ExceptionPriority.LOW_PRIORITY)
+ assert True # If we reach here, the test passes
+ else:
+ assert False, "log_exception did not raise an exception"
+
+
+@pytest.fixture
+def explode_on_error() -> Generator[None, None, None]:
+ """Fixture to explode on error."""
+ original_log_func = logger._log # pyre-fixme[16]: pyre doesn't know that _log exists
+ accumulated_errors: list[str] = []
+
+ def _log_wrapper(
+ level: str,
+ flag: int,
+ options: tuple[int, ...],
+ message: str,
+ args: tuple,
+ kwargs: dict,
+ ) -> Any:
+ if level == "ERROR":
+ accumulated_errors.append(message)
+ new_options = list(options)
+ new_options[1] = 1
+ return original_log_func(level, flag, tuple(new_options), message, args, kwargs)
+
+ logger._log = _log_wrapper
+
+ try:
+ yield
+ except BaseException:
+ raise
+ else:
+ if len(accumulated_errors) > 0:
+ raise IncorrectErrorsLoggedDuringTesting(f"Errors logged during testing: {accumulated_errors}")
+ finally:
+ logger._log = original_log_func
+
+
+def test_log_error(explode_on_error: Any) -> None:
+ with expect_exact_logged_errors(["Something bad happened"]):
+ logger.error("Something bad happened")
+
+ with pytest.raises(IncorrectErrorsLoggedDuringTesting):
+ with expect_exact_logged_errors(["Something bad happened"]):
+ pass
+
+ with pytest.raises(IncorrectErrorsLoggedDuringTesting):
+ with expect_exact_logged_errors(["Something bad happened"]):
+ logger.error("Something bad happened")
+ logger.error("Something else bad happened")
+
+
+def test_log_error_at_least(explode_on_error: Any) -> None:
+ with expect_at_least_logged_errors({"Something bad happened"}):
+ logger.error("Something bad happened")
+ logger.error("Something else bad happened")
+
+ with pytest.raises(IncorrectErrorsLoggedDuringTesting):
+ with expect_at_least_logged_errors({"Something bad happened", "Something else bad happened"}):
+ logger.error("Something bad happened")
diff --git a/imbue_core/imbue_core/async_utils.py b/imbue_core/imbue_core/async_utils.py
@@ -0,0 +1,526 @@
+import asyncio
+import functools
+import inspect
+import os
+import platform
+import sys
+import threading
+import traceback
+from contextlib import AbstractAsyncContextManager
+from contextlib import AbstractContextManager
+from contextlib import contextmanager
+from datetime import datetime
+from http.server import BaseHTTPRequestHandler
+from http.server import HTTPServer
+from pathlib import Path
+from types import FrameType
+from typing import Any
+from typing import AsyncGenerator
+from typing import Awaitable
+from typing import Callable
+from typing import Coroutine
+from typing import Generator
+from typing import Generic
+from typing import Iterable
+from typing import Iterator
+from typing import ParamSpec
+from typing import TypeVar
+from typing import cast
+from urllib.parse import parse_qs
+from urllib.parse import urlparse
+
+from loguru import logger
+from traceback_with_variables.core import _iter_lines
+
+from imbue_core.async_monkey_patches import log_exception
+from imbue_core.async_monkey_patches import safe_cancel
+
+P = ParamSpec("P")
+R = TypeVar("R")
+S = TypeVar("S")
+
+ALL_EVENT_LOOPS: list[asyncio.AbstractEventLoop] = []
+
+
+def sync(func: Callable[P, Awaitable[R]]) -> Callable[P, R]:
+ """Decorator that runs an async function synchronously by dispatching to
+ an event loop running in a separate thread.
+ """
+
+ @functools.wraps(func)
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
+ loop = _get_or_create_event_loop()
+ return asyncio.run_coroutine_threadsafe(func(*args, **kwargs), loop).result()
+
+ return wrapper
+
+
+def sync_generator(
+ func: Callable[P, AsyncGenerator[R, None]],
+) -> Callable[P, Generator[R, None, None]]:
+ """Decorator that runs an async generator synchronously by dispatching to
+ an event loop running in a separate thread.
+ """
+
+ @functools.wraps(func)
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> Generator[R, None, None]:
+ loop = _get_or_create_event_loop()
+ agen = func(*args, **kwargs)
+ while True:
+ try:
+ future = asyncio.run_coroutine_threadsafe(agen.__anext__(), loop)
+ yield future.result()
+ except StopAsyncIteration:
+ break
+
+ return wrapper
+
+
+@contextmanager
+# pyre-ignore[24]: pyre doesn't understand AbstractAsyncContextManager
+def sync_contextmanager(
+ async_context_manager: AbstractAsyncContextManager[S],
+) -> Generator[S, None, None]:
+ sync_aenter = sync(async_context_manager.__aenter__)
+ sync_aexit = sync(async_context_manager.__aexit__)
+
+ enter_result = sync_aenter()
+ try:
+ yield enter_result
+ except BaseException as e:
+ if not sync_aexit(e.__class__, e, e.__traceback__):
+ raise
+ else:
+ sync_aexit(None, None, None)
+
+
+# pyre doesn't understand AbstractAsyncContextManager
+def sync_contextmanager_func(
+ cm_func: Callable[P, AbstractAsyncContextManager[S]], # pyre-ignore[24]
+) -> Callable[P, AbstractContextManager[S]]: # pyre-ignore[24]
+ @functools.wraps(cm_func)
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> AbstractContextManager[S]: # pyre-ignore[24]
+ return sync_contextmanager(cm_func(*args, **kwargs))
+
+ return wrapper
+
+
+_LOOP: asyncio.AbstractEventLoop | None = None
+_LOOP_LOCK: threading.Lock = threading.Lock()
+
+
+def _get_or_create_event_loop() -> asyncio.AbstractEventLoop:
+ global _LOOP
+ if _LOOP is not None:
+ return _LOOP
+ with _LOOP_LOCK:
+ # Check again in case another thread created the loop while we were waiting for the lock.
+ if _LOOP is not None:
+ return _LOOP
+ _LOOP = asyncio.new_event_loop()
+ asyncio.set_event_loop(_LOOP)
+ # pyre-ignore[16]: we have _LOOP_LOCK, so _LOOP is still not None
+ threading.Thread(target=_LOOP.run_forever, daemon=True, name="async_loop").start()
+ return _LOOP # pyre-ignore[7]: we just made _LOOP, so it's not None unless it got destroyed just now
+
+
+def shorten_filename(filename: str) -> str:
+ path = Path(filename)
+ while path.parent:
+ path = path.parent
+ if not (path / "__init__.py").exists():
+ break
+
+ try:
+ shortened = str(Path(filename).relative_to(path))
+ except ValueError:
+ shortened = filename # in case the path cannot be made relative
+
+ return shortened
+
+
+# TODO: I'd really like to print these task groups in a hierarchical way instead of flat -- we know which groups
+# launched which other groups, so we could print them in a tree structure. That would be a lot more readable.
+# It might also be nice to print without any stacks at all. As long as the tasks had good names, that would make it
+# possible to very easily understand everything that was currently executing.
+# I could even imagine controls that allowed for printing just the task groups themselves, which would also be easier
+# to understand.
+def get_all_async_task_stacks(
+ num_skipped_frames: int = 0,
+ log_variables: bool = False,
+ loop: asyncio.AbstractEventLoop | None = None,
+) -> Iterator[str]:
+ """Yields the lines of a report for all stack frames of all async tasks including variables."""
+ tasks_by_task_group: dict[asyncio.TaskGroup | None, list[asyncio.Task]] = {}
+ owning_task_by_task_group: dict[asyncio.TaskGroup, asyncio.Task] = {}
+
+ for task in asyncio.all_tasks(loop=loop):
+ if task.done():
+ continue
+ task_group = cast(asyncio.TaskGroup | None, getattr(task, "task_group", None))
+ owned_task_group = cast(asyncio.TaskGroup | None, getattr(task, "owned_task_group", None))
+ if owned_task_group is not None:
+ owning_task_by_task_group[owned_task_group] = task
+ if owned_task_group not in tasks_by_task_group:
+ tasks_by_task_group[owned_task_group] = []
+ else:
+ tasks_by_task_group.setdefault(task_group, []).append(task)
+
+ all_owning_tasks = set(owning_task_by_task_group.values())
+
+ task_group_keys = list(tasks_by_task_group.keys())
+ for task_group in cast(list[asyncio.TaskGroup | None], [None]) + [x for x in task_group_keys if x is not None]:
+ if task_group not in tasks_by_task_group:
+ continue
+ tasks = tasks_by_task_group[task_group]
+ if task_group is None:
+ yield f"\n\n{'=' * 40}\nNo TaskGroup:\n"
+ else:
+ yield f"\n\n{'=' * 40}\nTaskGroup: {getattr(task_group, 'name', 'unknown')}\n"
+ owning_task = None
+ if task_group is not None:
+ owning_task = owning_task_by_task_group.get(task_group)
+ is_first_line_skipped = False
+ all_tasks = tasks
+ if owning_task is not None:
+ yield f"Owning Task: {owning_task.get_name()}\n"
+ is_first_line_skipped = True
+ all_tasks.insert(0, owning_task)
+ for task in all_tasks:
+ # skip these -- they'll be printed at the top of the group that they own
+ if task_group is None and task in all_owning_tasks:
+ continue
+ if is_first_line_skipped:
+ is_first_line_skipped = False
+ else:
+ yield f"{'-' * 40}\nTask {task.get_name()}:\n"
+ frames = extract_frames(task)
+ for frame in frames:
+ frame_infos = inspect.getouterframes(frame)[num_skipped_frames:]
+ # Use private method _iter_lines to traceback async tasks, which is not explicitly handled in the API
+ if log_variables:
+ for line in _iter_lines(
+ e=None,
+ frame_infos=frame_infos,
+ fmt=None,
+ for_file=None,
+ ):
+ yield line + "\n"
+ else:
+ frame_summaries = [
+ traceback.FrameSummary(
+ shorten_filename(info.filename),
+ lineno=info.lineno,
+ name=info.function,
+ line=(info.code_context[0].strip() if info.code_context else None),
+ )
+ for info in frame_infos
+ ]
+ yield from traceback.format_list(frame_summaries)
+
+
+def extract_frames(task: asyncio.Task) -> list[FrameType]:
+ """Extract the stack frames of an async task."""
+ coro = task.get_coro()
+ assert isinstance(coro, Coroutine)
+ frames = []
+ while coro is not None and coro.cr_frame is not None:
+ frames.append(coro.cr_frame)
+ coro = coro.cr_await # type: ignore
+ # this happens at the very bottom of the call stack, there it seems to often be a FutureIter, Event, etc
+ if type(coro).__name__ != "coroutine":
+ break
+ return frames
+
+
+def print_all_async_task_stacks(log_variables: bool = False) -> None:
+ """Prints the stack frames of all running tasks."""
+ for line in get_all_async_task_stacks(log_variables=log_variables):
+ print(line)
+
+
+def dump_all_async_task_stacks(log_path: str | Path, log_variables: bool = False) -> None:
+ """Dump the stack frames of all running tasks to file."""
+ with open(log_path, "w") as f:
+ for line in get_all_async_task_stacks(log_variables=log_variables):
+ if log_variables:
+ line += "\n"
+ f.write(line)
+
+
+async def periodically_log_async_stacks(log_dir: str | Path, interval: float, log_variables: bool = False) -> None:
+ """Periodically print the stack traces of all running tasks."""
+ Path(log_dir).mkdir(parents=True, exist_ok=True)
+ while True:
+ log_path = Path(log_dir) / f"{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
+ dump_all_async_task_stacks(log_path=log_path, log_variables=log_variables)
+ logger.debug("Dumped asyncio stack traces to {}", log_path)
+ await asyncio.sleep(interval)
+
+
+async def is_task_group_complete(
+ task_group: asyncio.TaskGroup, trace_task: asyncio.Task, buffer_time: float = 1.0
+) -> None:
+ """Continuously check if all tasks except the stack trace logger are done."""
+ while True:
+ if all(task.done() for task in task_group._tasks if task is not trace_task):
+ await asyncio.sleep(buffer_time) # Wait for buffer time in case new tasks are added
+ # Recheck to confirm
+ if all(task.done() for task in task_group._tasks if task is not trace_task):
+ break
+ await asyncio.sleep(1.0)
+
+
+async def inject_async_stack_trace_logger(
+ task_group: asyncio.TaskGroup,
+ log_dir: str | Path,
+ log_interval: float = 60.0,
+ log_variables: bool = False,
+) -> None:
+ """Inject a stack trace logger into the task group."""
+ trace_task = asyncio.create_task(
+ periodically_log_async_stacks(log_dir=log_dir, interval=log_interval, log_variables=log_variables),
+ name="periodically_log_async_stacks",
+ )
+ await is_task_group_complete(task_group, trace_task)
+ safe_cancel(trace_task)
+ try:
+ await trace_task
+ except asyncio.CancelledError:
+ pass
+
+
+class AsyncTaskStacksHandler(BaseHTTPRequestHandler):
+ def do_GET(self) -> None:
+ try:
+ parsed_url = urlparse(self.path)
+ query_params = parse_qs(parsed_url.query)
+ log_variables = query_params.get("locals", ["false"])[0].lower() in [
+ "true",
+ "1",
+ "yes",
+ ]
+
+ self.send_response(200)
+ self.send_header("Content-type", "text/plain")
+ self.end_headers()
+
+ pid = os.getpid()
+ command_line = " ".join(sys.argv)
+
+ # Path to the Python executable
+ python_executable = sys.executable
+
+ # Python version
+ python_version = platform.python_version()
+
+ # Print the collected information
+ self.wfile.write(f"Process {pid}: {python_executable} {command_line}\n".encode("utf-8"))
+ self.wfile.write(f"Python v{python_version} ({python_executable})\n\n".encode("utf-8"))
+
+ for loop in ALL_EVENT_LOOPS:
+ for line in get_all_async_task_stacks(log_variables=log_variables, loop=loop):
+ self.wfile.write(line.encode("utf-8"))
+ except BaseException as e:
+ log_exception(e, "exception in AsyncTaskStacksHandler")
+ raise
+
+
+def run_async_stackframe_server_thread(port_range_low: int, port_range_high: int) -> None:
+ try:
+ success = False
+ for port in range(port_range_low, port_range_high):
+ try:
+ server_address = ("localhost", port)
+ httpd = HTTPServer(server_address, AsyncTaskStacksHandler)
+ success = True
+ print(f"Starting async stack trace server on port {port}. Process pid: {os.getpid()}")
+ break
+ except OSError:
+ continue
+
+ if not success:
+ logger.info("Could not find an open port to start the async stack trace server, continuing without it.")
+ return
+
+ httpd.serve_forever()
+ except BaseException as e:
+ log_exception(e, "exception in run_async_stackframe_server_thread")
+ raise
+
+
+IS_STACKFRAME_SERVER_RUNNING = False
+STACKFRAME_SERVER_PORT_LOW = 60000
+STACKFRAME_SERVER_PORT_HIGH = 61000
+
+
+def run_async_stackframe_server_for_loop(event_loop: asyncio.AbstractEventLoop) -> None:
+ ALL_EVENT_LOOPS.append(event_loop)
+ run_async_stackframe_server()
+
+
+def run_async_stackframe_server() -> None:
+ global IS_STACKFRAME_SERVER_RUNNING
+ if not IS_STACKFRAME_SERVER_RUNNING:
+ IS_STACKFRAME_SERVER_RUNNING = True
+
+ port_str = os.environ.get("ASYNC_STACKFRAME_SERVER_PORT")
+ if port_str is not None:
+ try:
+ port = int(port_str)
+ port_range_low = port
+ port_range_high = port + 1
+ except ValueError:
+ logger.error("ASYNC_STACKFRAME_SERVER_PORT is not an integer: {}", port_str)
+ raise
+ else:
+ port_range_low = STACKFRAME_SERVER_PORT_LOW
+ port_range_high = STACKFRAME_SERVER_PORT_HIGH
+
+ thread = threading.Thread(
+ target=run_async_stackframe_server_thread,
+ daemon=True,
+ kwargs={
+ "port_range_low": port_range_low,
+ "port_range_high": port_range_high,
+ },
+ )
+ thread.start()
+
+
+def with_timeout(func: Callable[P, Awaitable[R]], timeout_secs: float) -> Callable[P, Awaitable[R]]:
+ """Decorator that adds a timeout to an async function."""
+
+ @functools.wraps(func)
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
+ return await asyncio.wait_for(func(*args, **kwargs), timeout_secs)
+
+ return wrapper
+
+
+T = TypeVar("T")
+
+
+async def gather_with_limited_concurrency(coros: Iterable[Awaitable[T]], n: int) -> list[T]:
+ """Like asyncio.gather() but will only run `n` in parallel at a time.
+
+ Note that a call like `asyncio.gather(*coros)` is now `gather_with_limited_concurrency(coros, n=10),
+ without the splat.
+ """
+ semaphore = asyncio.Semaphore(n)
+
+ async def sem_coro(coro: Awaitable[T]) -> T:
+ async with semaphore:
+ return await coro
+
+ return list(await asyncio.gather(*(sem_coro(c) for c in coros)))
+
+
+_NOT_FOUND = object()
+
+
+class AsyncCachedProperty(Generic[T]):
+ """A descriptor factory that behaves very similarly to `functools.cached_property`, but for
+ async methods!
+
+ The type annotations here are rough; it's not realistic to get them perfect without using a .pyi file.
+ """
+
+ def __init__(self, func: Callable[[Any], Coroutine[None, None, T]]) -> None:
+ self.func = func
+ self.attrname: str | None = None
+ self.__doc__ = func.__doc__
+
+ def __set_name__(self, owner: type, name: str) -> None:
+ if self.attrname is None:
+ self.attrname = name
+ elif name != self.attrname:
+ raise TypeError("Cannot assign the same AsyncCachedProperty to multiple names")
+
+ def _get_attrname(self) -> str:
+ if self.attrname is None:
+ raise TypeError("Cannot use AsyncCachedProperty instance without calling __set_name__")
+ return self.attrname
+
+ def _get_cache(self, instance: object) -> dict[str, Any]:
+ try:
+ return instance.__dict__
+ except AttributeError:
+ raise TypeError(
+ "Cannot use AsyncCachedProperty with instances that do not have a __dict__ attribute"
+ ) from None
+
+ def __get__(self, instance: object, owner: type | None = None) -> Awaitable[T]:
+ if instance is None:
+ return self # type: ignore
+ attrname = self._get_attrname()
+ cache = self._get_cache(instance)
+ val = cache.get(attrname, _NOT_FOUND)
+ if val is not _NOT_FOUND:
+ return cast(Awaitable[T], val)
+
+ task = asyncio.create_task(self.func(instance))
+ cache[attrname] = task
+ return task
+
+ def __delete__(self, instance: object) -> None:
+ if instance is None:
+ raise TypeError("Cannot delete AsyncCachedProperty on a class")
+ attrname = self._get_attrname()
+ cache = self._get_cache(instance)
+ try:
+ awaitable = cache.pop(attrname)
+ if not awaitable.done():
+ safe_cancel(awaitable)
+ except KeyError:
+ raise AttributeError(f"Cannot delete attribute {self.attrname!r}") from None
+
+ def __set__(self, instance: object, value: T) -> None:
+ if instance is None:
+ raise TypeError("Cannot set AsyncCachedProperty on a class")
+ attrname = self._get_attrname()
+ cache = self._get_cache(instance)
+ existing = cache.pop(attrname, None)
+ if existing is not None and not existing.done():
+ safe_cancel(existing)
+ fut: asyncio.Future[T] = asyncio.Future()
+ fut.set_result(value)
+ cache[attrname] = fut
+
+
+def wrapped_asyncio_run(
+ main: Awaitable[T],
+ *,
+ debug: bool | None = None,
+ loop_factory: Callable[..., asyncio.AbstractEventLoop] | None = None,
+) -> T:
+ """
+ This is a lightweight wrapper with a singular purpose -- it is here to enable apyspy
+
+ apyspy is our async equivalent to py-spy.
+
+ Without apyspy, it's really annoying to debug why some async task is stuck.
+ """
+
+ async def wrapper_main() -> T:
+ ALL_EVENT_LOOPS.append(asyncio.get_event_loop())
+ result = await main
+ ALL_EVENT_LOOPS.pop()
+ return result
+
+ run_async_stackframe_server()
+ with asyncio.Runner(debug=debug, loop_factory=loop_factory) as runner:
+ return runner.run(wrapper_main())
+
+
+def make_async(func: Callable[P, R]) -> Callable[P, Awaitable[R]]:
+ """
+ Turn the annotated function into an async function by running it in a thread.
+
+ This is useful for functions that perform blocking i/o.
+ """
+
+ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
+ return await asyncio.to_thread(func, *args, **kwargs)
+
+ return wrapper
diff --git a/imbue_core/imbue_core/caching.py b/imbue_core/imbue_core/caching.py
@@ -0,0 +1,235 @@
+from __future__ import annotations
+
+import asyncio
+import os
+from functools import lru_cache
+from pathlib import Path
+from types import TracebackType
+from typing import Generic
+from typing import Self
+from typing import Sequence
+from typing import TypeVar
+
+from diskcache import Cache
+from diskcache import JSONDisk
+
+from imbue_core.cattrs_serialization import deserialize_from_json
+from imbue_core.cattrs_serialization import serialize_to_json
+from imbue_core.frozen_utils import FrozenDict
+from imbue_core.frozen_utils import FrozenMapping
+
+ValueType = TypeVar("ValueType", covariant=True)
+
+
+class AsyncCacheInterface(Generic[ValueType]):
+ async def __aenter__(self) -> Self:
+ raise NotImplementedError()
+
+ async def __aexit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_val: BaseException | None,
+ exc_tb: TracebackType | None,
+ ) -> None:
+ raise NotImplementedError()
+
+ async def set(
+ self,
+ key: str,
+ # pyre-fixme[46]: ValueType is covariant
+ value: ValueType,
+ expire: int | None = None,
+ read: bool = False,
+ tag: str | None = None,
+ retry: bool = False,
+ ) -> bool:
+ raise NotImplementedError()
+
+ async def get(
+ self,
+ key: str,
+ default: ValueType | None = None,
+ read: bool = False,
+ expire_time: bool = False,
+ tag: bool = False,
+ retry: bool = False,
+ ) -> ValueType | None:
+ raise NotImplementedError()
+
+ async def get_all(
+ self,
+ keys: Sequence[str],
+ default: ValueType | None = None,
+ read: bool = False,
+ expire_time: bool = False,
+ tag: bool = False,
+ retry: bool = False,
+ ) -> FrozenMapping[str, ValueType | None]:
+ raise NotImplementedError()
+
+ async def get_all_keys(self, reverse: bool = False) -> tuple[str, ...]:
+ raise NotImplementedError()
+
+
+class AsyncCache(AsyncCacheInterface[ValueType], Generic[ValueType]):
+ def __init__(self, path: Path, value_cls: type[ValueType]) -> None:
+ self.path = path
+ self.value_cls = value_cls
+ self.cache: Cache | None = None
+
+ async def _build_cache(self) -> Cache:
+ loop = asyncio.get_running_loop()
+ # pyre-ignore[6]: pyre doesn't like the lru cache here
+ return await loop.run_in_executor(None, get_cache, self.path)
+
+ async def __aenter__(self) -> Self:
+ loop = asyncio.get_running_loop()
+ cache = await self._build_cache()
+ self.cache = cache
+ await loop.run_in_executor(None, cache.__enter__)
+ return self
+
+ async def __aexit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_val: BaseException | None,
+ exc_tb: TracebackType | None,
+ ) -> None:
+ loop = asyncio.get_running_loop()
+ cache = self.cache
+ assert cache is not None
+ result = await loop.run_in_executor(None, cache.__exit__, exc_type, exc_val, exc_tb)
+ self.cache = None
+ return result
+
+ async def set(
+ self,
+ key: str,
+ # pyre-fixme[46]: ValueType is covariant
+ value: ValueType,
+ expire: int | None = None,
+ read: bool = False,
+ tag: str | None = None,
+ retry: bool = False,
+ ) -> bool:
+ cache = self.cache
+ assert cache is not None
+ loop = asyncio.get_running_loop()
+ assert isinstance(value, self.value_cls), f"Expected {self.value_cls}, got {type(value)}"
+ serialized_value = serialize_to_json(value)
+ return await loop.run_in_executor(None, cache.set, key, serialized_value, expire, read, tag, retry)
+
+ async def delete(self, key: str, retry: bool = False) -> bool:
+ cache = self.cache
+ assert cache is not None
+ loop = asyncio.get_running_loop()
+ return await loop.run_in_executor(None, cache.delete, key, retry)
+
+ async def get(
+ self,
+ key: str,
+ default: ValueType | None = None,
+ read: bool = False,
+ expire_time: bool = False,
+ tag: bool = False,
+ retry: bool = False,
+ ) -> ValueType | None:
+ cache = self.cache
+ assert cache is not None
+ loop = asyncio.get_running_loop()
+ value = await loop.run_in_executor(None, cache.get, key, None, read, expire_time, tag, retry)
+ if value is None:
+ return default
+ deserialized_value = deserialize_from_json(value)
+ assert isinstance(
+ deserialized_value, self.value_cls
+ ), f"Expected {self.value_cls}, got {type(deserialized_value)}"
+ return deserialized_value
+
+ # TODO: this is not smart implementation, but at least it will be possible to optimize later without refactoring
+ async def get_all(
+ self,
+ keys: Sequence[str],
+ default: ValueType | None = None,
+ read: bool = False,
+ expire_time: bool = False,
+ tag: bool = False,
+ retry: bool = False,
+ ) -> FrozenMapping[str, ValueType | None]:
+ tasks = {}
+ for key in keys:
+ tasks[key] = self.get(key, default, read, expire_time, tag, retry)
+ results = await asyncio.gather(*tasks.values())
+ return FrozenDict(zip(tasks.keys(), results))
+
+ # TODO: might be nice to get iterkeys back someday, but whatever for now, too annoying to get the sync/async right
+ async def get_all_keys(self, reverse: bool = False) -> tuple[str, ...]:
+ cache = self.cache
+ assert cache is not None
+ loop = asyncio.get_running_loop()
+ return tuple(await loop.run_in_executor(None, cache.iterkeys, reverse))
+
+
+@lru_cache
+def get_cache(data_path: Path) -> Cache:
+ # not sure if the size limit applies when eviction is none, but ~64GB should be enough for now
+ return Cache(
+ str(data_path),
+ disk=JSONDisk,
+ disk_compress_level=0,
+ eviction_policy="none",
+ size_limit=2**36,
+ )
+
+
+def get_default_llm_response_cache() -> Path:
+ return Path(os.environ.get("RESPONSE_CACHE_PATH", os.path.expanduser("~/.llm_response_cache")))
+
+
+def get_default_count_tokens_cache() -> Path:
+ return Path(os.environ.get("COUNT_TOKENS_CACHE_PATH", os.path.expanduser("~/.count_tokens_cache")))
+
+
+def get_test_llm_response_cache() -> Path:
+ return Path(os.path.expanduser("~/.llm_test_response_cache"))
+
+
+class InMemoryCache(AsyncCacheInterface[ValueType], Generic[ValueType]):
+ def __init__(self, values: tuple[ValueType, ...]) -> None:
+ self.values = values
+
+ async def __aenter__(self) -> Self:
+ return self
+
+ async def __aexit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_val: BaseException | None,
+ exc_tb: TracebackType | None,
+ ) -> None:
+ pass
+
+ async def get(
+ self,
+ key: str,
+ default: ValueType | None = None,
+ read: bool = False,
+ expire_time: bool = False,
+ tag: bool = False,
+ retry: bool = False,
+ ) -> ValueType | None:
+ return self.values[int(key)]
+
+ async def get_all(
+ self,
+ keys: Sequence[str],
+ default: ValueType | None = None,
+ read: bool = False,
+ expire_time: bool = False,
+ tag: bool = False,
+ retry: bool = False,
+ ) -> FrozenMapping[str, ValueType | None]:
+ return FrozenDict(zip(await self.get_all_keys(), self.values))
+
+ async def get_all_keys(self, reverse: bool = False) -> tuple[str, ...]:
+ return tuple(map(str, range(len(self.values))))
diff --git a/imbue_core/imbue_core/cattrs_serialization.py b/imbue_core/imbue_core/cattrs_serialization.py
@@ -0,0 +1,1013 @@
+import abc
+import asyncio
+import base64
+import builtins
+import datetime
+import functools
+import importlib
+import inspect
+import json
+from decimal import Decimal
+from enum import Enum
+from functools import cached_property
+from functools import lru_cache
+from functools import partial
+from pathlib import Path
+from pathlib import PosixPath
+from types import NoneType
+from types import UnionType
+from typing import Any
+from typing import Callable
+from typing import ForwardRef
+from typing import Hashable
+from typing import Mapping
+from typing import TypeVar
+from typing import Union
+from typing import cast
+from typing import get_origin
+from uuid import UUID
+
+import anyio
+import attr
+from cachetools import LRUCache
+from cattrs import Converter
+from cattrs._compat import is_generic
+from cattrs.gen import make_dict_unstructure_fn
+from cattrs.gen import override
+from httpx import URL
+from humps import camelize # pyre-ignore[21]: pyre doesn't understand this import
+from pydantic import BaseModel
+from pydantic_core import PydanticUndefined
+
+from imbue_core.errors import ImbueError
+from imbue_core.fixed_traceback import FixedTraceback
+from imbue_core.frozen_utils import FrozenDict
+from imbue_core.frozen_utils import FrozenMapping
+from imbue_core.serialization import SerializedException
+from imbue_core.serialization_types import Serializable
+
+T = TypeVar("T")
+TYPE_KEY = "__type"
+EXCEPTION_KEY = "__exception"
+
+# LABELS for marking attributes with special handling
+DONT_SERIALIZE_METADATA_KEY = "_imbue_dont_serialize"
+DONT_SERIALIZE = {DONT_SERIALIZE_METADATA_KEY: True}
+SERIALIZE_WITH_DEFAULT_KEY = "_imbue_serialize_with_default"
+SERIALIZE_WITH_DEFAULT = {SERIALIZE_WITH_DEFAULT_KEY: True}
+
+SERIALIZABLE_PROPERTY_KEY = "_imbue_is_serializable_property"
+CACHED_SERIALIZABLE_PROPERTY_KEY = "_imbue_is_cached_serializable_property"
+
+
+##########################################################################################
+# UTILITY FUNCTIONS
+##########################################################################################
+
+
+def _safe_issubclass(t1: type, t2: type) -> bool:
+ return inspect.isclass(t1) and issubclass(t1, t2)
+
+
+def _is_frozen_mapping_type(t: type) -> bool:
+ return _safe_issubclass(get_origin(t) or t, FrozenMapping)
+
+
+def _is_mapping_type(t: type) -> bool:
+ return _safe_issubclass(get_origin(t) or t, Mapping)
+
+
+_ALLOWED_SPECIAL_MAPPING_TYPES = (LRUCache,)
+
+
+def _is_special_mapping_type(t: type) -> bool:
+ return t in _ALLOWED_SPECIAL_MAPPING_TYPES
+
+
+def _is_str_type_special_mapping_type(t: str) -> bool:
+ return t in [_type_to_string(t, fully_qualified=True) for t in _ALLOWED_SPECIAL_MAPPING_TYPES]
+
+
+def _is_obj_supported_primitive(obj: Any) -> bool:
+ return type(obj) in {bool, int, float, str, NoneType}
+
+
+def _type_to_string(t: type, fully_qualified: bool) -> str:
+ name = t.__name__
+ if fully_qualified:
+ return f"{t.__module__}.{name}"
+ else:
+ return name
+
+
+def _type_from_string(type_str: str) -> Any:
+ if "[" in type_str:
+ class_details, _ = type_str.split("[", 1)
+ else:
+ class_details = type_str
+ if "." in class_details:
+ module_path, class_name = class_details.rsplit(".", 1)
+ module = importlib.import_module(module_path)
+ else:
+ class_name = class_details
+ module = builtins
+ result = getattr(module, class_name)
+ return result
+
+
+def get_serializable_properties(obj: Any) -> dict[str, Any]:
+ members = inspect.getmembers(type(obj))
+ marked_members = {}
+ for name, member in members:
+ if is_serializable_property(member):
+ marked_members[name] = getattr(obj, name)
+ return marked_members
+
+
+def is_serializable_property(func: Callable) -> bool:
+ return getattr(func, CACHED_SERIALIZABLE_PROPERTY_KEY, False) or (
+ isinstance(func, property) and getattr(func.fget, SERIALIZABLE_PROPERTY_KEY, False)
+ )
+
+
+def cached_serializable_property(func: Callable[..., T]) -> cached_property[T]:
+ property_to_return = cached_property(func)
+ setattr(property_to_return, CACHED_SERIALIZABLE_PROPERTY_KEY, True)
+ return property_to_return
+
+
+def serializable_property(func: Callable[..., T]) -> property:
+ property_to_return = func
+ # NOTE: this will be stored in the fget attribute of the property, which is also the function
+ # we are decorating, so we must check in `func.fget` to see if the property is serializable.
+ # We need to do it this way because we cannot set the attribute on the property object/wrapper
+ # itself, because of the way the inbuilt `property` decorator works.
+ setattr(property_to_return, SERIALIZABLE_PROPERTY_KEY, True)
+ return property(property_to_return)
+
+
+def get_dont_serialize_member_names_of_type(obj_type: type) -> list[str]:
+ if not attr.has(obj_type):
+ return []
+ return [field.name for field in attr.fields(obj_type) if field.metadata.get(DONT_SERIALIZE_METADATA_KEY, False)]
+
+
+def get_serialize_with_default_member_names_of_type(
+ obj_type: type,
+) -> Mapping[str, Any]:
+ if _safe_issubclass(obj_type, BaseModel):
+ model_fields = getattr(obj_type, "model_fields", {})
+ return {
+ name: None if field.default == PydanticUndefined else field.default for name, field in model_fields.items()
+ }
+ if not attr.has(obj_type):
+ return {}
+ return {
+ field.name: None if field.default == attr.NOTHING else field.default
+ for field in attr.fields(obj_type)
+ if field.metadata.get(SERIALIZE_WITH_DEFAULT_KEY, False)
+ }
+
+
+def get_dont_serialize_member_names(obj: Any) -> list[str]:
+ if not attr.has(obj):
+ return []
+ members = inspect.getmembers(obj)
+ marked_members = []
+ for name, _ in members:
+ if is_dont_serialize_member(obj, name):
+ marked_members.append(name)
+ return marked_members
+
+
+def is_dont_serialize_member(obj: Any, member_name: str) -> bool:
+ if not attr.has(obj):
+ return False
+ for field in attr.fields(obj.__class__): # type: ignore
+ if field.name == member_name:
+ return bool(field.metadata.get(DONT_SERIALIZE_METADATA_KEY, False))
+ return False
+
+
+class SerializationError(ImbueError):
+ """Raised when we encounter problems related to Serialization or Deserialization."""
+
+
+def _to_json_dumpable_object_without_type_keys(data: Any) -> Any:
+ if isinstance(data, dict):
+ if data.get(TYPE_KEY, "") in {
+ _type_to_string(PosixPath, fully_qualified=True),
+ _type_to_string(Path, fully_qualified=True),
+ _type_to_string(UUID, fully_qualified=True),
+ }:
+ return data["value"]
+ else:
+ return {
+ key: _to_json_dumpable_object_without_type_keys(value) for key, value in data.items() if key != TYPE_KEY
+ }
+ elif isinstance(data, list):
+ return [_to_json_dumpable_object_without_type_keys(item) for item in data]
+ elif _is_obj_supported_primitive(data):
+ return data
+ else:
+ return str(data)
+
+
+def _camelize_keys_which_represent_python_names(data: Any) -> Any:
+ """Converts JSON-style objects to use camel case keys.
+
+ Takes a JSON-style object produced by CONVERTER.structure and returns the same object with certain
+ keys converted to camel case. Camel cases keys which are derived from names of Python attributes and properties.
+ Does not camel-case keys which were keys of dictionaries before serialization.
+
+ See cattrs_serialization_test.test_camel_casing for an example.
+ """
+ if isinstance(data, dict):
+ if TYPE_KEY not in data or issubclass(_type_from_string(data[TYPE_KEY]), Mapping):
+ return {key: _camelize_keys_which_represent_python_names(value) for key, value in data.items()}
+ else:
+ # pyre-ignore[16]: pyre doesn't understand the import of camelize
+ return {camelize(key): _camelize_keys_which_represent_python_names(value) for key, value in data.items()}
+ elif isinstance(data, list):
+ return [_camelize_keys_which_represent_python_names(item) for item in data]
+ else:
+ return data
+
+
+##########################################################################################
+# CLASS-SPECIFIC HOOKS
+##########################################################################################
+
+
+class _ShouldDeserialize:
+ pass
+
+
+# FIXME: Types such as LRUCache will always serialize without errors since they inherit from Mapping but will not deserialize correctly.
+# We should either document this behavior or change it so that the serialization fails if the type is not supported.
+def _serialize_mapping_to_json_dict(data: Mapping, converter: Converter) -> Any:
+ assert _is_mapping_type(type(data)), f"Attempted to serialize object of type {type(data)} as a mapping."
+ return {str(converter.unstructure(k)): converter.unstructure(v) for k, v in data.items()}
+
+
+def _serialize_mapping(data: Mapping, converter: Converter) -> Any:
+ assert _is_mapping_type(type(data)), f"Attempted to serialize object of type {type(data)} as a mapping."
+ entries = [(converter.unstructure(k), converter.unstructure(v)) for k, v in data.items()]
+ return {
+ TYPE_KEY: _type_to_string(type(data), fully_qualified=True),
+ "__entries": entries,
+ }
+
+
+def _deserialize_special_mapping_types(data: dict, type_key: str) -> Mapping:
+ if type_key == _type_to_string(LRUCache, fully_qualified=True):
+ # FIXME: We're not serializing the object correctly and so the deserialization is hacky
+ obj: LRUCache = LRUCache(maxsize=10000)
+ return obj
+ else:
+ raise ValueError(f"Unsupported type {type_key}")
+
+
+def _deserialize_mapping(data: dict, mapping_type: type, converter: Converter) -> Mapping:
+ if TYPE_KEY in data and _is_str_type_special_mapping_type(data[TYPE_KEY]):
+ return _deserialize_special_mapping_types(data, data[TYPE_KEY])
+
+ out = {}
+ if "__entries" in data:
+ entries = data["__entries"]
+ else:
+ # We keep this branch for backwards compatibility with mappings serialized as dictionaries.
+ # We do not support Yasoo's DictWithSerializedKeys -- those will need to be migrated to the new format.
+ if TYPE_KEY in data:
+ del data[TYPE_KEY]
+ entries = data.items()
+
+ for k, v in entries:
+ out[converter.structure(k, _ShouldDeserialize)] = converter.structure(v, _ShouldDeserialize)
+
+ if _is_frozen_mapping_type(mapping_type):
+ return FrozenDict(out)
+ return out
+
+
+def _serialize_frozen_set(data: frozenset, converter: Converter) -> dict:
+ assert type(data) is frozenset, f"Attempted to serialize object of type {type(data)} as a frozenset."
+ value = converter.unstructure(data, unstructure_as=list)
+ return {"value": value, TYPE_KEY: _type_to_string(type(data), fully_qualified=True)}
+
+
+def _deserialize_frozen_set(data: dict, _: type, converter: Converter) -> frozenset:
+ return frozenset(converter.structure(data["value"], list))
+
+
+def _serialize_uuid(data: UUID) -> dict:
+ if type(data) is UUID:
+ return {
+ "value": data.hex,
+ TYPE_KEY: _type_to_string(type(data), fully_qualified=True),
+ }
+ elif type(data) is str:
+ return {"value": data, TYPE_KEY: _type_to_string(UUID, fully_qualified=True)}
+ else:
+ raise TypeError("Tried to serialize " + str(data) + ", which is neither a string nor a UUID, as a UUID.")
+
+
+def _deserialize_uuid(data: dict[str, str] | str, _: type) -> UUID:
+ if isinstance(data, dict):
+ return UUID(data["value"])
+ elif isinstance(data, str):
+ return UUID(data)
+ else:
+ raise TypeError("Tried to deserialize something which is neither a string nor a dictionary, as a UUID.")
+
+
+def _serialize_tuple(data: tuple, converter: Converter) -> dict:
+ assert type(data) is tuple, f"Attempted to serialize object of type {type(data)} as a tuple."
+ return {
+ "value": [converter.unstructure(x) for x in data],
+ TYPE_KEY: _type_to_string(type(data), fully_qualified=True),
+ }
+
+
+def _deserialize_tuple(data: dict, _: type, converter: Converter) -> tuple:
+ return tuple(converter.structure(x, _ShouldDeserialize) for x in data["value"])
+
+
+def _serialize_url(data: URL) -> dict:
+ assert type(data) is URL, f"Tried to serialize {data} which is not a URL."
+ return {
+ "value": str(data),
+ TYPE_KEY: _type_to_string(type(data), fully_qualified=True),
+ }
+
+
+def _deserialize_url(data: dict, _: type) -> URL:
+ return URL(data["value"])
+
+
+def _serialize_decimal(data: Decimal) -> dict:
+ assert type(data) is Decimal, f"Attempted to serialize object of type {type(data)} as a Decimal."
+ return {
+ "value": str(data),
+ TYPE_KEY: _type_to_string(type(data), fully_qualified=True),
+ }
+
+
+def _deserialize_decimal(data: dict, _: type) -> Decimal:
+ return Decimal(data["value"])
+
+
+def _serialize_traceback(data: FixedTraceback) -> dict:
+ assert _safe_issubclass(
+ type(data), FixedTraceback
+ ), f"Attempted to serialize object of type {type(data)} as a traceback."
+ return {
+ "value": data.to_dict(),
+ TYPE_KEY: _type_to_string(type(data), fully_qualified=True),
+ }
+
+
+def _deserialize_traceback(data: dict, _: type) -> FixedTraceback:
+ return FixedTraceback.from_dict(data["value"])
+
+
+def _serialize_path(data: Path) -> dict:
+ assert _safe_issubclass(type(data), Path), f"Attempted to serialize an object of type {type(data)} as a Path."
+ return {
+ "value": str(data),
+ TYPE_KEY: _type_to_string(type(data), fully_qualified=True),
+ }
+
+
+def _deserialize_path(data: Any, _: type) -> Path:
+ if type(data) is dict:
+ return Path(data["value"])
+ return Path(data)
+
+
+def _serialize_anyio_path(data: anyio.Path) -> dict:
+ assert _safe_issubclass(type(data), anyio.Path), f"Attempted to serialize an object of type {type(data)} as a Path."
+ return {
+ "value": str(data),
+ TYPE_KEY: _type_to_string(type(data), fully_qualified=True),
+ }
+
+
+def _deserialize_anyio_path(data: Any, _: type) -> anyio.Path:
+ if type(data) is dict:
+ return anyio.Path(data["value"])
+ return anyio.Path(data)
+
+
+def _serialize_datetime(data: datetime.datetime) -> dict:
+ assert _safe_issubclass(
+ type(data), datetime.datetime
+ ), f"Attempted to serialize object of type {type(data)} as a datetime."
+ return {
+ TYPE_KEY: _type_to_string(type(data), fully_qualified=True),
+ "time": data.astimezone(datetime.timezone.utc).timestamp(),
+ "tzaware": data.tzinfo is not None,
+ }
+
+
+def _deserialize_datetime(data: dict, _: type) -> datetime.datetime:
+ return datetime.datetime.fromtimestamp(data["time"], datetime.timezone.utc if data.get("tzaware", None) else None)
+
+
+def _serialize_bytes(data: bytes) -> dict:
+ assert type(data) is bytes, f"Attempted to serialize object of type {type(data)} as bytes."
+ return {
+ TYPE_KEY: _type_to_string(type(data), fully_qualified=True),
+ # use ascii since base64 guarantees ascii characters only
+ "value": base64.b64encode(data).decode("ascii"),
+ }
+
+
+def _deserialize_bytes(data: dict, _: type) -> bytes:
+ return base64.b64decode(data["value"])
+
+
+def _is_forward_ref(t: type) -> bool:
+ return isinstance(t, ForwardRef)
+
+
+def _serialize_forward_ref(data: Any, converter: Converter) -> Any:
+ return converter.unstructure(data, unstructure_as=type(data))
+
+
+def _deserialize_forward_ref(data: Any, _: type, converter: Converter) -> Any:
+ # TODO: think of a way to evaluate the ForwardRef _, to improve type safety.
+ # Once we do that, we can swap out the evaluated type for ShouldDeserialize
+ # and enforce that we're getting an object of the correct type.
+ return _deserialize_serialized_object(data, _ShouldDeserialize, converter)
+
+
+def _is_union_type(t: type) -> bool:
+ origin = get_origin(t)
+ return origin is Union or origin is UnionType
+
+
+def _deserialize_union_type(data: Any, type_of_data: type, converter: Converter) -> Any:
+ return converter.structure(data, _ShouldDeserialize)
+
+
+def _serialize_enum(data: Enum, converter: Converter) -> Any:
+ assert inspect.isclass(type(data)) and issubclass(
+ type(data), Enum
+ ), f"Attempted to serialize object of type {type(data)} as an Enum."
+ return converter._unstructure_enum(data)
+
+
+def _deserialize_enum(data: dict[str, str] | str, t: type) -> Any:
+ # We include this complicated logic to preserve backwards compatibility with old JSON that was
+ # serialized by Yasoo. Yasoo serialized enums by converting them into the form
+ # {"__type": "...", "value": "..."}. Strangely, Yasoo converted this dictionary into a string
+ # whenever an enum value occurred as a dictionary key, but did not convert it into a string
+ # when it occurred anywhere else. Hence we need to handle enums that are represented by
+ # dictionaries, stringified dictionaries, and strings.
+
+ assert _safe_issubclass(t, Enum)
+
+ if isinstance(data, str):
+ try:
+ # This is the case where data is an enum value, serialized by Cattrs.
+ return t(data)
+ except ValueError:
+ # This is the case where data is a stringified dictionary, serialized by Yasoo.
+ data_as_dict = json.loads(data)
+ return t[data_as_dict["value"]] # type: ignore
+ else:
+ # This is the case where data is a dictionary, serialized by Yasoo.
+ return t[data["value"]] # type: ignore
+
+
+##########################################################################################
+# TYPE KEY LOGIC
+##########################################################################################
+
+
+class _AvoidTypeKeyLogic:
+ pass
+
+
+@lru_cache
+def flag_to_ignore_type_key_hooks(t: type) -> type:
+ class GivenTypeFlaggedToAvoidTypeKeyLogic(t, _AvoidTypeKeyLogic):
+ pass
+
+ GivenTypeFlaggedToAvoidTypeKeyLogic.__name__ = t.__name__
+ GivenTypeFlaggedToAvoidTypeKeyLogic.__qualname__ = t.__qualname__
+
+ # pyre-fixme[16]: pyre doesn't understand dynamically created classes
+ return GivenTypeFlaggedToAvoidTypeKeyLogic
+
+
+def get_pydantic_model_attributes(model: BaseModel) -> dict[str, Any]:
+ # This is a hack to dump only the top level but also avoid dumping any properties
+ attributes = getattr(type(model), "model_fields", {})
+ return {a: getattr(model, a) for a in attributes}
+
+
+# These two factory functions produce the functions for serializing attr classes.
+# Only one of them should be registered at a time, depending on whether we are including
+# do-not-serialize fields in the serialization.
+def _serialize_attr_class_factory(cls: type, converter: Converter) -> Callable[[Any], Any]:
+ return make_dict_unstructure_fn(cls, converter)
+
+
+def _serialize_attr_class_without_dont_serialize_fields(
+ cls: type, converter: Converter, is_camel_case: bool
+) -> Callable[[Any], Any]:
+ members_to_omit = get_dont_serialize_member_names_of_type(cls)
+ omit_kwargs = {name: override(omit=True) for name in members_to_omit}
+ return make_dict_unstructure_fn(cls, converter, **omit_kwargs) # type: ignore
+
+
+def _serialize_with_type_key(data: Any, converter: Converter, for_javascript: bool = False) -> Any:
+ type_of_data = type(data)
+
+ if _is_obj_supported_primitive(data) or isinstance(data, list) or isinstance(data, tuple):
+ # This means that data was annotated as a Serializable, but it is a primitive or a tuple.
+ return converter.unstructure(data, unstructure_as=type_of_data)
+
+ type_of_data_with_typekey_already_added = flag_to_ignore_type_key_hooks(type_of_data) # type: ignore
+
+ # This is a hack which is necessary because cattrs does not work well with Protocols.
+ # Protocols are generic classes, but they don't have __orig_bases__, which cattrs
+ # assumes them to have.
+ if is_generic(type_of_data_with_typekey_already_added):
+ old_orig_bases = getattr(type_of_data_with_typekey_already_added, "__orig_bases__", ())
+ setattr(type_of_data_with_typekey_already_added, "__orig_bases__", old_orig_bases)
+
+ if isinstance(data, BaseModel):
+ # This is a shortcut: when you encounter a Pydantic model, just use Pydantic serialization.
+ # NOTE: currently we don't support `DONT_SERIALIZE` fields in pydantic models.
+ # so we just serialize all fields.
+ unstructured = data.model_dump(by_alias=for_javascript, mode="json")
+ else:
+ unstructured = converter.unstructure(data, unstructure_as=type_of_data_with_typekey_already_added)
+
+ assert isinstance(unstructured, dict)
+
+ if for_javascript:
+ unstructured.update({k: converter.unstructure(v) for k, v in get_serializable_properties(data).items()})
+
+ return {
+ TYPE_KEY: _type_to_string(type_of_data, fully_qualified=True),
+ **unstructured,
+ }
+
+
+# This is the predicate used in the factory functions above, so they trigger for serializable and attr classes
+# that have had their type key logic handled.
+def _should_serialize_without_type_key(t: type) -> bool:
+ is_serializable_class = _safe_issubclass(t, Serializable) or attr.has(t) or _safe_issubclass(t, BaseModel)
+ return is_serializable_class and _safe_issubclass(t, _AvoidTypeKeyLogic)
+
+
+def _should_add_type_key(t: type) -> bool:
+ is_serializable_class = _safe_issubclass(t, Serializable) or attr.has(t) or _safe_issubclass(t, BaseModel)
+ return is_serializable_class and not _safe_issubclass(t, _AvoidTypeKeyLogic)
+
+
+def _deserialize_serialized_object(data: Any, type_of_data: type, converter: Converter) -> Any:
+ if isinstance(data, list):
+ # Data is a list of objects.
+ return converter.structure(data, list[_ShouldDeserialize])
+ elif not isinstance(data, Mapping):
+ # Data is a primitive, like an integer or a string.
+ return converter.structure(data, type(data))
+ else:
+ # Data is a dictionary with a type key, representing an attrs object, a Pydantic model, or a Mapping
+ return _deserialize_using_type_marker(data, type_of_data, converter)
+
+
+def _should_deserialize_with_type_key_logic(t: type) -> bool:
+ is_type_that_should_be_deserialized = (
+ attr.has(t)
+ or _safe_issubclass(t, Serializable)
+ or _safe_issubclass(t, _ShouldDeserialize)
+ or t is Hashable
+ or _is_mapping_type(t)
+ or _safe_issubclass(t, BaseModel)
+ )
+ should_avoid_type_key_logic = _safe_issubclass(t, _AvoidTypeKeyLogic) or _safe_issubclass(
+ get_origin(t) or NoneType, _AvoidTypeKeyLogic
+ )
+ return is_type_that_should_be_deserialized and not should_avoid_type_key_logic
+
+
+def deserialized_object_violates_target_type(obj: Any, target_type: type) -> bool:
+ if target_type is _ShouldDeserialize or target_type is Serializable:
+ return False
+ if type(target_type) is TypeVar:
+ # We're not really able to check if the object is an instance of a type that's behind a TypeVar.
+ return False
+ return not isinstance(obj, get_origin(target_type) or target_type)
+
+
+# Note that expected_type_based_on_annotations may be much more vague than the actual type of the object.
+# For example: it may be Serializable, when the object is supposed to be
+# deserialized as a HammerResult. We get the real type from the "__type" key.
+def _deserialize_using_type_marker(
+ obj: Mapping[Any, Any],
+ expected_type_based_on_annotations: type[T],
+ converter: Converter,
+) -> T:
+ if TYPE_KEY in obj:
+ type_of_obj = _type_from_string(obj[TYPE_KEY])
+ else:
+ type_of_obj = expected_type_based_on_annotations
+
+ if _is_special_mapping_type(type_of_obj):
+ pass
+ elif _is_frozen_mapping_type(type_of_obj):
+ obj.pop(TYPE_KEY, None) # type: ignore
+ type_of_obj = FrozenMapping[_ShouldDeserialize, _ShouldDeserialize]
+ elif _is_mapping_type(type_of_obj):
+ obj.pop(TYPE_KEY, None) # type: ignore
+ type_of_obj = dict[_ShouldDeserialize, _ShouldDeserialize]
+ elif _safe_issubclass(type_of_obj, BaseModel):
+ assert isinstance(obj, dict)
+ obj.pop(TYPE_KEY, None)
+ return cast(T, type_of_obj.model_validate(obj))
+ elif not attr.has(type_of_obj):
+ # This happens when there is a primitive object which is annotated as Serializable.
+ return converter.structure(obj, type_of_obj) # type: ignore
+
+ # By mixing in the "avoid type key logic" class, force cattrs to do its normal behavior.
+ ret: T = converter.structure(obj, flag_to_ignore_type_key_hooks(type_of_obj))
+
+ if inspect.isclass(type_of_obj):
+ # Upcast the result so that it has the correct type again, without the mixin.
+ object.__setattr__(ret, "__class__", type_of_obj)
+
+ if deserialized_object_violates_target_type(ret, expected_type_based_on_annotations):
+ raise TypeError(
+ f"Tried to deserialize into type {expected_type_based_on_annotations}, but got object of type {type(ret)}"
+ )
+
+ return ret
+
+
+def _resolve_default(default: Any) -> Any:
+ if isinstance(default, attr.Factory): # type: ignore
+ return default.factory()
+ return default
+
+
+def _serialize_with_defaults(cls: type, converter: Converter) -> Callable[[Any], Any]:
+ # Handle a pydantic model
+ if _safe_issubclass(cls, BaseModel):
+ return lambda x: {k: converter.unstructure(v) for k, v in get_pydantic_model_attributes(x).items()}
+
+ members_with_defaults = get_serialize_with_default_member_names_of_type(cls)
+ overriden_kwargs = {
+ name: override(unstruct_hook=(lambda _, value=_resolve_default(default): value)) # type: ignore
+ for name, default in members_with_defaults.items()
+ }
+ return make_dict_unstructure_fn(cls, converter, **overriden_kwargs) # type: ignore
+
+
+def _should_serialize_as_serialized_exception(t: type) -> bool:
+ return (
+ _safe_issubclass(get_origin(t) or t, BaseException) and not attr.has(t) and not _safe_issubclass(t, BaseModel)
+ )
+
+
+##########################################################################################
+# CONVERTER FACTORY
+##########################################################################################
+
+
+class _ConverterFactory:
+ """Factory for creating converters with different configurations.
+
+ e.g. for serializing to javascript, or python, or to include do-not-serialize fields.
+ """
+
+ def build_base_converter(self) -> Converter:
+ # Builds of new base converter object, which registers all the hooks that are common to all converters.
+ # The idea being that all new converters start from this base and then override hooks they need to change
+ # NOTE: we need to generate a new converter object for each independent concrete converter (as opposed to
+ # using converter.copy()) since we use partial functions/closures and this way we ensure the function is
+ # being called with the correct converter object.
+ converter = Converter()
+
+ converter.register_structure_hook_func(_is_mapping_type, partial(_deserialize_mapping, converter=converter))
+ # serialization of mapping types depends on the specific converter so is done in the get_converter factory method
+
+ converter.register_unstructure_hook(frozenset, partial(_serialize_frozen_set, converter=converter))
+ converter.register_structure_hook(frozenset, partial(_deserialize_frozen_set, converter=converter))
+
+ converter.register_unstructure_hook(UUID, _serialize_uuid)
+ converter.register_structure_hook(UUID, _deserialize_uuid)
+
+ converter.register_unstructure_hook(URL, _serialize_url)
+ converter.register_structure_hook(URL, _deserialize_url)
+
+ converter.register_unstructure_hook(Decimal, _serialize_decimal)
+ converter.register_structure_hook(Decimal, _deserialize_decimal)
+
+ converter.register_unstructure_hook(FixedTraceback, _serialize_traceback)
+ converter.register_structure_hook(FixedTraceback, _deserialize_traceback)
+
+ converter.register_unstructure_hook(Path, _serialize_path)
+ converter.register_structure_hook(Path, _deserialize_path)
+
+ converter.register_unstructure_hook(anyio.Path, _serialize_anyio_path)
+ converter.register_structure_hook(anyio.Path, _deserialize_anyio_path)
+
+ converter.register_unstructure_hook(datetime.datetime, _serialize_datetime)
+ converter.register_structure_hook(datetime.datetime, _deserialize_datetime)
+
+ converter.register_unstructure_hook(bytes, _serialize_bytes)
+ converter.register_structure_hook(bytes, _deserialize_bytes)
+
+ converter.register_unstructure_hook(PosixPath, _serialize_path)
+ converter.register_structure_hook(PosixPath, _deserialize_path)
+
+ converter.register_unstructure_hook_func(_is_forward_ref, partial(_serialize_forward_ref, converter=converter))
+ converter.register_structure_hook_func(_is_forward_ref, partial(_deserialize_forward_ref, converter=converter))
+
+ converter.register_structure_hook_func(_is_union_type, partial(_deserialize_union_type, converter=converter))
+
+ converter.register_structure_hook(NoneType, lambda data, _: None)
+
+ converter.register_unstructure_hook(Enum, partial(_serialize_enum, converter=converter))
+ converter.register_structure_hook(Enum, _deserialize_enum)
+
+ converter.register_unstructure_hook_func(
+ _should_serialize_as_serialized_exception,
+ lambda e: serialize_to_dict(
+ SerializedException.build(e),
+ use_defaults_for_unserializable_fields=True,
+ ),
+ )
+
+ converter.register_structure_hook_func(
+ _should_deserialize_with_type_key_logic,
+ partial(_deserialize_serialized_object, converter=converter),
+ )
+
+ converter.register_structure_hook_func(
+ lambda t: isinstance(t, TypeVar),
+ partial(_deserialize_serialized_object, converter=converter),
+ )
+
+ return converter
+
+ def get_converter_with_defaults(self, converter: Converter) -> Converter:
+ converter.register_unstructure_hook(asyncio.Lock, lambda _: None)
+ converter.register_structure_hook(asyncio.Lock, lambda data, _: asyncio.Lock())
+
+ converter.register_unstructure_hook(asyncio.Task, lambda _: None)
+ converter.register_structure_hook(asyncio.Task, lambda data, _: None)
+
+ converter.register_unstructure_hook(asyncio.Queue, lambda _: None)
+ converter.register_structure_hook(asyncio.Queue, lambda data, _: None)
+
+ converter.register_unstructure_hook(asyncio.Event, lambda _: None)
+ converter.register_structure_hook(asyncio.Event, lambda data, _: None)
+
+ converter.register_unstructure_hook(asyncio.Semaphore, lambda _: None)
+ converter.register_structure_hook(asyncio.Semaphore, lambda data, _: None)
+
+ converter.register_unstructure_hook(abc.ABCMeta, lambda _: None)
+ converter.register_structure_hook(abc.ABCMeta, lambda data, _: None)
+ converter.register_unstructure_hook_factory(
+ _should_serialize_without_type_key,
+ partial(_serialize_with_defaults, converter=converter),
+ )
+
+ return converter
+
+ @functools.cache
+ def get_converter(
+ self,
+ for_javascript: bool = False,
+ exclude_dont_serialize_fields: bool = False,
+ use_defaults_for_unserializable_fields: bool = False,
+ ) -> Converter:
+ """Returns a converter with the given configuration.
+
+ The result of this method is cached, so subsequent calls with the same arguments will return the same converter.
+ """
+ assert not (
+ exclude_dont_serialize_fields and use_defaults_for_unserializable_fields
+ ), f"Expected exactly one flag to be set, got {exclude_dont_serialize_fields=}, {use_defaults_for_unserializable_fields=}"
+
+ converter = self.build_base_converter()
+ if for_javascript:
+ converter.register_unstructure_hook_func(
+ _is_mapping_type,
+ partial(_serialize_mapping_to_json_dict, converter=converter),
+ )
+ else:
+ converter.register_unstructure_hook_func(_is_mapping_type, partial(_serialize_mapping, converter=converter))
+ converter.register_unstructure_hook(tuple, partial(_serialize_tuple, converter=converter))
+ converter.register_structure_hook(tuple, partial(_deserialize_tuple, converter=converter))
+
+ if exclude_dont_serialize_fields:
+ converter.register_unstructure_hook_factory(
+ _should_serialize_without_type_key,
+ partial(
+ _serialize_attr_class_without_dont_serialize_fields,
+ converter=converter,
+ is_camel_case=for_javascript,
+ ),
+ )
+ else:
+ converter.register_unstructure_hook_factory(
+ _should_serialize_without_type_key,
+ partial(_serialize_attr_class_factory, converter=converter),
+ )
+ if use_defaults_for_unserializable_fields:
+ converter = self.get_converter_with_defaults(converter)
+
+ converter.register_unstructure_hook_func(
+ _should_add_type_key,
+ partial(
+ _serialize_with_type_key,
+ converter=converter,
+ for_javascript=for_javascript,
+ ),
+ )
+
+ return converter
+
+
+CONVERTER_FACTORY = _ConverterFactory()
+
+
+##########################################################################################
+# ENTRY POINTS
+##########################################################################################
+
+
+def _serialize_to_json_dumpable_object(
+ obj: Any,
+ is_reversible: bool = True,
+ for_javascript: bool = False,
+ exclude_dont_serialize_fields: bool = False,
+ use_defaults_for_unserializable_fields: bool = False,
+) -> Any:
+ if exclude_dont_serialize_fields:
+ # Check and raise error to make it clear to the caller that the object cannot be deserialized.
+ # This is a sanity check, to make it easier to debug when using do-not-serialize fields.
+ # NOTE: this will only catch cases where non-serializable fields are in obj, but not cases where
+ # the non-serializable fields are in nested objects, checking for the nested case is a little complicated
+ # so we don't do it basically.
+ assert (
+ not is_reversible
+ ), "Cannot deserialize object when excluding do-not-serialize fields (i.e. when `exclude_dont_serialize_fields=True`). If you want to serialize an object and exclude do-not-serialize fields, make sure to set `is_reversible=False`."
+
+ if use_defaults_for_unserializable_fields:
+ # The point of the use_defaults_for_unserializable_fields flag is to make it possible to serialize objects
+ # and then recreate them later even if certain fields are not fully saved. We never want to use this flag
+ # with `is_reversible=False` since we won't know the type to be able to recreate the object.
+ assert is_reversible, "Cannot restructure inputs if is_reversible=False"
+
+ # TODO: this is a hack to make it possible to serialize ExecutionContexts for class method hammers.
+ # This lets us serialize ExecutionContexts for calls to class methods without serializing the class itself.
+ # The long-term solutions are 1) either get rid of all class method hammers,
+ # or 2) write a custom hook that can serialize type objects.
+ if type(obj) is dict and "__class__" in obj:
+ del obj["__class__"]
+
+ converter = CONVERTER_FACTORY.get_converter(
+ for_javascript=for_javascript,
+ exclude_dont_serialize_fields=exclude_dont_serialize_fields,
+ use_defaults_for_unserializable_fields=use_defaults_for_unserializable_fields,
+ )
+
+ dict_result = converter.unstructure(obj)
+ if for_javascript:
+ dict_result = _camelize_keys_which_represent_python_names(dict_result)
+
+ if not is_reversible:
+ return _to_json_dumpable_object_without_type_keys(dict_result)
+
+ return dict_result
+
+
+def serialize_to_dict(
+ obj: Any,
+ is_reversible: bool = True,
+ for_javascript: bool = False,
+ exclude_dont_serialize_fields: bool = False,
+ use_defaults_for_unserializable_fields: bool = False,
+) -> dict[str, Any]:
+ """Serialize to a python dict."""
+ return cast(
+ dict[str, Any],
+ _serialize_to_json_dumpable_object(
+ obj,
+ is_reversible=is_reversible,
+ for_javascript=for_javascript,
+ exclude_dont_serialize_fields=exclude_dont_serialize_fields,
+ use_defaults_for_unserializable_fields=use_defaults_for_unserializable_fields,
+ ),
+ )
+
+
+def serialize_to_json(
+ obj: Any,
+ indent: int | None = None,
+ sort_keys: bool = False,
+ is_reversible: bool = True,
+ for_javascript: bool = False,
+ exclude_dont_serialize_fields: bool = False,
+ use_defaults_for_unserializable_fields: bool = False,
+) -> str:
+ """Serialize an object to a JSON string.
+
+ This is the main serialization entrypoint.
+
+ `is_reversible` controls whether we enforce that the result can be deserialized. In some cases we don't care about
+ reversibility, e.g. when serializing data for a frontend we often don't care whether we can deserialize.
+
+ `for_javascript` controls whether we use camelCase for keys that originally were Python identifiers.
+
+ `exclude_dont_serialize_fields` controls whether we include do-not-serialize fields in the serialization.
+ If this is `False` then any attr class fields marked with as don't serialize, e.g. with `attr.ib(metadata=DONT_SERIALIZE)`,
+ will still be included in the serialization. If this is `True` then they will be excluded, however this also means that
+ the result will not be reversible (and thus the caller will have to set `is_reversible=False`).
+
+ `use_defaults_for_unserializable_fields` controls whether we fill fields that cannot be serialized with their default values.
+ IMPORTANT: If you use this flag, data may be discarded during deserialization.
+ The goal is to be able to deserialize fields to the original type without caring about the data contained.
+ Default value choices (guided by crafty serialization requirements):
+ - Fields that are marked with attr.ib(metadata=SERIALIZE_WITH_DEFAULT) have the following default values:
+ - Fields that are marked with `attr.ib(default=...)` or `attr.ib(factory=...)` use their default values.
+ - Fields that do not have a default value are filled with None.
+ - Asyncio objects are filled with None.
+ - Exceptions are replaced with a string representation
+ """
+ try:
+ unstructured = _serialize_to_json_dumpable_object(
+ obj,
+ is_reversible=is_reversible,
+ for_javascript=for_javascript,
+ exclude_dont_serialize_fields=exclude_dont_serialize_fields,
+ use_defaults_for_unserializable_fields=use_defaults_for_unserializable_fields,
+ )
+ return json.dumps(unstructured, indent=indent, sort_keys=sort_keys)
+ except Exception as e:
+ raise SerializationError(str(e)) from e
+
+
+def deserialize_from_json(
+ data: str,
+ for_javascript: bool = False,
+ exclude_dont_serialize_fields: bool = False,
+ use_defaults_for_unserializable_fields: bool = False,
+) -> Any:
+ try:
+ converter = CONVERTER_FACTORY.get_converter(
+ for_javascript=for_javascript,
+ exclude_dont_serialize_fields=exclude_dont_serialize_fields,
+ use_defaults_for_unserializable_fields=use_defaults_for_unserializable_fields,
+ )
+ return _deserialize_serialized_object(json.loads(data), _ShouldDeserialize, converter=converter)
+ except Exception as e:
+ raise SerializationError(str(e)) from e
+
+
+def deserialize_from_dict(
+ data: dict[str, Any],
+ as_type: type = _ShouldDeserialize,
+ for_javascript: bool = False,
+ exclude_dont_serialize_fields: bool = False,
+ use_defaults_for_unserializable_fields: bool = False,
+) -> Any:
+ try:
+ converter = CONVERTER_FACTORY.get_converter(
+ for_javascript=for_javascript,
+ exclude_dont_serialize_fields=exclude_dont_serialize_fields,
+ use_defaults_for_unserializable_fields=use_defaults_for_unserializable_fields,
+ )
+ return _deserialize_using_type_marker(data, as_type, converter=converter)
+ except Exception as e:
+ raise SerializationError(str(e)) from e
+
+
+def deserialize_from_dict_with_type(data: dict[str, Any], obj_type: type[T]) -> T:
+ try:
+ converter = CONVERTER_FACTORY.get_converter(for_javascript=False, exclude_dont_serialize_fields=False)
+ result = converter.structure(data, obj_type)
+ assert isinstance(result, obj_type), f"Expected an object of type {obj_type}, but got {result}"
+ return result
+ except Exception as e:
+ raise SerializationError(str(e)) from e
+
+
+def deserialize_from_json_with_type(data: str | bytes | bytearray, obj_type: type[T]) -> T:
+ try:
+ converter = CONVERTER_FACTORY.get_converter(for_javascript=False, exclude_dont_serialize_fields=False)
+ return cast(
+ T,
+ _deserialize_serialized_object(json.loads(data), obj_type, converter=converter),
+ )
+ except Exception as e:
+ raise SerializationError(str(e)) from e
diff --git a/imbue_core/imbue_core/common.py b/imbue_core/imbue_core/common.py
@@ -0,0 +1,135 @@
+import functools
+import hashlib
+import inspect
+import os
+import platform
+import sys
+import uuid
+from pathlib import Path
+from types import FrameType
+
+import pathspec
+
+
+def is_on_osx() -> bool:
+ return platform.system().lower() == "darwin"
+
+
+def is_running_within_a_pytest_tree() -> bool:
+ """
+ This is true if this, or any parent process, is running under pytest.
+
+ This is different from `is_running_within_a_pytest_process` in that it is true if we are logically testing or not.
+
+ This is usually what you want to check
+ (eg, this will be true even if you are a separately launched integration server process)
+ """
+ return "PYTEST_CURRENT_TEST" in os.environ
+
+
+def is_running_within_a_pytest_process() -> bool:
+ """
+ This is true if the current process is literally running pytest.
+
+ This is different from `is_running_within_a_pytest_tree` in that it checks if the current process is pytest itself,
+ which is most useful for knowing whether we are running a bunch of unit tests in this process or not.
+ """
+ return "pytest" in sys.modules
+
+
+def is_live_debugging() -> bool:
+ """
+ Returns True if the current process is being debugged, for example by PyCharm or another IDE.
+ """
+ # this is unfortunately true when measuring coverage and in other cases, sigh
+ # return sys.gettrace() is not None
+ # but this is only true when debugging in pycharm, I think?
+ return sys.breakpointhook.__module__ != "sys"
+
+
+@functools.lru_cache(maxsize=1)
+def get_filesystem_root() -> str:
+ env_value = os.getenv("SCIENCE_FILESYSTEM_ROOT")
+ if not env_value:
+ if is_on_osx():
+ return "/tmp/science"
+ else:
+ # When on the physical cluster (and possibly other core clusters), this path is mounted to a unique per-container file path.
+ # Anything produced at runtime >10mb should likely go here, as well as anything you might want to dig up for later debugging.
+ # The hosts clean up the paths from dead containers periodically, but large data processing jobs should still clean up after themselves.
+ return "/mnt/private"
+ return env_value
+
+
+@functools.lru_cache(maxsize=1)
+def get_temp_dir() -> str:
+ temp_dir = os.path.join(get_filesystem_root(), "tmp")
+ os.makedirs(temp_dir, exist_ok=True)
+ return temp_dir
+
+
+def hash_string(string: str) -> str:
+ return hashlib.md5(string.encode("utf-8")).hexdigest()
+
+
+def get_current_function_name() -> str:
+ frame = inspect.currentframe()
+ if frame is None:
+ return "no_frame"
+ prev_frame = frame.f_back
+ if prev_frame is None or not isinstance(prev_frame, FrameType):
+ return "no_previous_frame"
+ return prev_frame.f_code.co_name
+
+
+def filter_excluded_files(files: list[Path], directory: Path, exclude_file_name: str = ".gitignore") -> list[Path]:
+ """Remove files from the list that are matched by a .gitignore or similarly-specified exclude file such as
+ .gitignore or ratchet_excluded.txt.
+ """
+
+ # Underneath the root directory, find all the excluders.
+ # They can occur in subfolders and if they do they apply only to that subfolder.
+ excluders = {path for path in directory.rglob(exclude_file_name) if not path.is_symlink()}
+
+ # Per excluder, make a pathspec.
+ for excluder in excluders:
+ with excluder.open("r") as exclude_file:
+ exclude_spec = pathspec.GitIgnoreSpec.from_lines(exclude_file)
+
+ # Now we have two cases - We keep the file if the excluder doesn't apply because it's in a different
+ # folder, or if it applies but doesn't match
+ prefix = os.path.dirname(excluder)
+ files = [
+ file
+ for file in files
+ if not (file.is_relative_to(prefix) and exclude_spec.match_file(file.relative_to(prefix)))
+ ]
+
+ return files
+
+
+def generate_id() -> str:
+ return uuid.uuid4().hex
+
+
+def generate_id_from_existing_id(existing_id: str, seed: int) -> str:
+ return hashlib.md5(f"{existing_id}-{seed}".encode()).hexdigest()
+
+
+def truncate_string(s: str, max_length: int) -> str:
+ if len(s) <= max_length:
+ return s
+ return s[: max_length - 3] + "..."
+
+
+def parse_bool_environment_variable(var_name: str) -> bool:
+ env_var = os.environ.get(var_name, "0").lower()
+
+ assert env_var in (
+ "0",
+ "1",
+ "true",
+ "false",
+ ), f"{var_name} environment variable must be '0', '1', 'true', or 'false'. Current value: '{env_var}'"
+
+ return env_var in ("1", "true")
diff --git a/imbue_core/imbue_core/computing_environment/__init__.py b/imbue_core/imbue_core/computing_environment/__init__.py
diff --git a/imbue_core/imbue_core/computing_environment/computing_environment.py b/imbue_core/imbue_core/computing_environment/computing_environment.py
@@ -0,0 +1,1080 @@
+from __future__ import annotations
+
+import asyncio
+import shlex
+import time
+from datetime import datetime
+from pathlib import Path
+from typing import Protocol
+from typing import Sequence
+from typing import TYPE_CHECKING
+from uuid import uuid4
+
+import anyio
+from loguru import logger
+from tenacity import TryAgain
+from tenacity import retry
+from tenacity import retry_all
+from tenacity import retry_if_exception_type
+from tenacity import stop_after_attempt
+from tenacity import wait_random_exponential
+
+from imbue_core.async_monkey_patches import log_exception
+from imbue_core.computing_environment.data_types import AnyPath
+from imbue_core.computing_environment.data_types import FailedToMakeCommitError
+from imbue_core.computing_environment.data_types import PatchApplicationError
+from imbue_core.computing_environment.data_types import RunCommandError
+from imbue_core.git_data_types import CommitTimestamp
+from imbue_core.retry_utils import log_before_sleep
+from imbue_core.section import Section
+from imbue_core.time_utils import get_current_time
+
+# Import the types needed for file modes
+if TYPE_CHECKING:
+ # for proper file mode typing
+ from _typeshed import OpenBinaryModeReading
+ from _typeshed import OpenBinaryModeWriting
+ from _typeshed import OpenTextModeReading
+ from _typeshed import OpenTextModeWriting
+
+
+class ComputingEnvironment(Protocol):
+ """Protocol defining the interface for a computing environment.
+
+ This protocol specifies the required methods for interacting with a computing
+ environment, including running commands and file operations.
+ """
+
+ async def run_command(
+ self,
+ command: Sequence[str],
+ check: bool = True,
+ secrets: dict[str, str] | None = None,
+ cwd: AnyPath | None = None,
+ is_error_logged: bool = True,
+ ) -> str: ...
+
+ async def run_git(
+ self,
+ command: Sequence[str],
+ check: bool = True,
+ cwd: AnyPath | None = None,
+ is_error_logged: bool = True,
+ is_stripped: bool = True,
+ retry_on_git_lock_error: bool = True,
+ ) -> str: ...
+
+ async def write_file(
+ self,
+ relative_path: AnyPath,
+ content: str | bytes | None,
+ cwd: AnyPath | None = None,
+ mode: OpenTextModeWriting | OpenBinaryModeWriting = "w",
+ mkdir_if_missing: bool = True,
+ ) -> None: ...
+
+ async def read_file(
+ self,
+ relative_path: AnyPath,
+ cwd: AnyPath | None = None,
+ mode: OpenTextModeReading | OpenBinaryModeReading = "r",
+ mkdir_if_missing: bool = True,
+ ) -> str | bytes: ...
+
+ async def delete_file(
+ self,
+ relative_path: AnyPath,
+ cwd: AnyPath | None = None,
+ ) -> None: ...
+
+
+def _get_temp_patch_file() -> anyio.Path:
+ # this is a bad idea because it triggers the file watcher
+ # patch_file = (self.base_path / str(uuid4())).with_suffix(".patch")
+ patch_file = (Path("/tmp") / uuid4().hex).with_suffix(".patch")
+ return anyio.Path(patch_file)
+
+
+async def run_command_with_retry_on_git_lock_error(
+ computing_environment: ComputingEnvironment,
+ command: Sequence[str],
+ check: bool = True,
+ is_error_logged: bool = True,
+ cwd: AnyPath | None = None,
+) -> str:
+ max_retries = 50
+ retry_count = 0
+ retry_delay = 0.1 # seconds
+ while True:
+ try:
+ return await computing_environment.run_command(
+ command,
+ check=check,
+ is_error_logged=is_error_logged and retry_count >= max_retries,
+ cwd=cwd,
+ )
+ except RunCommandError as e:
+ error_message = str(e)
+ if "fatal: Unable to create" in error_message and ".git/index.lock': File exists" in error_message:
+ if retry_count >= max_retries:
+ raise
+ await asyncio.sleep(retry_delay)
+ retry_count += 1
+ else:
+ raise
+
+
+@retry(
+ wait=wait_random_exponential(multiplier=0.1, max=2, exp_base=2),
+ reraise=True,
+ stop=stop_after_attempt(50),
+ before_sleep=log_before_sleep,
+)
+async def wait_for_git_index_lock_to_be_free(local_sync_repo_path: Path) -> None:
+ # Path to the git index lock file using anyio.Path to avoid blocking
+ lock_file_path = anyio.Path(local_sync_repo_path / ".git" / "index.lock")
+ if await lock_file_path.exists():
+ raise TryAgain
+
+
+async def apply_patch_without_git(computing_environment: ComputingEnvironment, diff: str) -> None:
+ if diff.strip() == "":
+ return
+ patch_file = _get_temp_patch_file()
+ try:
+ await computing_environment.write_file(patch_file, diff)
+ await computing_environment.run_command(("bash", "-c", f"patch -p1 < {patch_file}"))
+ except RunCommandError as e:
+ raise PatchApplicationError(f"Failed to apply patch: {e}") from e
+ finally:
+ await computing_environment.delete_file(patch_file)
+
+
+async def is_repo_dirty(computing_environment: ComputingEnvironment, is_untracked_considered: bool = True) -> bool:
+ """Check if the repo has any uncommitted changes."""
+ return bool(
+ await computing_environment.run_git(
+ (
+ "status",
+ "--porcelain",
+ *([] if is_untracked_considered else ["--untracked-files=no"]),
+ )
+ )
+ )
+
+
+async def are_all_commits_pushed(computing_environment: ComputingEnvironment) -> bool:
+ """Check if the repo has any unpushed commits."""
+ output = await computing_environment.run_git(("cherry",))
+ return output.strip() == ""
+
+
+async def are_all_remote_commits_pulled(
+ computing_environment: ComputingEnvironment,
+) -> bool:
+ # note this will fail if the branch hasn't been pushed to the remote
+ output = await computing_environment.run_command(
+ ("bash", "-c", "git fetch && git rev-list HEAD..@{upstream} --count")
+ )
+ return output.strip() == "0"
+
+
+async def assert_repo_is_clean(computing_environment: ComputingEnvironment) -> None:
+ """Assert that the repo has no uncommitted changes."""
+ assert not await is_repo_dirty(
+ computing_environment
+ ), "You have untracked files. Please address them before using this script (this is to prevent accidentally adding large files unintentionally)"
+
+
+async def get_branch_name(computing_environment: ComputingEnvironment, is_error_logged: bool = True) -> str:
+ """Get the name of the current branch."""
+ return await computing_environment.run_git(("symbolic-ref", "--short", "HEAD"), is_error_logged=is_error_logged)
+
+
+async def rename_branch(
+ computing_environment: ComputingEnvironment,
+ old_name: str,
+ new_name: str,
+ force_if_exists: bool = True,
+) -> None:
+ """Rename the given branch."""
+ if force_if_exists:
+ await computing_environment.run_git(("branch", "-M", old_name, new_name))
+ else:
+ await computing_environment.run_git(("branch", "-m", old_name, new_name))
+
+
+async def get_branch_description(computing_environment: ComputingEnvironment, branch_name: str) -> str:
+ """Get the description of the given branch."""
+ try:
+ return await computing_environment.run_git(
+ ("config", f"branch.{branch_name}.description"), is_error_logged=False
+ )
+ except RunCommandError as e:
+ if e.returncode == 1:
+ # no description set
+ return ""
+ raise
+
+
+async def is_branch_exists(computing_environment: ComputingEnvironment, branch_name: str) -> bool:
+ """Check if the given branch exists."""
+ result = await computing_environment.run_git(
+ ("rev-parse", "--verify", "--quiet", branch_name),
+ is_error_logged=False,
+ check=False,
+ )
+ return result.strip() != ""
+
+
+async def is_detached_head(computing_environment: ComputingEnvironment) -> bool:
+ """Check if the current HEAD is detached."""
+ result = await computing_environment.run_git(("rev-parse", "--abbrev-ref", "HEAD"), is_error_logged=False)
+ return result.strip() == "HEAD"
+
+
+async def set_branch_description(
+ computing_environment: ComputingEnvironment, branch_name: str, description: str
+) -> None:
+ """Set the description of the given branch."""
+ await computing_environment.run_git(("config", f"branch.{branch_name}.description", description))
+
+
+async def get_branch_commit(computing_environment: ComputingEnvironment, branch_name: str) -> str:
+ """Get the commit of the given branch."""
+ return await computing_environment.run_git(("rev-parse", branch_name))
+
+
+async def get_all_branch_names_pointing_to_commit(
+ computing_environment: ComputingEnvironment, commit_hash: str
+) -> tuple[str, ...]:
+ """Get all branch names that point to the given commit."""
+ result = await computing_environment.run_git(
+ (
+ "for-each-ref",
+ "refs/heads/",
+ "--format='%(refname:short)'",
+ "--points-at",
+ commit_hash,
+ )
+ )
+ branch_names = tuple(result.splitlines())
+ # strip the quotes
+ return tuple(branch_name.strip("'") for branch_name in branch_names)
+
+
+async def is_branch_child_of_branch(
+ computing_environment: ComputingEnvironment,
+ child_branch_name: str,
+ parent_branch_name: str,
+) -> bool:
+ """Check if the given branch is a child of the parent branch."""
+ try:
+ await computing_environment.run_git(
+ ("merge-base", "--is-ancestor", parent_branch_name, child_branch_name),
+ is_error_logged=False,
+ )
+ return True
+ except RunCommandError as e:
+ if e.stderr.strip() == "" and e.returncode == 1:
+ # we expect this command to give an empty stderr and a return code of 1
+ # if the child branch is not an ancestor of the parent branch
+ return False
+ raise
+
+
+async def is_commit_on_branch(
+ computing_environment: ComputingEnvironment,
+ commit_hash: str,
+ branch_name: str,
+ local_only: bool = True,
+) -> bool:
+ """Check if the given commit is on the given branch."""
+ if local_only:
+ result = await computing_environment.run_git(("branch", "--contains", commit_hash))
+ else:
+ result = await computing_environment.run_git(("branch", "-a", "--contains", commit_hash))
+ return any(branch_name == x.strip() for x in result.splitlines())
+
+
+async def fetch_and_get_first_entry_in_fetch_head(
+ computing_environment: ComputingEnvironment, remote: str, fetch_refs: Sequence[str]
+) -> str:
+ """Fetch the given refs from the remote and return the first entry in FETCH_HEAD."""
+ refs_str = " ".join(fetch_refs)
+ command = [
+ "bash",
+ "-c",
+ (
+ f"git fetch {remote} {refs_str} && "
+ # get first commit from FETCH_HEAD
+ "git rev-parse FETCH_HEAD"
+ ),
+ ]
+ result = await run_command_with_retry_on_git_lock_error(computing_environment, command)
+ return result.strip()
+
+
+async def fetch_branch(computing_environment: ComputingEnvironment, branch_name: str) -> None:
+ """Fetch the given branch from the remote."""
+ await computing_environment.run_git(("fetch", "origin", branch_name))
+
+
+async def is_branch_present(computing_environment: ComputingEnvironment, branch_name: str) -> bool:
+ """Check if branch with given name is present."""
+ result = await computing_environment.run_git(("branch",))
+ return branch_name in result.splitlines()
+
+
+async def create_reset_and_checkout_branch(computing_environment: ComputingEnvironment, branch_name: str) -> str:
+ """Create new branch with given name."""
+ return await computing_environment.run_git(("switch", "-C", branch_name))
+
+
+async def switch_branch(computing_environment: ComputingEnvironment, branch_name: str) -> str:
+ """Switch to branch with given name."""
+ return await computing_environment.run_git(("switch", branch_name))
+
+
+async def delete_branch(computing_environment: ComputingEnvironment, branch_name: str, delete_remote: bool) -> str:
+ """Delete branch with given name."""
+ result = await computing_environment.run_git(("branch", "-D", branch_name))
+ if delete_remote:
+ result = await computing_environment.run_git(("push", "origin", "--delete", branch_name))
+ return result
+
+
+async def update_branch_to_hash(computing_environment: ComputingEnvironment, branch_name: str, git_hash: str) -> None:
+ """Update the given branch to reference the given git hash."""
+ # here we do it without checking out the branch
+ await computing_environment.run_git(("branch", "-f", branch_name, git_hash))
+
+
+async def switch_and_create_branch_if_needed(computing_environment: ComputingEnvironment, branch_name: str) -> str:
+ """Switch to new branch, creating it if it doesn't already exist."""
+ if await is_branch_present(computing_environment, branch_name):
+ await switch_branch(computing_environment, branch_name)
+ else:
+ await create_reset_and_checkout_branch(computing_environment, branch_name)
+ return await get_branch_name(computing_environment)
+
+
+async def merge_branches(
+ computing_environment: ComputingEnvironment,
+ base_branch_name: str,
+ merge_branch_name: str,
+ is_moving_to_base_branch: bool = True,
+) -> str:
+ """Merge `merge_branch_name` into `base_branch_name`."""
+ await switch_branch(computing_environment, base_branch_name)
+ await computing_environment.run_git(("merge", merge_branch_name))
+ if not is_moving_to_base_branch:
+ await switch_branch(computing_environment, "-")
+ return await get_branch_name(computing_environment)
+
+
+async def get_merge_base(computing_environment: ComputingEnvironment, branch_name: str, target_branch: str) -> str:
+ """Get the merge base of the given branch and target branch.
+
+ The merge base is the most recent commit that is on both branches.
+ """
+ return await computing_environment.run_git(["merge-base", branch_name, target_branch], is_error_logged=False)
+
+
+async def checkout_hash(computing_environment: ComputingEnvironment, git_hash: str) -> str:
+ """Checkout given git hash."""
+ return await computing_environment.run_git(("checkout", git_hash))
+
+
+async def force_add(computing_environment: ComputingEnvironment, *paths: str) -> None:
+ """Force-add the specified paths to the git index."""
+ await computing_environment.run_git(("add", "-f", *paths))
+
+
+async def git_add(computing_environment: ComputingEnvironment, *paths: str) -> None:
+ """Add the specified paths to the git index."""
+ await computing_environment.run_git(("add", *paths))
+
+
+def convert_datetime_to_git_timestamp(dt: datetime) -> str:
+ return datetime.isoformat(dt)
+
+
+def convert_git_timestamp_to_datetime(timestamp: str) -> datetime:
+ return datetime.fromisoformat(timestamp)
+
+
+def get_commit_ts_for_current_time() -> CommitTimestamp:
+ """Get the commit timestamp for the current time."""
+ current_time = get_current_time()
+ return CommitTimestamp(
+ author_ts=convert_datetime_to_git_timestamp(current_time),
+ committer_ts=convert_datetime_to_git_timestamp(current_time),
+ )
+
+
+def _convert_time_to_commit_ts(
+ time: str | datetime | CommitTimestamp | None,
+) -> CommitTimestamp:
+ if time is None:
+ return get_commit_ts_for_current_time()
+ elif isinstance(time, datetime):
+ return CommitTimestamp(
+ author_ts=convert_datetime_to_git_timestamp(time),
+ committer_ts=convert_datetime_to_git_timestamp(time),
+ )
+ elif isinstance(time, CommitTimestamp):
+ return time
+ else:
+ # assume it's a git timestamp
+ return CommitTimestamp(author_ts=time, committer_ts=time)
+
+
+async def make_commit(
+ computing_environment: ComputingEnvironment,
+ commit_message: str,
+ allow_empty: bool = False,
+ amend: bool = False,
+ commit_time: str | datetime | CommitTimestamp | None = None,
+) -> str:
+ if commit_message.strip() == "":
+ commit_message = "No commit message provided"
+
+ commit_ts = _convert_time_to_commit_ts(commit_time)
+ time_args = f'GIT_AUTHOR_DATE="{commit_ts.author_ts}" GIT_COMMITTER_DATE="{commit_ts.committer_ts}" '
+
+ commit_message = shlex.quote(commit_message)
+ no_changes_message = "No changes to commit"
+ amend_args = "--amend " if amend else ""
+ if allow_empty or amend:
+ bash_command = f"""git add . && {time_args}git commit {amend_args}--allow-empty -m {commit_message} > /dev/null && git rev-parse HEAD"""
+ else:
+ bash_command = f"""git add . && ( git status | grep -q "nothing to commit" && echo "{no_changes_message}" ) || ( {time_args}git commit {amend_args}-m {commit_message} > /dev/null && git rev-parse HEAD )"""
+
+ with Section(f"committing changes with message: '{commit_message}'", log_level="DEBUG"):
+ stdout = await run_command_with_retry_on_git_lock_error(
+ computing_environment,
+ ["bash", "-c", bash_command],
+ )
+ stdout = stdout.strip()
+ if stdout == no_changes_message:
+ raise FailedToMakeCommitError(f"Failed to make commit with message: {commit_message}. {bash_command=}")
+ new_git_hash = stdout
+ return new_git_hash
+
+
+async def get_tree_hash_for_commit(computing_environment: ComputingEnvironment, commit: str) -> str:
+ """Get the tree hash for the given commit."""
+ return await computing_environment.run_git(["rev-parse", commit + "^{tree}"])
+
+
+async def get_commit_timestamp(computing_environment: ComputingEnvironment, commit: str) -> CommitTimestamp:
+ """Get the commit timestamp for the given commit."""
+ split_token = "<|>"
+ result = await computing_environment.run_git(["show", "-s", "--format=%aI<|>%cI", commit])
+ author_ts, committer_ts = result.split(split_token)
+ return CommitTimestamp(author_ts=author_ts.strip(), committer_ts=committer_ts.strip())
+
+
+async def tag_commit(computing_environment: ComputingEnvironment, tag: str, commit_hash: str) -> None:
+ """Tag the given commit with the given tag."""
+ # We use -f to force the tag to be created even if it already exists.
+ await computing_environment.run_git(("tag", "-f", tag, commit_hash))
+
+
+async def git_push(computing_environment: ComputingEnvironment, branch_name: str) -> str:
+ """Push changes to remote branch with given name."""
+ return await computing_environment.run_git(("push", "origin", branch_name))
+
+
+async def force_push(computing_environment: ComputingEnvironment, branch_name: str) -> str:
+ """Push changes to remote branch with given name."""
+ return await computing_environment.run_git(("push", "--force", "origin", branch_name))
+
+
+async def force_push_commit_with_retry(
+ computing_environment: ComputingEnvironment,
+ commit: str,
+ branch_name: str,
+ timeout: float = 30.0,
+) -> None:
+ start_time = time.monotonic()
+ sleep_time = 0.5
+ while True:
+ try:
+ await force_push_commit(computing_environment, commit, branch_name)
+ break
+ except Exception as exc:
+ if time.monotonic() - start_time > timeout:
+ raise TimeoutError(
+ f"Timeout reached: Could not force push {commit} to {branch_name} in {timeout} seconds."
+ ) from exc
+ logger.info("Force push of {} to {} failed; trying again...", commit, branch_name)
+ await asyncio.sleep(sleep_time)
+ sleep_time *= 2
+
+
+async def force_push_commit(computing_environment: ComputingEnvironment, commit: str, branch_name: str) -> None:
+ try:
+ await computing_environment.run_git(["push", "-f", "origin", f"{commit}:{branch_name}"], is_error_logged=False)
+ except RunCommandError as e:
+ if "fatal: bad object" in e.stderr:
+ # TODO (danielmewes): We're retrying failed fetches here. However, there is also a separate
+ # force_push_commit_with_retry method that retries the entire force_push_commit.
+ # We should probably try the fetch only once, and then rely on the outer
+ # force_push_commit_with_retry to retry the entire force_push_commit call when retrying is =
+ # desired?
+ NUM_TRIES = 3
+ for _ in range(NUM_TRIES):
+ try:
+ await computing_environment.run_git(["fetch", "origin", commit], is_error_logged=False)
+ except RunCommandError as fetch_e:
+ if "not our ref" in fetch_e.stderr:
+ # FIXME: actually, this has been getting worse... I suspect perhaps rate limiting or something? We are checking thing out much more than usual...
+ await asyncio.sleep(2)
+ else:
+ raise fetch_e
+ else:
+ start_time = time.monotonic()
+ while time.monotonic() - start_time < 10:
+ try:
+ await computing_environment.run_git(["push", "-f", "origin", f"{commit}:{branch_name}"])
+ except RunCommandError as repush_e:
+ if "not our ref" in repush_e.stderr:
+ # FIXME: actually, this has been getting worse... I suspect perhaps rate limiting or something? We are checking thing out much more than usual...
+ await asyncio.sleep(2)
+ else:
+ raise repush_e
+ else:
+ return
+ raise Exception(f"Could not force push commit {commit}")
+ raise Exception(f"Could not fetch commit {commit} to force push it")
+ else:
+ raise
+
+
+async def get_staged_files(
+ computing_environment: ComputingEnvironment,
+) -> tuple[str, ...]:
+ """Get list of all files in repo that are currently staged."""
+ result = await computing_environment.run_git(("diff", "--full-index", "--binary", "--name-only", "--cached"))
+ return tuple(result.splitlines())
+
+
+async def get_unstaged_files(
+ computing_environment: ComputingEnvironment,
+) -> tuple[str, ...]:
+ """Get list of all files in repo that are currently unstaged."""
+ result = await computing_environment.run_git(("diff", "--full-index", "--binary", "--name-only"))
+ return tuple(result.splitlines())
+
+
+async def restore_all_staged_files(computing_environment: ComputingEnvironment) -> None:
+ """Restore all staged files."""
+ await computing_environment.run_git(("restore", "--staged", "."))
+
+
+async def restore_all_unstaged_changes(
+ computing_environment: ComputingEnvironment,
+) -> None:
+ """Restore all unstaged changes."""
+ await computing_environment.run_git(("restore", "."))
+
+
+async def apply_patch_via_git_with_conflict_markers(
+ computing_environment: ComputingEnvironment,
+ git_diff: str,
+ is_error_logged: bool = True,
+) -> None:
+ """Apply a diff to repo with conflict markers."""
+ if git_diff.strip() == "":
+ return
+ if not git_diff.endswith("\n"):
+ # git requires a newline at the end of the patch
+ git_diff += "\n"
+ patch_file = _get_temp_patch_file()
+ try:
+ await computing_environment.write_file(patch_file, git_diff)
+ await computing_environment.run_command(
+ [
+ "bash",
+ "-c",
+ f"git add . && git apply --verbose {patch_file} || git apply -3 --verbose {patch_file}",
+ ],
+ is_error_logged=is_error_logged,
+ )
+ except RunCommandError as e:
+ raise PatchApplicationError(f"Failed to apply patch: {e}") from e
+ finally:
+ await computing_environment.delete_file(patch_file)
+
+
+async def is_repo_conflicted(computing_environment: ComputingEnvironment) -> bool:
+ output = await computing_environment.run_git(["status"], is_error_logged=False, check=False)
+ if "Unmerged paths:" in output:
+ return True
+ return False
+
+
+async def get_head_hash(computing_environment: ComputingEnvironment) -> str:
+ """Get the hash of the current HEAD commit."""
+ git_hash = await computing_environment.run_git(["rev-parse", "HEAD"])
+ assert len(git_hash) == 40, f"Expected 40-character git hash, got {git_hash}"
+ return git_hash
+
+
+async def get_parent_commit_hash(computing_environment: ComputingEnvironment, commit_hash: str) -> str:
+ """Get the parent commit hash of the given commit hash."""
+ git_hash = await computing_environment.run_git(["rev-parse", f"{commit_hash}^"])
+ assert len(git_hash) == 40, f"Expected 40-character git hash, got {git_hash}"
+ return git_hash
+
+
+async def get_most_recent_sibling_branch_of_branch(
+ computing_environment: ComputingEnvironment, target_branch_name: str
+) -> tuple[str, ...] | None:
+ """Get the most recent sibling branch of the given branch name.
+
+ This is the first branch that shares a common ancestor commit with the given branch name.
+ Note, that it is possible that there are multiple branches that share the most recent common ancestor.
+ In this case, we return all of them.
+
+ Also if there are no sibling branches (either at all or within the max lookback), we return None.
+ """
+ # FIXME: this is imprecise -- it really just needs to be head 2, but I didn't want to figure out the regex...
+ output = await computing_environment.run_command(
+ [
+ "bash",
+ "-c",
+ f'git log --decorate=full --oneline {target_branch_name} | grep "refs/heads/" | head -n 10',
+ ],
+ check=True,
+ )
+ for line in output.splitlines():
+ branch_list_string = line.split(" (", maxsplit=1)[-1].split(")", maxsplit=1)[0]
+ parsed_branch_names = []
+ for branch_name_string in branch_list_string.split(", "):
+ parsed_branch_name = branch_name_string.rsplit(" ")[-1]
+ if parsed_branch_name.startswith("refs/heads") and parsed_branch_name != f"refs/heads/{target_branch_name}":
+ parsed_branch_names.append(parsed_branch_name.replace("refs/heads/", "", 1))
+ # otherwise, just return the first one
+ if len(parsed_branch_names) > 0:
+ return tuple(parsed_branch_names)
+ return None
+
+
+async def get_nth_commit_ago(
+ computing_environment: ComputingEnvironment,
+ commit_hash: str,
+ n: int,
+ is_error_logged: bool = True,
+) -> str:
+ """Get the nth commit ago of the given commit hash."""
+ git_hash = await computing_environment.run_git(["rev-parse", f"{commit_hash}~{n}"], is_error_logged=is_error_logged)
+ assert len(git_hash) == 40, f"Expected 40-character git hash, got {git_hash}"
+ return git_hash
+
+
+async def get_initial_repo_commit_hash(computing_environment: ComputingEnvironment, commit_hash: str = "HEAD") -> str:
+ """Get the initial commit hash of the repo.
+
+ As written, if invoked on an empty repo with no commits (immediately after `git init`), this fails with:
+ `fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree.`
+ """
+ # --max-parents=0: only consider commits with no parents
+ # --date-order: sort by date (newest first)
+ output = await computing_environment.run_git(["rev-list", "--max-parents=0", commit_hash, "--date-order"])
+ # assume the oldest commit with no parents is the initial repo commit
+ all_root_commits = output.splitlines()
+ root_commit = all_root_commits[-1]
+ assert len(root_commit) == 40, f"Expected 40-character git hash, got {root_commit}"
+ return root_commit
+
+
+async def get_upto_nth_commit_ago(computing_environment: ComputingEnvironment, commit_hash: str, n: int) -> str:
+ """Get the commit hash of the upto nth commit ago of the given commit hash.
+
+ If the commit history is shorter than n, it will return the first commit.
+ """
+ try:
+ return await get_nth_commit_ago(computing_environment, commit_hash, n, is_error_logged=False)
+ except RunCommandError as e:
+ if "unknown revision or path not in the working tree" in e.stderr:
+ return await get_initial_repo_commit_hash(computing_environment, commit_hash)
+ raise
+
+
+async def get_commit_message(computing_environment: ComputingEnvironment, commit_hash: str) -> str:
+ """Get the commit message of the given commit hash."""
+ return await computing_environment.run_git(["log", "-1", "--pretty=%B", commit_hash])
+
+
+async def get_commit_count_between_hashes(
+ computing_environment: ComputingEnvironment, old_hash: str, new_hash: str
+) -> int:
+ """Get the number of commits between two hashes."""
+ output = await computing_environment.run_git(["rev-list", "--count", f"{old_hash}..{new_hash}"])
+ return int(output.strip())
+
+
+async def fetch_and_checkout_hash(
+ computing_environment: ComputingEnvironment,
+ git_hash: str,
+ is_error_logged: bool = True,
+) -> None:
+ await computing_environment.run_command(
+ ["bash", "-c", f"git fetch origin {git_hash} && git checkout {git_hash}"],
+ is_error_logged=is_error_logged,
+ )
+
+
+async def fetch_ref_and_checkout_hash(
+ computing_environment: ComputingEnvironment,
+ ref: str,
+ git_hash: str,
+ is_error_logged: bool = True,
+) -> None:
+ await computing_environment.run_command(
+ ["bash", "-c", f"git fetch origin {ref} && git checkout {git_hash}"],
+ is_error_logged=is_error_logged,
+ )
+
+
+@retry(
+ stop=stop_after_attempt(5),
+ wait=wait_random_exponential(min=0.5, max=30, exp_base=2),
+ reraise=True,
+ retry=retry_all(retry_if_exception_type(RunCommandError)),
+ before_sleep=log_before_sleep,
+)
+async def wait_for_git_hash_to_checkout(computing_environment: ComputingEnvironment, git_hash: str) -> None:
+ await fetch_and_checkout_hash(computing_environment, git_hash, is_error_logged=False)
+
+
+async def wait_for_git_hash_with_ref_to_checkout(
+ computing_environment: ComputingEnvironment,
+ git_hash: str,
+ ref: str,
+ timeout: float = 20.0,
+) -> None:
+ with Section(f"Checking out git hash {git_hash} with ref {ref}", log_level="DEBUG"):
+ start_time = time.monotonic()
+ while True:
+ try:
+ await fetch_ref_and_checkout_hash(computing_environment, ref, git_hash, is_error_logged=False)
+ break
+ except RunCommandError as exc:
+ if time.monotonic() - start_time > timeout:
+ log_exception(
+ exc,
+ "Timeout reached: Git hash {git_hash} is not available after {timeout} seconds.",
+ git_hash=git_hash,
+ timeout=timeout,
+ )
+ raise TimeoutError(f"Timeout reached: Git hash {git_hash} is not available.") from exc
+ logger.info("Waiting for git hash {} to be available...", git_hash)
+ await asyncio.sleep(0.5)
+
+
+async def force_checkout_git_hash_immediate_on_branch(
+ computing_environment: ComputingEnvironment, git_hash: str, branch_name: str
+) -> None:
+ # Here we clear any uncommited changes, then change branch, before checking out the new hash
+ # We need to do these three steps so we don't affect the currently checked out branch
+ command = f"git reset --hard && git checkout -B {branch_name} && git reset --hard {git_hash}"
+ logger.debug("Running command: {}", command)
+ await run_command_with_retry_on_git_lock_error(
+ computing_environment,
+ [
+ "bash",
+ "-c",
+ command,
+ ],
+ )
+
+
+async def get_git_folder_paths(
+ computing_environment: ComputingEnvironment,
+) -> tuple[str, ...]:
+ """Get the paths of all the git folders in the repo."""
+ result = await computing_environment.run_command(["ls", ".git"])
+ return tuple(result.splitlines())
+
+
+async def apply_patch_via_git(
+ computing_environment: ComputingEnvironment, git_diff: str, is_error_logged: bool
+) -> None:
+ """Apply a diff to repo."""
+ if git_diff.strip() == "":
+ return
+ patch_file = _get_temp_patch_file()
+ await computing_environment.write_file(patch_file, git_diff)
+ # NOTE: --allow-empty is necessary because the patch may be empty, or result in no changes
+ # update (2024-11-22) --allow-empty is not available in the git version in our devcontainers
+ # so we have to do a janky error check below
+ try:
+ await computing_environment.run_git(("apply", "--verbose", str(patch_file)), is_error_logged=is_error_logged)
+ except RunCommandError as e:
+ raise PatchApplicationError(f"Failed to apply patch: {e}") from e
+ finally:
+ await computing_environment.delete_file(patch_file)
+
+
+async def get_git_diff(
+ computing_environment: ComputingEnvironment,
+ commit_hash: str | None = None,
+ staged: bool = False,
+ is_error_logged: bool = True,
+ include_binary: bool = True,
+) -> str:
+ """Get the diff for the current repo state."""
+ # make sure `is_stripped=False` otherwise patch can be invalid
+ command = ["diff", "--full-index"]
+ if include_binary:
+ # Without --binary, diffs of binary files will just contain a summary statement such as "Binary files a/file.bin and b/file.bin differ".
+ # Such diffs cannot be applied, but are useful for inclusion in LLM prompts.
+ command.append("--binary")
+ if staged:
+ command.append("--staged")
+ if commit_hash:
+ command.append(commit_hash)
+ return await computing_environment.run_git(command, is_stripped=False, is_error_logged=is_error_logged)
+
+
+async def get_diff_between_hashes(computing_environment: ComputingEnvironment, old_hash: str, new_hash: str) -> str:
+ """Get the diff between two git hashes."""
+ # make sure `is_stripped=False` otherwise patch can be invalid
+ return await computing_environment.run_git(
+ ["diff", "--full-index", "--binary", old_hash, new_hash], is_stripped=False
+ )
+
+
+async def get_patch_for_commit(computing_environment: ComputingEnvironment, commit_hash: str) -> str:
+ """Get the patch for a given commit hash."""
+ return await computing_environment.run_git(["show", "--pretty=format:", "--patch", commit_hash], is_stripped=False)
+
+
+async def get_untracked_files(
+ computing_environment: ComputingEnvironment,
+) -> tuple[str, ...]:
+ """Get the untracked files in the repo."""
+ result = await computing_environment.run_git(["ls-files", "--others", "--exclude-standard"], is_error_logged=False)
+ return tuple([line.strip() for line in result.splitlines() if line.strip()])
+
+
+async def get_untracked_file_diff(
+ computing_environment: ComputingEnvironment,
+ file_path: str,
+ include_binary: bool = True,
+) -> str:
+ """Get the diff for a untracked file.
+
+ Note this function will raise a RunCommandError if the there is no diff for the untracked file or if there
+ is another error running the command. So it is best to use this function after checking that the file is untracked
+ using get_untracked_files function.
+ """
+ command = ["diff", "--no-index"]
+ if include_binary:
+ command.append("--binary")
+ untracked_diff = await computing_environment.run_git(
+ command + ["/dev/null", str(file_path)],
+ # Unfortunately, `--no-index` implies `--exit-code`, which will cause git diff to return an exit code of 1
+ # if the diff is not empty. So we can't use check=True here. We'll check for an empty output to detect failures.
+ check=False,
+ is_error_logged=True,
+ is_stripped=False,
+ )
+ if not untracked_diff:
+ raise RunCommandError(f"Unable to diff untracked file {file_path}")
+ return untracked_diff
+
+
+async def get_staged_unstaged_and_combined_diffs(
+ computing_environment: ComputingEnvironment, base_commit: str = "HEAD"
+) -> tuple[str, str, str]:
+ """Get the staged diff, the unstaged diff, and the combined diff"""
+ staged_diff = await get_git_diff(computing_environment, staged=True)
+ unstaged_diff = await get_git_diff(computing_environment, staged=False)
+ combined_diff = await get_git_diff(computing_environment, commit_hash=base_commit)
+ return staged_diff, unstaged_diff, combined_diff
+
+
+async def get_unmerged_blob_hashes(
+ computing_environment: ComputingEnvironment,
+) -> tuple[str, ...]:
+ """Get the blob hashes of all the unmerged files in the repo."""
+ result = await computing_environment.run_command(
+ ["bash", "-c", "git ls-files --unmerged | awk '{print $2}' | sort -u"],
+ check=False,
+ )
+ return tuple(line.strip() for line in result.splitlines() if line.strip() != "")
+
+
+async def get_staged_blob_hashes(
+ computing_environment: ComputingEnvironment,
+) -> tuple[str, ...]:
+ """Get the blob hashes of all the staged files in the repo."""
+ staged_blob_hashes = await computing_environment.run_command(
+ [
+ "bash",
+ "-c",
+ 'staged_blobs=$(git diff --full-index --binary --cached --name-only --diff-filter=ACMRT | while read file; do git ls-files --stage "$file" | awk \'{print $2}\'; done); echo "$staged_blobs"',
+ ]
+ )
+ return tuple(line.strip() for line in staged_blob_hashes.splitlines() if line.strip() != "")
+
+
+async def get_blob_content_by_hash(computing_environment: ComputingEnvironment, blob_hash: str) -> bytes:
+ """Get the content of a blob by its hash."""
+ result = await computing_environment.run_git(["cat-file", "-p", blob_hash], is_stripped=False)
+ return result.encode("utf-8")
+
+
+async def get_unmerged_and_staged_blob_contents_by_hash(
+ computing_environment: ComputingEnvironment,
+) -> dict[str, bytes]:
+ """Get the contents of all the unmerged and staged blobs in the repo."""
+ unmerged_blob_hashes = await get_unmerged_blob_hashes(computing_environment)
+ staged_blob_hashes = await get_staged_blob_hashes(computing_environment)
+ all_relevant_blob_hashes = unmerged_blob_hashes + staged_blob_hashes
+ blob_content_tasks_by_hash = {
+ blob_hash: get_blob_content_by_hash(computing_environment, blob_hash) for blob_hash in all_relevant_blob_hashes
+ }
+ blob_contents = await asyncio.gather(*blob_content_tasks_by_hash.values())
+ return {
+ blob_hash: blob_content for blob_hash, blob_content in zip(blob_content_tasks_by_hash.keys(), blob_contents)
+ }
+
+
+async def write_blob_content(computing_environment: ComputingEnvironment, blob_hash: str, blob_content: bytes) -> None:
+ """Write the content of a blob to the repo."""
+ # write the blob content to a temp file
+ temp_file = anyio.Path(f"/tmp/{blob_hash}")
+ try:
+ await computing_environment.write_file(temp_file, blob_content, mode="wb")
+ # write the blob to the repo
+ result = await computing_environment.run_git(["hash-object", "-w", str(temp_file)])
+ assert result.strip() == blob_hash, f"Expected blob hash {blob_hash}, got {result.strip()}"
+ finally:
+ await computing_environment.delete_file(temp_file)
+
+
+async def write_blob_content_by_hash(
+ computing_environment: ComputingEnvironment, blob_content_by_hash: dict[str, bytes]
+) -> None:
+ """Write the content of all the blobs to the repo."""
+ tasks = []
+ for blob_hash, blob_content in blob_content_by_hash.items():
+ tasks.append(write_blob_content(computing_environment, blob_hash, blob_content))
+ await asyncio.gather(*tasks)
+
+
+async def get_modified_files_with_conflicts(
+ computing_environment: ComputingEnvironment,
+) -> tuple[str, ...]:
+ """Get the modified files with conflicts."""
+ commands = [
+ "diff --check --staged --full-index --binary",
+ "diff --check --full-index --binary",
+ ]
+ conflicted_files = set()
+ for command in commands:
+ result = await computing_environment.run_git(command.split(), check=False, is_error_logged=False)
+ # output is of the form:
+ # test.txt:2: leftover conflict marker
+
+ for line in result.splitlines():
+ parts = line.split(":", maxsplit=1)
+ if len(parts) == 2:
+ file_path, message = parts
+ if "leftover conflict marker" in message.strip():
+ conflicted_files.add(file_path)
+ return tuple(conflicted_files)
+
+
+async def get_conflicted_pathnames(
+ computing_environment: ComputingEnvironment,
+) -> tuple[str, ...]:
+ """Get the pathnames of all the conflicted files in the repo."""
+ result = await computing_environment.run_git(["diff", "--full-index", "--binary", "--name-only", "--diff-filter=U"])
+ return tuple(result.splitlines())
+
+
+async def get_conflicted_contents_by_path(
+ computing_environment: ComputingEnvironment,
+) -> dict[str, bytes]:
+ """Get the contents of all the conflicted files in the repo."""
+ conflicted_files = await get_conflicted_pathnames(computing_environment)
+ conflicted_contents_by_path: dict[str, bytes] = {}
+ for file_path in conflicted_files:
+ content = await computing_environment.read_file(file_path, mode="rb")
+ assert isinstance(content, bytes), f"Expected bytes, got {type(content)}"
+ conflicted_contents_by_path[file_path] = content
+ return conflicted_contents_by_path
+
+
+async def get_modified_pathnames(
+ computing_environment: ComputingEnvironment,
+) -> tuple[str, ...]:
+ """Get the pathnames of all the modified files in the repo."""
+ result = await computing_environment.run_command(["bash", "-c", "git status --porcelain | awk '{print $2}'"])
+ return tuple(result.splitlines())
+
+
+async def get_modified_file_contents_by_path(
+ computing_environment: ComputingEnvironment,
+) -> dict[str, bytes]:
+ """Get the contents of all the modified files in the repo."""
+ modified_pathnames = await get_modified_pathnames(computing_environment)
+ modified_file_contents_by_path: dict[str, bytes] = {}
+ for pathname in modified_pathnames:
+ content = await computing_environment.read_file(pathname, mode="rb")
+ assert isinstance(content, bytes), f"Expected bytes, got {type(content)}"
+ modified_file_contents_by_path[pathname] = content
+ return modified_file_contents_by_path
+
+
+async def get_repo_url(computing_environment: ComputingEnvironment) -> str:
+ repo_url = await computing_environment.run_git(["remote", "get-url", "origin"])
+ if repo_url.startswith("git@"):
+ # convert ssh url to https
+ repo_url = repo_url.replace(":", "/")
+ repo_url = f"https://{repo_url[4:]}"
+ if "https://oauth2:" in repo_url:
+ # remove the oauth2 prefix
+ # repo_url is something like https://oauth2:{token}@gitlab.com/.../.git
+ # change it to https://gitlab.com/.../.git
+ suffix = repo_url.split("@")[-1]
+ repo_url = "https://" + suffix
+ return repo_url
+
+
+async def get_main_branch_name_for_repo(
+ computing_environment: ComputingEnvironment, default_branch: str | None = None
+) -> str:
+ """Get the name of the main branch for the repo.
+
+ Attempts to detect whether the repository uses 'main', 'master', or another name
+ as its primary branch by checking for common branch names in order of preference.
+ """
+ possible_main_branches = ["main", "master", "trunk", "development"]
+
+ if default_branch is not None and default_branch not in possible_main_branches:
+ possible_main_branches.insert(0, default_branch)
+
+ # First check if any of the common main branch names exist
+ # and return the first one that does
+ for branch in possible_main_branches:
+ if await is_branch_exists(computing_environment, branch):
+ return branch
+
+ # If we couldn't find a common main branch, try to determine the default branch
+ # This gets the branch that HEAD points to in a newly cloned repo
+ default_remote_branch = await computing_environment.run_git(
+ ["symbolic-ref", "refs/remotes/origin/HEAD"], is_error_logged=False
+ )
+ if default_remote_branch:
+ # Format is typically refs/remotes/origin/main, so extract the last part
+ default_remote_branch = default_remote_branch.strip().split("/")[-1]
+ return default_remote_branch
+ raise ValueError("Could not detect main branch for repo.")
diff --git a/imbue_core/imbue_core/computing_environment/data_types.py b/imbue_core/imbue_core/computing_environment/data_types.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+import subprocess
+from pathlib import Path
+from typing import Any
+
+import anyio
+
+# Use AnyPath type to match Sanctum
+AnyPath = Path | str | anyio.Path
+
+
+class RunCommandError(subprocess.CalledProcessError):
+ """Custom exception for errors encountered during Git commands."""
+
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ self.cwd = kwargs.get("cwd", None)
+ if "cwd" in kwargs:
+ del kwargs["cwd"]
+ super().__init__(*args, **kwargs)
+
+ def __str__(self) -> str:
+ return f"Command `{self.cmd}` returned non-zero exit status {self.returncode}.\nOutput: {self.stdout}\nError: {self.stderr}\nCWD: {self.cwd}"
+
+
+class PatchApplicationError(Exception):
+ """Custom exception for errors encountered during patch application."""
+
+
+class FailedToMakeCommitError(Exception):
+ """Custom exception for errors encountered during commit creation."""
diff --git a/imbue_core/imbue_core/conftest.py b/imbue_core/imbue_core/conftest.py
@@ -0,0 +1,41 @@
+import contextlib
+from pathlib import Path
+from tempfile import NamedTemporaryFile
+from typing import Generator
+from typing import Iterator
+
+import pytest
+
+from imbue_core.log_utils import ensure_core_log_levels_configured
+from imbue_core.test_repo_utils import make_test_data_mock_repo
+
+
+@pytest.fixture(scope="session", autouse=True)
+def setup_logging_and_secrets() -> None:
+ ensure_core_log_levels_configured()
+
+
+@contextlib.contextmanager
+def create_temp_file(contents: str, suffix: str, root_dir: Path) -> Generator[Path, None, None]:
+ with NamedTemporaryFile(mode="w", suffix=suffix, dir=root_dir, delete=False) as temp_file:
+ temp_file.write(contents)
+ temp_file.flush()
+ yield Path(temp_file.name)
+ temp_file.close()
+ Path(temp_file.name).unlink()
+
+
+mock_repo = pytest.fixture(make_test_data_mock_repo)
+
+
+@contextlib.contextmanager
+def dummy_exception_manager() -> Iterator[None]:
+ """
+ Use with patch to disable exception managing for LLM calls.
+ Useful when you want a test to fail fast.
+
+ Example:
+ with patch("imbue_core.agents.llm_apis.anthropic_api._anthropic_exception_manager", dummy_exception_manager):
+ # your test code here
+ """
+ yield
diff --git a/imbue_core/imbue_core/constants.py b/imbue_core/imbue_core/constants.py
@@ -0,0 +1,21 @@
+from enum import StrEnum
+from pathlib import Path
+
+IMBUE_TOML_PATH = Path("~/.imbue/config.toml").expanduser()
+
+DEFAULT_PROJECT_SECRET_PATH = Path(".env")
+
+LOW_PRIORITY_LEVEL = 37
+MEDIUM_PRIORITY_LEVEL = 38
+HIGH_PRIORITY_LEVEL = 39
+
+DISCORD_URL = "https://discord.gg/sBAVvHPUTE"
+
+
+class ExceptionPriority(StrEnum):
+ # for issues that will result in the app crashing
+ HIGH_PRIORITY = "HIGH_PRIORITY"
+ # for issues that will cause major functionality to stop working
+ MEDIUM_PRIORITY = "MEDIUM_PRIORITY"
+ # everything else -- e.g. exception sites that are typically retriable, but may catch something unrecoverable
+ LOW_PRIORITY = "LOW_PRIORITY"
diff --git a/imbue_core/imbue_core/data_types.py b/imbue_core/imbue_core/data_types.py
@@ -0,0 +1,317 @@
+"""
+Interfaces and data types for the issue identification system.
+
+We foresee two basic kinds of issue identifiers:
+
+ 1. Heavily specialized ones.
+ - Those would typically only check for a single well-defined issue.
+ - The user only wants to know if a specific thing is wrong.
+ 2. General ones.
+ - They would still have a certain focus but they would typically check for a broader range of issues.
+ - We wouldn't know the whole range of possible issues in advance.
+ - Here, the user asks for up to N most problematic issues of the given kind in a given scope.
+
+Issue identifiers can either be created:
+ - by implementing the IssueIdentifier protocol in an arbitrary way
+ - or by leaning on a common LLM-based zero-shot classifier
+ - This has the advantage of being more efficient.
+ - We can ask for a (set of) score(s) on various metrics / error types for a list of scopes.
+ - These computations can basically all be batched together into a single call to the LLM.
+ - NOTE: as of writing this, this hasn't been implemented yet.
+
+What follows is a list of possible issues we may eventually want to identify:
+
+
+- docstrings / documentation / tests / constraints / validation
+ - missing
+ - outdated
+ - ambiguous
+ - poorly written / duplicated
+ - conflicting
+ - insufficient
+- assumptions
+ - unstated
+ - violated
+ - conflicting
+- possible race condition
+- overly complex code
+- code in need of refactoring
+- project layout in need of refactoring
+- duplicated code
+- brittle logic
+- use of state (at all, where unnecessary, where needless)
+- gross inefficiency
+- caching (the presence of, at all)
+- bad/confusing/unclear naming
+- forbidden stylistic patterns (that cannot be caught by ratchets)
+- poorly handled edge cases
+- just plain ol bugs, overall correctness, etc
+- missing test coverage (not line based, but more meaning based, esp around integration tests)
+- disagreeing ensembles
+- missing features / implementations / etc
+- refactoring elements that were missed
+- invocation outputs that seem suspect
+- overly broad types
+- misunderstanding the users mental model
+- architectural flaws
+- general critiques
+- better alternatives
+- reinvented wheels / places where some library or external service should have been used
+- mutated globals / global state / imported globals
+- any unnecessary complexity
+
+There are also things we explicitly don't want to catch with this system:
+
+- runtime errors (when running a main script or tests)
+- most ratchet errors
+- anything caught by an existing tool (typing, pylint, tests, etc)
+- errors during the deployment process itself
+- errors when building the image
+- errors about packaging, installation, dependencies, etc
+- errors collected from production
+
+"""
+
+from enum import StrEnum
+from typing import Literal
+
+from pydantic import Field
+
+from imbue_core.common import generate_id
+from imbue_core.pydantic_serialization import SerializableModel
+
+# Define semantics for the normalized confidence and severity scores.
+CONFIDENCE_CERTAINLY_FINE = (0.0, 0.2)
+CONFIDENCE_RATHER_FINE = (0.2, 0.4)
+CONFIDENCE_NOT_SURE = (0.4, 0.6)
+CONFIDENCE_RATHER_PROBLEMATIC = (0.6, 0.8)
+CONFIDENCE_CERTAINLY_PROBLEMATIC = (0.8, 1.0)
+
+
+class ConfidenceScore(SerializableModel):
+ """
+ A score for the confidence in the issue / error detection.
+
+ - The raw score is the score as output by the underlying model.
+ - The normalized score is rescaled in such a way that the interval between 0 and 1 maps to the defined confidence levels.
+
+ """
+
+ raw: float
+ normalized: float
+
+
+class SeverityScore(SerializableModel):
+ """
+ A score for the severity of the issue / error.
+
+ - The raw score is the score as potentially output by the underlying model.
+ - The normalized score is rescaled in such a way that the interval between 0 and 1 maps to the defined severity levels.
+
+ """
+
+ raw: float
+ normalized: float
+
+
+class LineRange(SerializableModel):
+ start: int
+ end: int
+
+ def __lt__(self, other: "LineRange") -> bool:
+ if self.start != other.start:
+ return self.start < other.start
+ return self.end < other.end
+
+ @classmethod
+ def build_from_substring(cls, file_contents: str, substring: str) -> tuple["LineRange", ...]:
+ """
+ Convert a substring in a file to a tuple of LineRange instances.
+
+ Each LineRange instance corresponds to a single occurrence of the substring in the file.
+ (Except when multiple occurences are on the same line, in which case only one LineRange is
+ created to represent them).
+
+ LineRanges are returned in the order they appear in the file.
+
+ In case the substring can't be found, an empty tuple is returned.
+
+ """
+
+ line_ranges = set()
+ offset_chars = 0
+ offset_lines = 0
+ while True:
+ cut_contents = file_contents[offset_chars:]
+ start_index = cut_contents.find(substring)
+ if start_index == -1:
+ break
+ end_index = start_index + len(substring)
+ line_start = offset_lines + cut_contents.count("\n", 0, start_index)
+ line_end = offset_lines + cut_contents.count("\n", 0, end_index)
+ offset_chars += end_index
+ offset_lines = line_end
+ line_ranges.add(LineRange(start=line_start, end=line_end))
+ return tuple(sorted(line_ranges))
+
+
+class AgenticPhase(StrEnum):
+ """Phases of agentic analysis."""
+
+ ISSUE_IDENTIFICATION = "issue_identification"
+ COLLATION = "collation"
+ FILTRATION = "filtration"
+ DEDUPLICATION = "deduplication"
+
+
+class IssueIdentifierType(StrEnum):
+ BATCHED_COMMIT_CHECK = "batched_commit_check"
+ CORRECTNESS_COMMIT_CLASSIFIER = "correctness_commit_classifier"
+ AGENTIC_ISSUE_IDENTIFIER = "agentic_issue_identifier"
+ CONVERSATION_HISTORY_IDENTIFIER = "conversation_history_issue_identifier"
+
+
+class IssueCode(StrEnum):
+ """
+ A code for the type of issue / error detected.
+
+ The code can either correspond something very specific (e.g. "ambiguous_docstring")
+ or to something more general (e.g. "function_implementation").
+
+ The latter case would be used as an "umbrella" code in cases we don't know what exactly comes out of an issue verifier.
+
+ """
+
+ # Verifier-based.
+ INCORRECT_FUNCTION_IMPLEMENTATION = "incorrect_function_implementation"
+
+ # Batched file checks
+ INEFFICIENT_CODE = "inefficient_code"
+ BAD_NAMING = "bad_naming"
+ POOR_DOCSTRING = "poor_docstring"
+ RACE_CONDITION = "race_condition"
+ HARDCODED_SECRET = "hardcoded_secret"
+ DUPLICATE_CODE = "duplicate_code"
+ UNUSED_CODE = "unused_code"
+ COMMIT_MESSAGE_MISMATCH = "commit_message_mismatch"
+
+ # Batched commit checks
+ INCOMPLETE_INTEGRATION_WITH_EXISTING_CODE = "incomplete_integration_with_existing_code"
+ DOCUMENTATION_IMPLEMENTATION_MISMATCH = "documentation_implementation_mismatch"
+ USER_REQUEST_ARTIFACTS_LEFT_IN_CODE = "user_request_artifacts_left_in_code"
+ POOR_NAMING = "poor_naming"
+ REPETITIVE_OR_DUPLICATE_CODE = "repetitive_or_duplicate_code"
+ REFACTORING_NEEDED = "refactoring_needed"
+ TEST_COVERAGE = "test_coverage"
+ RESOURCE_LEAKAGE = "resource_leakage"
+ DEPENDENCY_MANAGEMENT = "dependency_management"
+ INSECURE_CODE = "insecure_code"
+ CORRECTNESS_SYNTAX_ISSUES = "correctness_syntax_issues"
+ FAILS_SILENTLY = "fails_silently"
+ INSTRUCTION_FILE_DISOBEYED = "instruction_file_disobeyed"
+ ABSTRACTION_VIOLATION = "abstraction_violation"
+
+ # Correctness commit classifier
+ LOGIC_ERROR = "logic_error"
+ RUNTIME_ERROR_RISK = "runtime_error_risk"
+ INCORRECT_ALGORITHM = "incorrect_algorithm"
+ ERROR_HANDLING_MISSING = "error_handling_missing"
+ ASYNC_CORRECTNESS = "async_correctness"
+ TYPE_SAFETY_VIOLATION = "type_safety_violation"
+
+ # Conversation history identifier
+ MISLEADING_BEHAVIOR = "misleading_behavior"
+ INSTRUCTION_TO_SAVE = "instruction_to_save"
+
+ # Github dataset, not yet implemented in commit checks
+ MISMATCHED_CODE_PATTERNS = "mismatched_code_patterns"
+
+ # Issue code for flagging suggested improvements or new features, as opposed to actual issues
+ SUGGESTED_IMPROVEMENT = "suggested_improvement"
+
+ # Catchall
+ MISCELLANEOUS = "miscellaneous"
+ ALL_CODE_ISSUES = "all_code_issues"
+
+ # Deprecated
+ _DEPRECATED_LLM_ARTIFACTS_LEFT_IN_CODE = "llm_artifacts_left_in_code"
+
+
+class IssueLocation(SerializableModel):
+ """A location in a file."""
+
+ line_start: int
+ line_end: int
+ filename: str | None = None
+ # The scope of the issue. Usually the qualified name of the function that the issue is located in.
+ # If the issue is part of a class definition (and not limited to a particular method),
+ # the name of the class. If the issue is at the global file level, None.
+ scope: str | None = None
+
+
+IssueID = str
+
+
+class IdentifiedVerifyIssue(SerializableModel):
+ """An identified code issue / error."""
+
+ issue_id: IssueID | None = Field(default_factory=generate_id)
+ code: IssueCode
+ description: str
+ severity_score: SeverityScore
+ location: tuple[IssueLocation, ...] = Field(default_factory=tuple)
+ confidence_score: ConfidenceScore | None = None
+ fix: str | None = None
+ violating_instruction: str | None = None
+ violating_instruction_location: IssueLocation | None = None
+ # TODO: remove these fields
+
+ # An issue is fundamentally fixable if we can change the implementation to make the issue go away.
+ # (An example of a non-fixable issue is a nonsensical commit message - changing the implementation doesn't help here.)
+ # - iffv something is not fixable why would we want to report it?
+ is_fixable: bool = True
+
+
+class InvocationInfo(SerializableModel):
+ """Information about an LLM invocation including token usage, timing, and cost. Populate whichever fields are available."""
+
+ input_tokens: int | None = None
+ cache_creation_input_tokens: int | None = None
+ cache_read_input_tokens: int | None = None
+ total_input_tokens: int | None = None
+ output_tokens: int | None = None
+ duration_ms: float | None = None
+ cost: float | None = None
+ num_turns: int | None = None
+
+
+class IssueIdentificationLLMResponseMetadata(SerializableModel):
+ """Configuration metadata for LLM responses."""
+
+ type: Literal["IssueIdentificationLLMResponseMetadata", "IssueIdentificationLLMResponseConfig"] = (
+ "IssueIdentificationLLMResponseMetadata"
+ )
+ agentic_phase: AgenticPhase | None = None
+ issue_type: IssueCode | None = None
+ identifier_name: str | None = None
+ issue_ids: tuple[IssueID] | None = None
+
+
+class LLMResponse(SerializableModel):
+ metadata: IssueIdentificationLLMResponseMetadata # Make this a union if there are other types of LLM responses
+ raw_response: tuple[str, ...]
+ invocation_info: InvocationInfo | None = None
+
+ # Deprecated fields
+ config: IssueIdentificationLLMResponseMetadata | None = Field(default=None, deprecated=True)
+
+
+class IssueIdentificationDebugInfo(SerializableModel):
+ llm_responses: tuple[LLMResponse, ...]
+
+
+class IssueIdentifierResult(SerializableModel):
+ """Container for an identified issue along with the LLM responses that generated it."""
+
+ issue: IdentifiedVerifyIssue
+ passes_filtration: bool = True
diff --git a/imbue_core/imbue_core/error_utils.py b/imbue_core/imbue_core/error_utils.py
@@ -0,0 +1,570 @@
+import functools
+import os
+import re
+import sys
+import threading
+import time
+import traceback
+from collections.abc import Callable
+from collections.abc import Collection
+from collections.abc import Hashable
+from enum import StrEnum
+from typing import Any
+from typing import Iterable
+from typing import Mapping
+from typing import MutableMapping
+from typing import assert_never
+
+import sentry_sdk
+import sentry_sdk.utils
+import traceback_with_variables
+from loguru import logger
+from pydantic import Field
+from pydantic import PrivateAttr
+from sentry_sdk import HttpTransport
+from sentry_sdk.attachments import Attachment
+from sentry_sdk.consts import EndpointType
+from sentry_sdk.envelope import Envelope
+from sentry_sdk.integrations.stdlib import StdlibIntegration
+from sentry_sdk.types import Event
+from sentry_sdk.types import Hint
+from traceback_with_variables import Format
+
+from imbue_core.common import truncate_string
+from imbue_core.pydantic_serialization import FrozenModel
+from imbue_core.pydantic_serialization import MutableModel
+from imbue_core.s3_uploader import upload_to_s3
+from imbue_core.sentry_loguru_handler import SENTRY_LOG_FORMAT
+from imbue_core.sentry_loguru_handler import SentryBreadcrumbHandler
+from imbue_core.sentry_loguru_handler import SentryEventHandler
+from imbue_core.sentry_loguru_handler import SentryLoguruLoggingLevels
+from imbue_core.sentry_loguru_handler import log_error_inside_sentry
+
+try:
+ import brotli # type: ignore
+except ImportError:
+ brotli = None
+
+
+# sentry's size limits are annoyingly hard to evaluate before sending the event. we'll just try to be conservative.
+# https://docs.sentry.io/concepts/data-management/size-limits/
+# https://develop.sentry.dev/sdk/data-model/envelopes/#size-limits
+MAX_SENTRY_ATTACHMENT_SIZE = 10 * 1024 * 1024
+
+
+class SentryEventRejected(Exception):
+ pass
+
+
+class ExceptionKey(FrozenModel):
+ exception_type: type[BaseException] | None
+ exception_args: tuple[Hashable, ...]
+
+ @classmethod
+ def build_from_exception_or_fingerprint(
+ cls, exception: BaseException | None, log_fingerprint: str | None
+ ) -> "ExceptionKey":
+ if exception is None:
+ return cls(
+ exception_type=None,
+ exception_args=(log_fingerprint,),
+ )
+ else:
+ return cls(
+ exception_type=type(exception),
+ # FIXME: we may grab things with references here unnecessarily. Let's store only the hash here and stringified representation.
+ exception_args=tuple(arg for arg in exception.args if isinstance(arg, Hashable)),
+ )
+
+
+class ExceptionHistory(MutableModel):
+ total_sent: int = 0
+ total_throttled: int = 0
+
+ last_reported_at: float | None = None # monotonic clock value
+ throttled_since_last_report: int = 0
+
+ @property
+ def since_last_report(self) -> float:
+ last_reported_at = self.last_reported_at
+ if last_reported_at is None:
+ return float("inf")
+ return time.monotonic() - last_reported_at
+
+ def log_throttled(self):
+ self.throttled_since_last_report += 1
+ self.total_throttled += 1
+
+ def log_reported(self):
+ self.last_reported_at = time.monotonic()
+ self.throttled_since_last_report = 0
+ self.total_sent += 1
+
+
+def _first_line_of_log_message(event: Event) -> str | None:
+ """Extracts the first line of the log message from the event, if any."""
+ message = event.get("logentry", {}).get("message")
+ if message and isinstance(message, str):
+ message_lines = message.strip().splitlines()
+ if message_lines:
+ return message_lines[0]
+ return None
+
+
+def _get_full_location_from_event(event: Event) -> str | None:
+ """Extracts the `full_location` field that we are supposed to generate in our log handlers."""
+ extra = event.get("extra", {}).get("extra")
+ if extra and isinstance(extra, dict):
+ full_location = extra.get("full_location")
+ if full_location and isinstance(full_location, str):
+ return full_location.strip() or None
+ return None
+
+
+class _ReasonToAllowSendingEvent(StrEnum):
+ PASS_THRU = "pass_thru"
+ NO_RATE_LIMIT_INFO = "no_rate_limit_info"
+ TOO_MANY_TRACKED_EXCEPTIONS = "too_many_tracked_exceptions"
+ INITIAL = "initial"
+ INITIAL_GRACE_PERIOD = "initial_grace_period"
+ TIMEOUT_ELAPSED = "timeout_elapsed"
+
+
+class _SentryEventRateLimiter(MutableModel):
+ """Prevent logging the same specific exceptions multiple times to sentry.
+
+ Each allowed exception is assumed to be sent.
+ """
+
+ # these exception will never be rate limited
+ pass_thru_exception_types: Collection[type[BaseException]] = Field(default_factory=set)
+ # the number of initial reports to allow before starting to apply rate limiting
+ initial_reports_without_rate_limiting: int = 2
+ # the time (in seconds) that must pass since the last report of a given exception before allowing
+ # another report it is multiplied by the number of times the exception has been passed-thru since
+ # the app start after the first throttling event
+ timeout_factor: float = 60.0
+ # maximum number of different exceptions to track for rate limiting
+ # once this number is exceeded, all events will be passed through unfiltered
+ max_tracked_rate_limited_exceptions: int = 10_000
+
+ # we should not be called in parallel, but better safe than sorry
+ # this lock protects access to _exception_history, its contents, and the total counters
+ _lock: threading.Lock = PrivateAttr(default_factory=threading.Lock)
+ _exception_history: MutableMapping[ExceptionKey, ExceptionHistory] = PrivateAttr(default_factory=dict)
+ _total_throttled: int = PrivateAttr(default=0)
+ _total_sent: int = PrivateAttr(default=0)
+
+ def _annotate_event(
+ self,
+ event: Event,
+ reason_to_allow: _ReasonToAllowSendingEvent,
+ past_history: ExceptionHistory | None = None,
+ ) -> Event:
+ logger.trace("Annotating event with rate limiter: {}", reason_to_allow)
+
+ annotation: dict[str, Any] = {
+ "reason_to_allow": reason_to_allow.value,
+ "application": {
+ "total_throttled": self._total_throttled,
+ "total_sent": self._total_sent,
+ "total_tracked": len(
+ self._exception_history
+ ), # thread-safe to read without lock since we don't care about consistency
+ },
+ }
+ if past_history is not None:
+ annotation["instance"] = {
+ "since_last_report": past_history.since_last_report,
+ "throttled_since_last_report": past_history.throttled_since_last_report,
+ "total_throttled": past_history.total_throttled,
+ "total_sent": past_history.total_sent,
+ }
+
+ event.setdefault("extra", {})
+ event["extra"]["rate_limiter"] = annotation
+
+ event.setdefault("tags", {})
+ event["tags"]["rate_limiter_reason_to_allow"] = reason_to_allow
+ return event
+
+ def before_send(self, event: Event, hint: Hint) -> Event | None:
+ annotated_event = self._before_send(event, hint)
+ with self._lock:
+ if annotated_event is None:
+ self._total_throttled += 1
+ else:
+ self._total_sent += 1
+
+ return annotated_event
+
+ def _before_send(self, event: Event, hint: Hint) -> Event | None:
+ exception = None
+ exception_type = None
+ # see sentry_sdk._types.ExcInfo which sadly we can't import
+ if "exc_info" in hint:
+ exception_type, exception, _ = hint["exc_info"]
+
+ if (exception_type is not None) and (exception_type in self.pass_thru_exception_types):
+ return self._annotate_event(event, _ReasonToAllowSendingEvent.PASS_THRU)
+
+ first_line = _first_line_of_log_message(event)
+ full_location = _get_full_location_from_event(event)
+ if first_line and full_location:
+ log_fingerprint = "\n".join([first_line, full_location])
+ else:
+ log_fingerprint = None
+
+ if not (log_fingerprint or exception):
+ # nothing to rate limit on
+ return self._annotate_event(event, _ReasonToAllowSendingEvent.NO_RATE_LIMIT_INFO)
+
+ key = ExceptionKey.build_from_exception_or_fingerprint(exception, log_fingerprint)
+ with self._lock:
+ if key not in self._exception_history:
+ # we could LRU but if we got to this point, there's something else to figure out, like bad keying
+ if len(self._exception_history) >= self.max_tracked_rate_limited_exceptions:
+ return self._annotate_event(event, _ReasonToAllowSendingEvent.TOO_MANY_TRACKED_EXCEPTIONS)
+ history = ExceptionHistory(last_reported_at=time.monotonic(), total_sent=1)
+ self._exception_history[key] = history
+ return self._annotate_event(event, _ReasonToAllowSendingEvent.INITIAL)
+
+ history = self._exception_history[key]
+ reason_to_allow: _ReasonToAllowSendingEvent | None = None
+ if history.total_sent < self.initial_reports_without_rate_limiting:
+ reason_to_allow = _ReasonToAllowSendingEvent.INITIAL_GRACE_PERIOD
+ else:
+ current_timeout = self.timeout_factor * max(
+ 1,
+ history.total_sent - self.initial_reports_without_rate_limiting + 1,
+ )
+ if history.since_last_report >= current_timeout:
+ logger.trace("Timeout elapsed for event: {}, {}", key, current_timeout)
+ reason_to_allow = _ReasonToAllowSendingEvent.TIMEOUT_ELAPSED
+
+ if reason_to_allow:
+ event = self._annotate_event(event, reason_to_allow=reason_to_allow, past_history=history)
+ history.log_reported()
+ return event
+ history.log_throttled()
+
+ logger.trace("Rate limiting event: {}", key)
+ return None
+
+
+class ImbueSentryHttpTransport(HttpTransport):
+ """The sentry python sdk has pretty lame behavior if the event is too large.
+ It'll just drop it, and record stats indicating that an event was dropped.
+ You can see these at `https://generally-intelligent-e3.sentry.io/stats`, category "invalid".
+ But there's no way to recover any information about the dropped event.
+
+ We could try to just ensure the events don't violate the size limit, which we try to do,
+ but their size limits are a bit complicated and thus hard to pre-verify. So we also want to know if anything slips through.
+
+ The actual sentry web API does return a status code (413) if the event was rejected,
+ so we need to handle this at the level of the sentry HttpTransport and do something with it.
+ """
+
+ def _send_request(
+ self,
+ body: bytes,
+ headers: dict[str, str],
+ endpoint_type: EndpointType = EndpointType.ENVELOPE,
+ envelope: Envelope | None = None,
+ ) -> None:
+ """This is a copy of the original `_send_request` method from the HttpTransport class,
+ with a hook to call `on_too_large_event` added.
+ """
+
+ def record_loss(reason: str) -> None:
+ if envelope is None:
+ self.record_lost_event(reason, data_category="error")
+ else:
+ envelope_items = envelope.items
+ assert envelope_items is not None
+ for item in envelope_items:
+ self.record_lost_event(reason, item=item)
+
+ headers.update(
+ {
+ "User-Agent": str(self._auth.client),
+ "X-Sentry-Auth": str(self._auth.to_header()),
+ }
+ )
+ try:
+ response = self._request(
+ "POST",
+ endpoint_type,
+ body,
+ headers,
+ )
+ except Exception:
+ self.on_dropped_event("network")
+ record_loss("network_error")
+ raise
+
+ try:
+ self._update_rate_limits(response)
+
+ if response.status == 429:
+ # if we hit a 429. Something was rate limited but we already
+ # acted on this in `self._update_rate_limits`. Note that we
+ # do not want to record event loss here as we will have recorded
+ # an outcome in relay already.
+ self.on_dropped_event("status_429")
+
+ elif response.status >= 300 or response.status < 200:
+ sentry_sdk.utils.logger.error(
+ "Unexpected status code: %s (body: %s)",
+ response.status,
+ getattr(response, "data", getattr(response, "content", None)),
+ )
+ self.on_dropped_event("status_{}".format(response.status))
+ record_loss("network_error")
+
+ if response.status == 413:
+ assert envelope is not None
+ self.on_too_large_event(body, envelope)
+ finally:
+ response.close()
+
+ def on_too_large_event(self, body: bytes, envelope: Envelope) -> None:
+ """we want to log _something_ to sentry, because otherwise we have no idea what happened,
+ but we also need to be super careful that this fallback doesn't itself fail.
+
+ exceptions raised here will simply get eaten and result in nothing getting logged to sentry,
+ both due to sentry's usage of `capture_internal_exceptions`
+ and that we're running in a worker thread and i don't think they make an effort to re-surface exceptions from threads.
+ """
+ msg = "request was too large to send to sentry"
+ try:
+ raise SentryEventRejected(msg)
+ except SentryEventRejected as e:
+ stripped_envelope = Envelope(headers=envelope.headers)
+ attachment_sizes = {}
+ envelope_items = envelope.items
+ assert envelope_items is not None
+ for item in envelope_items:
+ if item.data_category == "attachment":
+ payload = item.payload
+ payload_bytes_len = len(payload.get_bytes() if not isinstance(payload, (bytes, str)) else payload)
+ item_headers = item.headers
+ assert item_headers is not None
+ attachment_sizes[item_headers["filename"]] = payload_bytes_len
+ continue
+ stripped_envelope.add_item(item)
+ # this is uncompressed (so we can inspect it)
+ serialized_stripped_envelope = stripped_envelope.serialize()
+
+ extra: dict[str, str | int] = {
+ "uncompressed_attachment_sizes": str(attachment_sizes),
+ "original_compressed_request_body_size": len(body),
+ "uncompressed_stripped_envelope_size": len(serialized_stripped_envelope),
+ }
+
+ # send stripped envelope to S3 -- is preceding code now overkill?
+ upload_name = upload_to_s3("stripped_envelope", ".txt", serialized_stripped_envelope)
+
+ log_error_inside_sentry(
+ e,
+ msg,
+ extra=extra,
+ additional_s3_uploads=(upload_name,) if upload_name else None,
+ )
+
+
+def get_traceback_with_vars(exception: BaseException | None = None) -> str:
+ # be careful of potential performance regressions with increasing these limits
+ tb_format = Format(max_value_str_len=100_000, max_exc_str_len=2_000_000)
+ if exception is None:
+ # no exception passed in; get the current exception. this will still be None if not in an exception handler
+ exception = sys.exception()
+ try:
+ if exception is not None:
+ # we are in an exception handler, use that for the traceback
+ # for some reason this breaks when casting to an `Exception`, so just using type: ignore
+ return traceback_with_variables.format_exc(exception, fmt=tb_format) # type: ignore
+ else:
+ # not in an exception handler, just get the current stack
+ return traceback_with_variables.format_cur_tb(fmt=tb_format)
+ except Exception as e:
+ return (
+ f"got exception while formatting traceback with `traceback_with_variables`: {traceback.format_exception(e)}"
+ )
+
+
+def _default_sentry_add_extra_info_hook(event: Event, hint: Hint) -> tuple[Event, Hint, tuple[Callable, ...]]:
+ """Add traceback with variables to the event as an attachment."""
+ # TODO: We just use sentry attachments here; we could also upload to S3, but figure this hook is itself a fallback, so leaving it for now?
+ expected_attachments = []
+ tb_with_vars = truncate_string(get_traceback_with_vars(), MAX_SENTRY_ATTACHMENT_SIZE)
+ hint["attachments"].append(Attachment(tb_with_vars.encode(), filename="traceback_with_variables.txt"))
+ expected_attachments.append("traceback_with_variables.txt")
+ # record the names of the expected attachments just in case there's any weirdness about attachments not showing up
+ event.setdefault("extra", {})["expected_attachments"] = str(expected_attachments)
+ return event, hint, ()
+
+
+# We define BeforeSendType here to be one or more callables that match the signature of sentry's before_send hook.
+# The event will be passed through each one in our wrapping code.
+BaseBeforeSendType = Callable[[Event, Hint], Event | None]
+BeforeSendType = BaseBeforeSendType | list[BaseBeforeSendType]
+
+
+# NOTE: if the actual event (without attachments) being too large is a problem, then it will be handled
+# in our custom logic in ImbueSentryHttpTransport above.
+def _before_send_wrapper(
+ event: Event,
+ hint: Hint,
+ before_send_list: Iterable[BaseBeforeSendType],
+) -> Event | None:
+ try:
+ for before_send in before_send_list:
+ # pyre-fixme[9]: the result of before_send can be None which is not compatible with event annotation.
+ event = before_send(event, hint)
+ if event is None:
+ return None
+
+ return event
+ except Exception as e:
+ # It is critical that we catch errors here and print them, because this is called from sentry
+ # Failing to do so means that we will see NOTHING about the failure!
+ # See this PR for more: https://gitlab.com/generally-intelligent/generally_intelligent/-/merge_requests/5789
+ #
+ # Questions to the above:
+ # - why are we not relying on the Sentry's logger for this?
+ # - won't the call to `logger.exception` itself try to send something to Sentry causing recursion?
+ # - the following message will likely hit an error inside Loguru handler because it is not allowed
+ # to call emit from inside emit (that's what we're in here).
+ logger.exception("Failure when processing event in before_send hook: {}", e)
+ # NOTE: this re-raise will get suppressed by Sentry and treated as if `before_send` returned `None`
+ raise
+
+
+def _fixup_release_id(release_id: str) -> str:
+ """
+ For pre-release release candidate versions, Sentry requires the release ID to be in the semver format.
+
+ E.g. "0.1.0rc1" should be converted to "0.1.0-rc.1".
+
+ """
+ return re.sub(r"(\d+\.\d+\.\d+)rc(\d+)", r"\1-rc.\2", release_id)
+
+
+def setup_sentry(
+ dsn: str,
+ release_id: str,
+ global_user_context: Mapping[str, str] | None = None,
+ integrations: tuple[Any, ...] = (),
+ before_send: BeforeSendType | None = None,
+ add_extra_info_hook: Callable[[Event, Hint], tuple[Event, Hint, tuple[Callable, ...]]] | None = None,
+ environment: str | None = None,
+) -> None:
+ """Sets up the main Sentry instance for this process.
+
+ This should be done *after* setting up normal loguru loggers, to ensure that sentry handling happens after normal logging.
+ In case the sentry stuff hangs or something odd, we want to make sure to at least get regular log output.
+
+ Args:
+ ...
+ add_extra_info_hook: If provided, this function will be called with the event at Handle time.
+ before_send: If provided, this function (or list of functions) will be called in order to handle and mutate the event before sending to Sentry.
+ """
+ assert (
+ "SENTRY_DSN" not in os.environ
+ ), "Please `unset SENTRY_DSN` in your environment. Set the DSN via the server settings FRONTEND_SENTRY_DSN and BACKEND_SENTRY_DSN instead."
+
+ before_send_unrolled = []
+
+ if isinstance(before_send, list):
+ before_send_unrolled = list(before_send)
+ elif callable(before_send):
+ before_send_unrolled = [before_send]
+ elif before_send is None:
+ pass
+ else:
+ assert_never(before_send)
+
+ # NOTE: the rate limiter object's lifetime is maintained by being captured in the
+ # closure of the before_send function
+ rate_limiter = _SentryEventRateLimiter()
+ before_send_unrolled.append(rate_limiter.before_send)
+
+ before_send = functools.partial(
+ _before_send_wrapper,
+ before_send_list=before_send_unrolled,
+ )
+
+ sentry_sdk.init(
+ sample_rate=1.0,
+ environment=environment,
+ traces_sample_rate=1.0,
+ # required for `logger.error` calls to include stacktraces
+ attach_stacktrace=True,
+ # note this will capture unhandled exceptions even if not explicitly logged, among other things
+ # https://docs.sentry.io/platforms/python/integrations/default-integrations/
+ default_integrations=True,
+ # this doesn't affect the default integrations, but prevents any other ones from being added automatically
+ auto_enabling_integrations=False,
+ integrations=[
+ *integrations,
+ ],
+ disabled_integrations=[
+ # this only adds hooks to subprocess and httplib, which imo just adds noisy breadcrumbs.
+ StdlibIntegration()
+ ],
+ dsn=dsn,
+ # may want to get more restrictive about this in the future
+ # see https://docs.sentry.io/platforms/python/data-management/data-collected/
+ send_default_pii=True,
+ # sentry has a max payload size of 1MB, so we can't make this infinite
+ max_value_length=10_000,
+ add_full_stack=True,
+ before_send=before_send,
+ release=_fixup_release_id(release_id),
+ # default is 100; can't make it too large because total event size must be <1MB
+ max_breadcrumbs=100,
+ # if the locals is very large, sentry gets to be quite slow to log errors if this is enabled.
+ # we log our own traceback_with_variables anyways.
+ include_local_variables=False,
+ transport=ImbueSentryHttpTransport,
+ )
+ logger.info("Sentry initialized")
+
+ if global_user_context is not None:
+ sentry_sdk.set_user(dict(global_user_context))
+
+ # capture loguru errors/exceptions with a custom handler
+ min_sentry_level: int = SentryLoguruLoggingLevels.LOW_PRIORITY.value
+ handler = SentryEventHandler(
+ level=min_sentry_level,
+ add_extra_info_hook=add_extra_info_hook or _default_sentry_add_extra_info_hook,
+ )
+ register_sentry_event_handler(handler)
+ logger.add(
+ handler,
+ level=min_sentry_level,
+ diagnose=False,
+ format=SENTRY_LOG_FORMAT,
+ )
+ # capture lower level loguru messages to add as breadcrumbs on events
+ # the extra info is not helpful here and makes the breadcrumbs larger; they're still available in the log file attachment
+ breadcrumb_level: int = SentryLoguruLoggingLevels.INFO.value
+ logger.add(
+ SentryBreadcrumbHandler(level=breadcrumb_level, strip_extra=True),
+ level=breadcrumb_level,
+ diagnose=False,
+ format=SENTRY_LOG_FORMAT,
+ )
+
+
+_SENTRY_EVENT_HANDLER: SentryEventHandler | None = None
+
+
+def register_sentry_event_handler(handler: SentryEventHandler) -> None:
+ global _SENTRY_EVENT_HANDLER
+ _SENTRY_EVENT_HANDLER = handler
+
+
+def get_sentry_event_handler() -> SentryEventHandler | None:
+ return _SENTRY_EVENT_HANDLER
diff --git a/imbue_core/imbue_core/errors.py b/imbue_core/imbue_core/errors.py
@@ -0,0 +1,36 @@
+"""This module contains the base errors for the Imbue applications.
+
+Please subclass from one of the errors in this module for errors that you expect other Imbumans to handle.
+"""
+
+from typing import Any
+
+
+class ImbueRuntimeException(BaseException):
+ """Base class for all things that could go wrong within Imbue code.
+
+ An ImbueRuntimeException may or may not be recoverable. Most library code should not be throwing
+ ImbueRuntimeExceptions.
+ """
+
+ _was_logged_by_log_exception: bool
+
+ def __init__(self, *args: Any) -> None:
+ super().__init__(*args)
+ # New instances start out marked as not-yet-logged.
+ self._was_logged_by_log_exception = False
+
+
+class ImbueError(ImbueRuntimeException, Exception):
+ """Base class for all errors that could possibly be handled.
+
+ When you are building the external API of your subcomponent, the errors you throw should subclass from this.
+ """
+
+
+class ExpectedError(ImbueError):
+ """Base class for all Imbue errors that we expect to be handled.
+
+ Use this subclass of ImbueError to represent an exception that -must- be handled by a caller. The usual use-case is
+ when caller and callee are part of the same subsystem.
+ """
diff --git a/imbue_core/imbue_core/fixed_traceback.py b/imbue_core/imbue_core/fixed_traceback.py
@@ -0,0 +1,35 @@
+from types import TracebackType
+from typing import Any
+from typing import Self
+from typing import cast
+
+from tblib import Traceback
+
+
+class FixedTraceback(Traceback):
+ """
+ This class exists mostly to fix a bug in tblib where tb_lasti is not properly initialized.
+ We don't care about that value, so we just set it to -1.
+
+ While I was at it, I also fixed the types for the methods we use.
+ """
+
+ def __init__(self, tb: TracebackType) -> None:
+ super().__init__(tb)
+ tb_next = self
+ while tb_next:
+ setattr(tb_next, "tb_lasti", -1)
+ tb_next = tb_next.tb_next
+
+ def as_traceback(self) -> TracebackType | None:
+ return cast(TracebackType | None, super().as_traceback())
+
+ @classmethod
+ def from_tb(cls, tb: TracebackType) -> Self:
+ result = cls(tb)
+ return result
+
+ @classmethod
+ def from_dict(cls, dct: dict[str, Any]) -> Self:
+ # pyre-fixme[11]: pyre seems to have some trouble with Self in some specific cases, including cast
+ return cast(cls, super().from_dict(dct))
diff --git a/imbue_core/imbue_core/frozen_utils.py b/imbue_core/imbue_core/frozen_utils.py
@@ -0,0 +1,141 @@
+from abc import ABC
+from abc import abstractmethod
+from copy import deepcopy
+from functools import cached_property
+from typing import Any
+from typing import Iterable
+from typing import Mapping
+from typing import NoReturn
+from typing import Protocol
+from typing import Sequence
+from typing import TYPE_CHECKING
+from typing import TypeAlias
+from typing import TypeVar
+from typing import cast
+
+if TYPE_CHECKING:
+ from _typeshed import SupportsKeysAndGetItem
+
+
+class _SupportsLessThan(Protocol):
+ def __lt__(self, __other: Any) -> bool: ...
+
+
+T = TypeVar("T")
+TV = TypeVar("TV")
+TK = TypeVar("TK", bound=_SupportsLessThan)
+
+
+class FrozenMapping(Mapping[T, TV], ABC):
+ @abstractmethod
+ def __hash__(self) -> int: ...
+
+
+class FrozenDict(dict[T, TV], FrozenMapping[T, TV]):
+ def _key(self) -> frozenset[tuple[T, TV]]:
+ return frozenset(self.items())
+
+ @cached_property
+ def _hash(self) -> int:
+ # bawr said it should be fine
+ return hash(self._key())
+
+ def __hash__(self) -> int: # type: ignore
+ return self._hash
+
+ def _mutation_error(self, method: str) -> RuntimeError:
+ return RuntimeError(f"Cannot call mutation method {method} on _FrozenDict {self}")
+
+ def __setitem__(self, __name: T, __value: TV) -> NoReturn:
+ raise self._mutation_error("__setitem__")
+
+ def __delitem__(self, __name: T) -> NoReturn:
+ raise self._mutation_error("__delitem__")
+
+ # pyre-fixme[14]: pyre thinks this is an inconsistent override and i don't feel like fixing it because the function ignores its arguments
+ def update(
+ self,
+ __m: "SupportsKeysAndGetItem[T, TV] | Iterable[tuple[T, TV]]" = (),
+ **kwargs: TV,
+ ) -> NoReturn:
+ raise self._mutation_error("update")
+
+ # pyre-fixme[14]: pyre thinks this is an inconsistent override and i don't feel like fixing it because the function ignores its arguments
+ def setdefault(self, *args: Any, **kwargs: Any) -> NoReturn:
+ raise self._mutation_error("setdefault")
+
+ # pyre-fixme[14]: pyre thinks this is an inconsistent override and i don't feel like fixing it because the function ignores its arguments
+ def pop(self, *args: Any, **kwargs: Any) -> NoReturn:
+ raise self._mutation_error("pop")
+
+ # pyre-ignore[15]: pyre doesn't like overriding the return type with NoReturn
+ def popitem(self) -> NoReturn:
+ raise self._mutation_error("popitem")
+
+ # pyre-ignore[15]: pyre doesn't like overriding the return type with NoReturn
+ def clear(self) -> NoReturn:
+ raise self._mutation_error("clear")
+
+ def __repr__(self) -> str:
+ return f"_FrozenDict({super().__repr__()})"
+
+ def __copy__(self) -> "FrozenDict":
+ return type(self)(self)
+
+ def __deepcopy__(self, memo: dict[int, Any]) -> "FrozenDict":
+ memo[id(self)] = self
+ copied_items = ((deepcopy(key, memo), deepcopy(value, memo)) for key, value in self.items())
+ return type(self)(copied_items)
+
+ def __reduce__(self) -> tuple[Any, ...]:
+ return (FrozenDict, (dict(self),))
+
+
+def empty_mapping() -> FrozenDict[Any, Any]:
+ return FrozenDict()
+
+
+def deep_freeze_mapping(mapping: Mapping[T, TV]) -> FrozenDict[T, Any]:
+ return FrozenDict({key: cast(TV, _deep_freeze_any(value)) for key, value in mapping.items()})
+
+
+def _freeze_iterable_values(iterable: Iterable[T]) -> Iterable[Any]:
+ return (cast(T, _deep_freeze_any(value)) for value in iterable)
+
+
+def deep_freeze_set(input_set: set[T] | frozenset[T]) -> frozenset[Any]:
+ return frozenset(_freeze_iterable_values(input_set))
+
+
+def _deep_freeze_any(input_object: object) -> object:
+ if isinstance(input_object, Mapping):
+ return deep_freeze_mapping(input_object)
+
+ if isinstance(input_object, (set, frozenset)):
+ return deep_freeze_set(input_object)
+
+ if isinstance(input_object, Iterable) and not isinstance(input_object, str) and not isinstance(input_object, bytes):
+ return tuple(_freeze_iterable_values(input_object))
+
+ return input_object
+
+
+def deep_freeze_sequence(sequence: Sequence[T]) -> tuple[Any, ...]:
+ return tuple(_freeze_iterable_values(sequence))
+
+
+# Recursive type alias that captures the possible types of JSON objects (e.g. from json.loads).
+JSON: TypeAlias = "str | int | bool | float | None | dict[str, JSON] | list[JSON]"
+
+
+# Immutable version of JSON.
+FrozenJSON: TypeAlias = "str | int | bool | float | None | FrozenDict[str, FrozenJSON] | tuple[FrozenJSON, ...]"
+
+
+def deep_freeze_json(json: JSON) -> FrozenJSON:
+ if isinstance(json, dict):
+ return FrozenDict({k: deep_freeze_json(v) for k, v in json.items()})
+ elif isinstance(json, list):
+ return tuple(deep_freeze_json(v) for v in json)
+ else:
+ return json
diff --git a/imbue_core/imbue_core/git.py b/imbue_core/imbue_core/git.py
@@ -0,0 +1,587 @@
+"""Utility abstractions for interacting with git repositories."""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import shlex
+import shutil
+import subprocess
+import sys
+from asyncio.subprocess import PIPE
+from asyncio.subprocess import STDOUT
+from contextlib import asynccontextmanager
+from io import StringIO
+from pathlib import Path
+from types import TracebackType
+from typing import Any
+from typing import AsyncGenerator
+from typing import AsyncIterator
+from typing import Self
+from typing import Sequence
+from typing import TYPE_CHECKING
+from typing import TextIO
+
+import anyio
+import attr
+from loguru import logger
+
+from imbue_core.async_monkey_patches import log_exception
+from imbue_core.async_utils import sync
+from imbue_core.async_utils import sync_contextmanager_func
+from imbue_core.computing_environment.computing_environment import assert_repo_is_clean
+from imbue_core.computing_environment.computing_environment import get_head_hash
+from imbue_core.computing_environment.computing_environment import git_add
+from imbue_core.computing_environment.computing_environment import is_repo_dirty
+from imbue_core.computing_environment.computing_environment import make_commit
+from imbue_core.computing_environment.computing_environment import (
+ restore_all_staged_files,
+)
+from imbue_core.computing_environment.computing_environment import (
+ restore_all_unstaged_changes,
+)
+from imbue_core.computing_environment.computing_environment import (
+ run_command_with_retry_on_git_lock_error,
+)
+from imbue_core.computing_environment.data_types import AnyPath
+from imbue_core.computing_environment.data_types import RunCommandError
+
+if TYPE_CHECKING:
+ # for proper file mode typing
+ from _typeshed import OpenBinaryMode
+ from _typeshed import OpenBinaryModeReading
+ from _typeshed import OpenBinaryModeWriting
+ from _typeshed import OpenTextMode
+ from _typeshed import OpenTextModeReading
+ from _typeshed import OpenTextModeWriting
+
+PYTHON_EXTENSION = ".py"
+
+
+def is_path_in_git_repo(path: Path) -> bool:
+ """Check if a path is in a git repository."""
+ if path.is_file():
+ path = path.parent
+ completed_process = subprocess.run(
+ ["git", "-C", path, "rev-parse", "--is-inside-work-tree"],
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ check=False,
+ )
+ if completed_process.returncode != 0:
+ return False
+ result = completed_process.stdout.decode().strip()
+ assert result in ("true", "false"), result
+ return result == "true"
+
+
+def get_git_repo_root() -> Path:
+ """Gets a Path to the current git repo root, assuming that our cwd is somewhere inside the repo."""
+ completed_process = subprocess.run(
+ ("git", "rev-parse", "--show-toplevel"),
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ check=True,
+ )
+ root_dir = Path(completed_process.stdout.decode().strip())
+ assert root_dir.is_dir(), f"{root_dir} must be a directory"
+ return root_dir
+
+
+def get_git_repo_root_from_path(path: Path) -> Path:
+ """Gets a Path to the git repo root for the given path."""
+ if path.is_file():
+ path = path.parent
+ completed_process = subprocess.run(
+ ["git", "-C", path, "rev-parse", "--show-toplevel"],
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ check=True,
+ )
+ root_dir = Path(completed_process.stdout.decode().strip())
+ assert root_dir.is_dir(), f"{root_dir} must be a directory"
+ return root_dir
+
+
+@attr.s(auto_attribs=True, frozen=True)
+class LocalGitRepo:
+ """
+ DEPRECATED: Unless you either need asyncio or the ability to run git commands remotely on non-local compute environments, consider using SyncLocalGitRepo
+ from simple_git.py instead. SimpleGitRepo provides a subset of the functions available through computing_environment.py + LocalGitRepo, but
+ in a single class made for synchronous use.
+ """
+
+ base_path: Path
+
+ @classmethod
+ def build_from_cwd(cls) -> Self:
+ """Create a `LocalGitRepo` instance from the current working directory."""
+ return cls(get_git_repo_root())
+
+ async def run_git(
+ self,
+ command: Sequence[str],
+ check: bool = True,
+ cwd: AnyPath | None = None,
+ is_error_logged: bool = True,
+ is_stripped: bool = True,
+ retry_on_git_lock_error: bool = True,
+ ) -> str:
+ """Run a git command in the repo.
+
+ Example:
+ ```
+ git_repo.run_git("status")
+ ```
+ """
+ # TODO: check for whether hooks should actually be run when we call this function
+ # Note: this used to be within an asyncio lock to prevent the program from concurrently running git commands.
+ # This lock was removed since it was within global state, a dangerous pattern, and wasn't preventing other users from interacting with the git repo.
+ command = ["git"] + list(command)
+ if not retry_on_git_lock_error:
+ result = await self.run_command(command, check=check, is_error_logged=is_error_logged, cwd=cwd)
+ else:
+ result = await run_command_with_retry_on_git_lock_error(
+ self, command, check=check, is_error_logged=is_error_logged, cwd=cwd
+ )
+ if is_stripped:
+ return result.strip()
+ return result
+
+ sync_run_git = sync(run_git)
+
+ async def run_command(
+ self,
+ command: Sequence[str],
+ check: bool = True,
+ secrets: dict[str, str] | None = None,
+ cwd: AnyPath | None = None,
+ is_error_logged: bool = True,
+ ) -> str:
+ """Run a command in the repo.
+
+ Note, this can be used to run any command, not just git.
+ """
+ command_string = shlex.join(command)
+ logger.trace(
+ f"Running command: {command_string=} from cwd={cwd or self.base_path} with {secrets=} {check=} {is_error_logged=}"
+ )
+ proc = await asyncio.create_subprocess_exec(
+ *command,
+ cwd=cwd or self.base_path,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=secrets,
+ )
+ # note, need to be carefull not to strip() lines since whitespace may be important (e.g. for diffs)
+ # return joined lines since mostly we only use the output for logging, and this way we arn't
+ # passing around lots of lists. Also it's easy to parse by lines if needed
+ stdout_bytes, stderr_bytes = await proc.communicate()
+ try:
+ stdout = stdout_bytes.decode("UTF-8")
+ except UnicodeDecodeError as e:
+ # If we don't encounter this, it likely means something was fixed upstream and we can safely delete
+ log_exception(
+ e,
+ "Command {command_string} failed to decode stdout, replacing any invalid bytes which could lead to problems later",
+ command_string=command_string,
+ )
+ stdout = stdout_bytes.decode("UTF-8", errors="replace")
+ stderr = stderr_bytes.decode("UTF-8")
+ if check and proc.returncode != 0:
+ error_message = f"command run from cwd={self.base_path} failed with exit code {proc.returncode} and stdout:\n{stdout}\nstderr:\n{stderr}"
+ if is_error_logged:
+ logger.error(
+ f"command attempted: '{command_string}' from cwd={self.base_path}\nerror message: {error_message}"
+ )
+ # this should not be None, but do this to satisfy type checker, int or None we throw the same error
+ returncode = proc.returncode or -1
+ raise RunCommandError(
+ cmd=command_string,
+ stderr=stderr,
+ returncode=returncode,
+ cwd=cwd or self.base_path,
+ )
+ return stdout
+
+ @contextlib.asynccontextmanager
+ async def _open_file(
+ self,
+ relative_path: AnyPath,
+ cwd: AnyPath | None = None,
+ mode: OpenTextMode | OpenBinaryMode = "r",
+ mkdir_if_missing: bool = True,
+ ) -> AsyncGenerator[anyio.AsyncFile[Any], None]:
+ logger.trace("opening file {} in cwd {} with mode {}", relative_path, cwd, mode)
+ if cwd is not None:
+ sb_file_path = str(Path(cwd) / relative_path)
+ else:
+ sb_file_path = str(self.base_path / relative_path)
+
+ if mkdir_if_missing:
+ parent_dir = anyio.Path(sb_file_path).parent
+ await parent_dir.mkdir(parents=True, exist_ok=True)
+
+ f: anyio.AsyncFile[Any] | None = None
+ try:
+ f = await anyio.Path(sb_file_path).open(mode=mode) # type: ignore
+ yield f
+ finally:
+ if f is not None:
+ await f.aclose()
+
+ async def write_file(
+ self,
+ relative_path: AnyPath,
+ content: str | bytes | None,
+ cwd: AnyPath | None = None,
+ mode: OpenTextModeWriting | OpenBinaryModeWriting = "w",
+ mkdir_if_missing: bool = True,
+ ) -> None:
+ if content is None:
+ await self.delete_file(relative_path, cwd=cwd)
+ return
+
+ async with self._open_file(relative_path, cwd=cwd, mode=mode, mkdir_if_missing=mkdir_if_missing) as f:
+ logger.trace("writing to file {} in cwd {} with mode {}", relative_path, cwd, mode)
+ # pyre-fixme[6]: content can be bytes
+ await f.write(content)
+
+ async def delete_file(self, relative_path: AnyPath, cwd: AnyPath | None = None) -> None:
+ logger.trace("deleting the file {} in cwd {}", relative_path, cwd)
+ if cwd is not None:
+ sb_file_path = str(Path(cwd) / relative_path)
+ else:
+ sb_file_path = str(self.base_path / relative_path)
+ await anyio.Path(sb_file_path).unlink()
+
+ async def read_file(
+ self,
+ relative_path: AnyPath,
+ cwd: AnyPath | None = None,
+ mode: OpenTextModeReading | OpenBinaryModeReading = "r",
+ mkdir_if_missing: bool = True,
+ ) -> str | bytes:
+ async with self._open_file(relative_path, cwd=cwd, mode=mode, mkdir_if_missing=mkdir_if_missing) as f:
+ logger.trace("reading file {} in cwd {} with mode {}", relative_path, cwd, mode)
+ content = await f.read()
+ assert isinstance(content, str) or isinstance(content, bytes)
+ return content
+
+ async def head_hash(self) -> str:
+ """Get the hash of the current HEAD commit."""
+ return await get_head_hash(self)
+
+ async def is_git_repo(self) -> bool:
+ """Check that repo is valid git repo."""
+ return await anyio.Path(self.base_path / ".git").exists()
+
+ sync_is_git_repo = sync(is_git_repo)
+
+ async def assert_clean(self) -> None:
+ await assert_repo_is_clean(self)
+
+ sync_assert_clean = sync(assert_clean)
+
+ async def configure_git(
+ self,
+ git_user_name: str | None = None,
+ git_user_email: str | None = None,
+ initial_commit_message: str = "initial commit",
+ is_recreating: bool = False,
+ ) -> None:
+ """Configure git repo with user name and email."""
+ if is_recreating:
+ if await self.is_git_repo():
+ await asyncio.to_thread(shutil.rmtree, self.base_path / ".git")
+
+ # order here is important
+ # ref https://stackoverflow.com/questions/11656761/git-please-tell-me-who-you-are-error?noredirect=1
+ await self.run_git(("init",))
+ if git_user_name:
+ await self.run_git(("config", "user.name", f"'{git_user_name}'"))
+ if git_user_email:
+ await self.run_git(("config", "user.email", f"'{git_user_email}'"))
+ await self.run_git(("add", "."))
+ await self.run_git(("commit", "-m", f"'{initial_commit_message}'"))
+ branch_name = await self.run_git(("symbolic-ref", "HEAD"))
+ if not branch_name == "refs/heads/main":
+ # rename master to main for consistency
+ await self.run_git(("branch", "-m", "master", "main"))
+
+ sync_configure_git = sync(configure_git)
+
+ @asynccontextmanager
+ async def temporary_commit(
+ self,
+ tag_prefix: str,
+ commit_message: str,
+ raise_on_head_hash_change: bool = False,
+ ) -> AsyncIterator[str]:
+ """Context manager to make a temporary commit and tag in the repo."""
+ await self.run_git(("commit", "-am", commit_message, "--allow-empty", "--no-verify"))
+ head_hash = await self.head_hash()
+ tag = f"{tag_prefix}/{head_hash}"
+ await self.run_git(("tag", "--force", tag))
+ await self.run_git(("push", "origin", tag, "--no-verify"))
+ try:
+ yield head_hash
+ finally:
+ # This is susceptible to a race condition (if the user makes a commit between the time we check the head hash and the time we reset the state).
+ # So it's important to keep any block that uses this context manager short - make the commit, copy it to the controller, and work there. Don't hold the repo hostage.
+ current_head_hash = await self.head_hash()
+ if current_head_hash != head_hash and raise_on_head_hash_change:
+ raise AssertionError(
+ f"Head hash has changed from {head_hash} to {current_head_hash} since the temporary commit was made. Giving up on resetting git state, please address this manually."
+ )
+ else:
+ await self.run_git(("reset", "HEAD~"))
+
+ sync_temporary_commit = sync_contextmanager_func(temporary_commit)
+
+ async def copy_repo(self, new_repo_path: Path, exists_ok: bool = True) -> "LocalGitRepo":
+ """Make a full copy of this repo in a new directory.
+
+ Note, this will copy all the files in the repo into a new local directory, but will not handle
+ configuring the new directory as a git repo.
+ """
+ if await anyio.Path(new_repo_path).exists():
+ if not exists_ok:
+ raise FileExistsError(
+ f"New repo path '{new_repo_path} already exists. Set `exists_ok=True` if you are happy overwriting it, otherwise select new path."
+ )
+ await asyncio.to_thread(shutil.rmtree, new_repo_path)
+ await asyncio.to_thread(
+ shutil.copytree,
+ self.base_path,
+ new_repo_path,
+ dirs_exist_ok=True,
+ ignore=shutil.ignore_patterns(".git", ".gitsecret"),
+ )
+ return LocalGitRepo(new_repo_path)
+
+ sync_copy_repo = sync(copy_repo)
+
+ async def is_path_in_repo(self, file_path: str | Path | anyio.Path) -> bool:
+ """Check whether a given file path is within this repo.
+
+ FIXME: It doesn't seem entirely necessary to enumerate all of the files with a particular extension
+ just to check if a single file (whose path we know) is in the repo.
+ """
+ if isinstance(file_path, (str, Path)):
+ file_path = anyio.Path(file_path)
+ extension = file_path.suffix
+ return file_path in await self.get_all_files_by_extension(extension=extension)
+
+ async def _get_file_path(self, file_path: str | Path) -> anyio.Path:
+ path = anyio.Path(file_path)
+ if not path.is_absolute():
+ path = anyio.Path(self.base_path / path)
+ assert await path.exists(), f"File {path} does not exist."
+ return path
+
+ async def safely_read_file_from_repo(self, file_path: str | Path) -> str:
+ """Safely read file from repo."""
+ path = await self._get_file_path(file_path)
+ assert await self.is_path_in_repo(path), f"File {path} is not in repo."
+ return await path.read_text()
+
+ sync_safely_read_file_from_repo = sync(safely_read_file_from_repo)
+
+ async def get_all_files_by_extension(self, extension: str = PYTHON_EXTENSION) -> tuple[Path, ...]:
+ """Get absolute path of all files in the repo with given extension."""
+ paths: list[Path] = []
+ async for path in anyio.Path(self.base_path).rglob(f"*{extension}"):
+ paths.append(Path(path))
+ return tuple(paths)
+
+
+@attr.s(auto_attribs=True, frozen=True, kw_only=True)
+class WritableLocalGitRepo(LocalGitRepo):
+ """A Local Git Repo with support for modifying files and reseting to an initial state.
+
+ Note, this does not handle creating a copy of an existing repo, or anything. Rather it adds some additional
+ functionality for actually writing to files in the repo. For the creation of a separate copy of an existing
+ repo which supports making changes without affecting the main repo see the `temp_writable_local_git_repo` context
+ manager.
+
+ It is also recommended the `build_from_repo` function when creating a WritableLocalGitRepo, as this will
+ make sure that any untracked and uncommited changes are managed correctly.
+ """
+
+ initial_git_hash: str
+ stash_git_hash: str | None
+
+ @classmethod
+ async def build_from_repo(cls, repo: LocalGitRepo) -> "WritableLocalGitRepo":
+ """Create a writable repo from an local repo."""
+ init_hash = await repo.head_hash()
+ if await is_repo_dirty(repo):
+ await repo.run_git(("add", "."))
+ stash_hash = await make_commit(repo, "stashing uncommited and untracked changes")
+ else:
+ stash_hash = None
+
+ return cls(
+ base_path=repo.base_path,
+ initial_git_hash=init_hash,
+ stash_git_hash=stash_hash,
+ )
+
+ async def _setup(self) -> None:
+ init_hash = await self.head_hash()
+ expected_hash = self.stash_git_hash or self.initial_git_hash
+ assert init_hash == expected_hash, "git repo is not currently at expected commit"
+ assert await self.is_git_repo(), f"{self.base_path} is not a git repo"
+ await self.assert_clean()
+
+ async def reset(self) -> None:
+ """Reset the repo to the state it was in when this class was created."""
+ await restore_all_staged_files(self)
+ await restore_all_unstaged_changes(self)
+ if self.stash_git_hash:
+ # hard to reset to commit with stashed untracked and uncommited changes
+ await self.run_git(("reset", "--hard", self.stash_git_hash))
+ # soft reset to return to initial commit but keep the untracked and uncommited changes
+ await self.run_git(("reset", "--soft", self.initial_git_hash))
+ # unstage untracked and uncommited changes
+ await restore_all_staged_files(self)
+ else:
+ await self.run_git(("reset", "--hard", self.initial_git_hash))
+ await self.run_git(("clean", "-f"))
+ await self.run_git(("checkout", self.initial_git_hash))
+
+ current_git_hash = await self.head_hash()
+ assert (
+ current_git_hash == self.initial_git_hash
+ ), f"base branch changed, current git hash ({current_git_hash}) != initial git hash ({self.initial_git_hash})"
+ await self.assert_clean()
+
+ async def apply_change_to_file(self, file_path: str | Path, new_contents: str) -> None:
+ """Apply change to a single file."""
+ path = await self._get_file_path(file_path)
+ assert await self.is_path_in_repo(path), f"File {path} is not in repo."
+ await path.write_text(new_contents)
+ await git_add(self, str(path))
+
+ async def __aenter__(self) -> "WritableLocalGitRepo":
+ await self._setup()
+ return self
+
+ async def __aexit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_val: BaseException | None,
+ exc_tb: TracebackType | None,
+ ) -> None:
+ await self.reset()
+
+
+async def copy_files_from_one_repo_to_another(
+ src_repo_path: Path, dst_repo_path: Path, relative_file_paths: Sequence[str | Path]
+) -> None:
+ """Copies files from src to dst repo using the relative file paths."""
+ for relative_path in relative_file_paths:
+ src_file_path = src_repo_path / relative_path
+ dst_file_path = anyio.Path(dst_repo_path / relative_path)
+ # make sure necessary directories exist in destination
+ await dst_file_path.parent.mkdir(parents=True, exist_ok=True)
+ await asyncio.to_thread(shutil.copy2, src_file_path, dst_file_path)
+
+
+def get_repo_url_from_folder(repo_path: Path) -> str:
+ try:
+ repo_url = subprocess.check_output(
+ ["git", "remote", "get-url", "origin"],
+ cwd=repo_path,
+ universal_newlines=True,
+ ).strip()
+ except subprocess.CalledProcessError:
+ raise
+ else:
+ if repo_url.startswith("git@"):
+ # convert ssh url to https
+ repo_url = repo_url.replace(":", "/")
+ repo_url = f"https://{repo_url[4:]}"
+ if "https://oauth2:" in repo_url:
+ # remove the oauth2 prefix
+ # repo_url is something like https://oauth2:{token}@gitlab.com/.../.git
+ # change it to https://gitlab.com/.../.git
+ # This will happen if repo was originallycloned using oauth2
+ suffix = repo_url.split("@")[-1]
+ repo_url = "https://" + suffix
+ return repo_url
+
+
+def get_repo_base_path() -> Path:
+ working_directory = Path(__file__).parent
+ try:
+ return Path(
+ _run_command_and_capture_output(["git", "rev-parse", "--show-toplevel"], cwd=working_directory).strip()
+ )
+ except subprocess.CalledProcessError as e:
+ try:
+ return working_directory.parents[1]
+ except IndexError:
+ raise UnableToFindRepoBase() from e
+
+
+def _run_command_and_capture_output(args: Sequence[str], cwd: Path | None = None) -> str:
+ arg_str = " ".join(shlex.quote(arg) for arg in args)
+ print(f"Running command: {arg_str}", file=sys.stderr)
+ with subprocess.Popen(args, text=True, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) as proc:
+ with StringIO() as output:
+ _handle_output(proc, output, sys.stderr)
+ if proc.wait() != 0:
+ raise subprocess.CalledProcessError(proc.returncode, cmd=args, output=output.getvalue())
+ return output.getvalue()
+
+
+class UnableToFindRepoBase(Exception):
+ """Raised when the base of the repository cannot be found."""
+
+
+def _handle_output(process: subprocess.Popen[str], *files: TextIO) -> None:
+ process_stdout = process.stdout
+ assert process_stdout is not None
+ while True:
+ output = process_stdout.read(1)
+ if output:
+ for f in files:
+ f.write(output)
+ elif process.poll() is not None:
+ break
+
+
+def get_diff_without_index(diff: str) -> str:
+ new_lines = []
+ for line in diff.splitlines():
+ if line.startswith("index "):
+ # We replace index lines with "index 0000000..0000000 100644" because:
+ # - `0000000..0000000` ensures no real object hashes are referenced, making the diff neutral.
+ # - `100644` is the standard file mode for non-executable files in git diffs, ensuring compatibility.
+ # - This keeps the diff format valid while removing specific index information.
+ new_lines.append("index 0000000..0000000 100644")
+ else:
+ new_lines.append(line)
+ return "\n".join(new_lines).strip()
+
+
+def is_diffs_without_index_equal(diff_1: str, diff_2: str) -> bool:
+ return get_diff_without_index(diff_1) == get_diff_without_index(diff_2)
+
+
+# Copy-pasted from imbue to avoid moving the whole hammers machinery over to imbue-core.
+async def get_lines_from_process(shell_command: str, is_exit_code_validated: bool = True, **kwargs: Any) -> list[str]:
+ p = await asyncio.create_subprocess_shell(shell_command, stdin=PIPE, stdout=PIPE, stderr=STDOUT, **kwargs)
+ lines = [x.decode("UTF-8") for x in (await p.communicate())[0].splitlines()]
+ if is_exit_code_validated:
+ joined_lines = "\n".join(lines)
+ assert (
+ p.returncode == 0
+ ), f"command failed: {shell_command}\nwith output:\n{joined_lines} with exit code {p.returncode}"
+ return lines
diff --git a/imbue_core/imbue_core/git_data_types.py b/imbue_core/imbue_core/git_data_types.py
@@ -0,0 +1,35 @@
+from datetime import datetime
+from typing import Annotated
+
+from pydantic.functional_validators import PlainValidator
+
+from imbue_core.pydantic_serialization import FrozenModel
+from imbue_core.pydantic_serialization import SerializableModel
+
+
+def _validate_git_timestamp(value: str) -> str:
+ try:
+ datetime.fromisoformat(value)
+ return value
+ except ValueError:
+ raise ValueError(f"Invalid git timestamp: {value}")
+
+
+class CommitTimestamp(SerializableModel):
+ author_ts: Annotated[str, PlainValidator(_validate_git_timestamp)]
+ committer_ts: Annotated[str, PlainValidator(_validate_git_timestamp)]
+
+
+class CommitMetadata(FrozenModel):
+ commit: str
+ tree_hash: str
+ message: str
+ commit_time: CommitTimestamp
+
+ @property
+ def body(self) -> str:
+ return self.message.split("\n", 1)[-1]
+
+ @property
+ def subject(self) -> str:
+ return self.message.split("\n", 1)[0]
diff --git a/imbue_core/imbue_core/ids.py b/imbue_core/imbue_core/ids.py
@@ -0,0 +1,44 @@
+from typing import Any
+from typing import Self
+
+from pydantic import GetCoreSchemaHandler
+from pydantic_core import core_schema
+
+
+class NonEmptyStr(str):
+ # pyre-fixme[11]: pyre seems to have some trouble with Self in some specific cases, including type[Self]
+ def __new__(cls: type[Self], *args: Any, **kwargs: Any) -> Self:
+ value = str.__new__(cls, *args, **kwargs)
+ if len(value) == 0:
+ raise ValueError("NonEmptyStr cannot be empty")
+ return value
+
+ @classmethod
+ def __get_pydantic_core_schema__(cls, source_type: type, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
+ """
+ Support transparently deserializing strings into ObjectID instances and vice versa.
+ """
+ return core_schema.no_info_before_validator_function(
+ lambda raw_value: (cls(raw_value) if isinstance(raw_value, str) else raw_value),
+ core_schema.union_schema(
+ [
+ core_schema.is_instance_schema(cls),
+ core_schema.str_schema(),
+ ]
+ ),
+ serialization=core_schema.plain_serializer_function_ser_schema(
+ lambda instance: str(instance), return_schema=core_schema.str_schema()
+ ),
+ )
+
+
+class ExternalID(NonEmptyStr):
+ pass
+
+
+class AssistantMessageID(ExternalID):
+ pass
+
+
+class ToolUseID(ExternalID):
+ pass
diff --git a/imbue_core/imbue_core/imbue_cli/__init__.py b/imbue_core/imbue_core/imbue_cli/__init__.py
diff --git a/imbue_core/imbue_core/imbue_cli/action.py b/imbue_core/imbue_core/imbue_cli/action.py
@@ -0,0 +1,71 @@
+from typing import Annotated
+from typing import Literal
+
+from pydantic import Field
+from pydantic import Tag
+
+from imbue_core.imbue_cli.scout_message_types import ScoutMessageUnion
+from imbue_core.issues import CheckFailedIssue
+from imbue_core.issues import IdentifiedIssue
+from imbue_core.pydantic_serialization import SerializableModel
+from imbue_core.pydantic_serialization import build_discriminator
+from imbue_core.suggestions import CheckOutputID
+from imbue_core.suggestions import Suggestion
+
+
+class UserDisplayOutput(SerializableModel):
+ object_type: str
+
+
+class ErroredOutput(UserDisplayOutput):
+ object_type: Literal["ErroredOutput"] = "ErroredOutput"
+ error_message: str
+
+
+class CommandTextOutput(UserDisplayOutput):
+ object_type: Literal["CommandTextOutput"] = "CommandTextOutput"
+ output: str
+
+
+class CommandHTMLOutput(UserDisplayOutput):
+ object_type: Literal["CommandHTMLOutput"] = "CommandHTMLOutput"
+ output: str
+
+
+UserDisplayOutputUnion = Annotated[
+ Annotated[ErroredOutput, Tag("ErroredOutput")]
+ | Annotated[CommandTextOutput, Tag("CommandTextOutput")]
+ | Annotated[CommandHTMLOutput, Tag("CommandHTMLOutput")],
+ build_discriminator(),
+]
+
+
+class RetrieveOutput(SerializableModel):
+ object_type: str = "RetrieveOutput"
+ files: tuple[str, ...]
+
+
+class ScoutOutput(SerializableModel):
+ object_type: str = "ScoutOutput"
+ id: CheckOutputID
+ data: ScoutMessageUnion
+
+
+ActionOutputUnion = Annotated[
+ (
+ Annotated[ScoutOutput, Tag("ScoutOutput")]
+ | Annotated[Suggestion, Tag("Suggestion")]
+ | Annotated[CheckFailedIssue, Tag("CheckFailedIssue")]
+ | Annotated[IdentifiedIssue, Tag("IdentifiedIssue")]
+ | Annotated[RetrieveOutput, Tag("RetrieveOutput")]
+ ),
+ build_discriminator(),
+]
+
+
+class ActionOutput(SerializableModel):
+ command: str = Field(description="The command that was executed to produce the output.")
+ outputs: tuple[ActionOutputUnion, ...] = Field(description="The structured output data from the action.")
+ user_display: UserDisplayOutputUnion = Field(
+ description="The user display output from the action. This can be used by consumers to display a user-friendly version of the action output."
+ )
diff --git a/imbue_core/imbue_core/imbue_cli/scout_data_types.py b/imbue_core/imbue_core/imbue_cli/scout_data_types.py
@@ -0,0 +1,40 @@
+from typing import Literal
+
+from imbue_core.pydantic_serialization import SerializableModel
+
+# TODO refactor evidence example to use different classes for different output types
+
+
+class ScoutEvidenceExample(SerializableModel):
+ """A single example of evidence for the report."""
+
+ description: str
+ type: Literal["positive", "negative"]
+ command: str | None = None
+ output: str | None = None
+ code: str | None = None
+ image_path: str | None = None
+ image_data: bytes | None = None
+ image_format: str | None = None
+ image_caption: str | None = None
+
+
+class ScoutEvidence(SerializableModel):
+ """A piece of evidence for the report."""
+
+ question: str
+ action: str
+ result: str
+ score: Literal["Good", "Moderate", "Bad"]
+ confidence: Literal["High", "Medium", "Low"]
+ reference: str | None = None
+ examples: list[ScoutEvidenceExample] | None = None
+
+
+class ScoutReport(SerializableModel):
+ """A report of the scout analysis."""
+
+ goal: str
+ evidence: list[ScoutEvidence]
+ total_cost: float
+ total_time_taken: float
diff --git a/imbue_core/imbue_core/imbue_cli/scout_message_types.py b/imbue_core/imbue_core/imbue_cli/scout_message_types.py
@@ -0,0 +1,96 @@
+"""Message types for scout output."""
+
+import time
+from typing import Annotated
+from typing import Literal
+
+from pydantic import Field
+from pydantic import Tag
+from pydantic import TypeAdapter
+
+from imbue_core.imbue_cli.scout_data_types import ScoutEvidenceExample
+from imbue_core.pydantic_serialization import SerializableModel
+from imbue_core.pydantic_serialization import build_discriminator
+
+
+class ScoutMessage(SerializableModel):
+ """Base class for all scout output messages."""
+
+ object_type: str = Field(description="Discriminator field for message type")
+ timestamp: float = Field(default_factory=time.time, description="Unix timestamp when message was created")
+
+
+class EvidenceMessage(ScoutMessage):
+ """Message containing a piece of evidence."""
+
+ object_type: Literal["EvidenceMessage"] = "EvidenceMessage"
+
+ # Evidence fields (same as current ScoutEvidence)
+ question: str
+ action: str
+ result: str
+ score: Literal["Good", "Moderate", "Bad"]
+ confidence: Literal["High", "Medium", "Low"]
+ reference: str | None = None
+ examples: list[ScoutEvidenceExample] | None = None
+
+
+class ScoreMessage(ScoutMessage):
+ """Message containing an overall score assessment."""
+
+ object_type: Literal["ScoreMessage"] = "ScoreMessage"
+
+ overall_score: float # 0.0 to 1.0
+ evidence_count: int # Number of evidence pieces contributing to this score
+ score_breakdown: dict[str, int] # Distribution of Good/Moderate/Bad evidence counts
+ confidence_breakdown: dict[str, int] # Distribution of High/Medium/Low confidence counts
+ time_elapsed: float # Time elapsed since start in seconds
+
+
+class MetadataMessage(ScoutMessage):
+ """Message containing metadata about the scout run."""
+
+ object_type: Literal["MetadataMessage"] = "MetadataMessage"
+
+ goal: str
+ repo_path: str
+ model: str
+ started_at: float
+
+
+class CostMessage(ScoutMessage):
+ """Message containing cost information."""
+
+ object_type: Literal["CostMessage"] = "CostMessage"
+
+ total_cost_usd: float
+ tokens_used: int | None = None
+
+
+class StatusMessage(ScoutMessage):
+ """Message containing status updates."""
+
+ object_type: Literal["StatusMessage"] = "StatusMessage"
+
+ status: Literal["started", "running", "completed", "failed"]
+ message: str | None = None
+
+
+# Union type for all messages
+ScoutMessageUnion = Annotated[
+ (
+ Annotated[EvidenceMessage, Tag("EvidenceMessage")]
+ | Annotated[ScoreMessage, Tag("ScoreMessage")]
+ | Annotated[MetadataMessage, Tag("MetadataMessage")]
+ | Annotated[CostMessage, Tag("CostMessage")]
+ | Annotated[StatusMessage, Tag("StatusMessage")]
+ ),
+ build_discriminator(),
+]
+
+_scout_message_type_adapter = TypeAdapter(ScoutMessageUnion)
+
+
+def deserialize_scout_message_json(data: str) -> ScoutMessageUnion:
+ print(f"Parsing scout message json: {data}")
+ return _scout_message_type_adapter.validate_json(data)
diff --git a/imbue_core/imbue_core/issues.py b/imbue_core/imbue_core/issues.py
@@ -0,0 +1,67 @@
+from enum import StrEnum
+from typing import Annotated
+
+from pydantic import Tag
+
+from imbue_core.pydantic_serialization import SerializableModel
+from imbue_core.pydantic_serialization import build_discriminator
+
+
+class IssueSeverityLevel(StrEnum):
+ CRITICAL = "CRITICAL"
+ ERROR = "ERROR"
+ WARNING = "WARNING"
+ NIT = "NIT"
+
+
+class IssueKey(SerializableModel):
+ # issues from different commands should not collide
+ command: str
+ # this should NOT contain line numbers, as we want it to be stable across changes as much as possible
+ # NOTE: we do some initial formatting to avoid issues around message containing code
+ identifier: str
+
+
+class Issue(SerializableModel):
+ key: IssueKey
+ severity: IssueSeverityLevel
+
+ def is_equal(self, other: "Issue") -> bool:
+ return self.key == other.key
+
+ def summary_line(self) -> str:
+ return f"{self.severity}: {self.key}"
+
+
+class CheckFailedIssue(Issue):
+ object_type: str = "CheckFailedIssue"
+ error_message: str
+
+ raw: str | None = None
+
+
+class IdentifiedIssue(Issue):
+ object_type: str = "IdentifiedIssue"
+ issue_location: str | None = None
+
+ # The oneliner message of the problem.
+ # PYTEST: AssertionError
+ # MYPY: error: Function is missing a type annotation
+ message: str
+
+ # The full description of the issue (can be many lines)
+ # Examples:
+ # PYTEST: the longrepr, which is "def test_pearson_correlation_basic_lists():\n x = [1, 2, 3, 4, 5]\n y = [2, 4, 6, 8, 10]\n expected_correlation = 0.9819805060619657\n> ..." # noqa
+ # MYPY: "def calculate_pearson_correlation(x, y):"
+ description: str
+
+ def summary_line(self) -> str:
+ return f"{super().summary_line()} message={self.message!r}" + (
+ f" ({self.issue_location})" if self.issue_location else ""
+ )
+
+
+IssueUnion = Annotated[
+ (Annotated[CheckFailedIssue, Tag("CheckFailedIssue")] | Annotated[IdentifiedIssue, Tag("IdentifiedIssue")]),
+ build_discriminator(),
+]
diff --git a/imbue_core/imbue_core/itertools.py b/imbue_core/imbue_core/itertools.py
@@ -0,0 +1,80 @@
+import contextlib
+import itertools
+from typing import AsyncGenerator
+from typing import Callable
+from typing import Generator
+from typing import Iterable
+from typing import Sequence
+from typing import TypeVar
+from typing import cast
+
+from imbue_core.errors import ImbueError
+
+T = TypeVar("T")
+TV = TypeVar("TV")
+
+
+class ImbueItertoolsValueError(ImbueError, ValueError):
+ """This value error is thrown when the assumptions of the itertools module are violated."""
+
+
+def flatten(iterable: Iterable[Iterable[T]]) -> list[T]:
+ return list(itertools.chain.from_iterable(iterable))
+
+
+def remove_none(data: Iterable[T | None]) -> list[T]:
+ return [x for x in data if x is not None]
+
+
+def only(x: Iterable[T]) -> T:
+ try:
+ (value,) = x
+ except ValueError as e:
+ message = "Expected exactly one value"
+ if isinstance(x, Sequence):
+ with contextlib.suppress():
+ message += f" but got {len(x)} {x[:3]=}"
+ raise ImbueItertoolsValueError(message) from e
+
+ return value
+
+
+def first(iterable: Iterable[T]) -> T | None:
+ return next(iter(iterable), None)
+
+
+async def iterable_to_async(iterable: Iterable[T]) -> AsyncGenerator[T, None]:
+ for item in iterable:
+ yield item
+
+
+# TODO delete/migrate out computronium/computronium/universal/utils.py
+def generate_unique(
+ source: Iterable[T],
+ key: Callable[[T], TV] = cast(Callable[[T], TV], lambda item: item),
+) -> Generator[T, None, None]:
+ unique = set()
+ for item in source:
+ value = key(item)
+ if value in unique:
+ continue
+ yield item
+ unique.add(value)
+
+
+def generate_flattened(iterable: Iterable[Iterable[T]]) -> Generator[T, None, None]:
+ for item in iterable:
+ yield from item
+
+
+# TODO replace with itertools.batched when we can require Python 3.12+
+def generate_chunks(iterable: Iterable[T], chunk_size: int) -> Generator[tuple[T, ...], None, None]:
+ """Yield successive n-sized chunks from any iterable"""
+ chunk = []
+ for item in iterable:
+ chunk.append(item)
+ if len(chunk) == chunk_size:
+ yield tuple(chunk)
+ chunk = []
+ if len(chunk) > 0:
+ yield tuple(chunk)
diff --git a/imbue_core/imbue_core/language_model_mode.py b/imbue_core/imbue_core/language_model_mode.py
@@ -0,0 +1,8 @@
+from enum import StrEnum
+
+
+class LanguageModelMode(StrEnum):
+ LIVE = "LIVE"
+ UPDATE_SNAPSHOT = "UPDATE_SNAPSHOT"
+ OFFLINE = "OFFLINE"
+ MOCKED = "MOCKED"
diff --git a/imbue_core/imbue_core/llm_testing_utils.py b/imbue_core/imbue_core/llm_testing_utils.py
@@ -0,0 +1,57 @@
+from pathlib import Path
+
+from loguru import logger
+from syrupy.assertion import SnapshotAssertion
+
+from imbue_core.caching import AsyncCache
+from imbue_core.cattrs_serialization import deserialize_from_json
+from imbue_core.cattrs_serialization import serialize_to_json
+
+
+async def preload_llm_cache(persistent_cache_path: Path, temp_cache: AsyncCache) -> None:
+ logger.info(
+ "Loading existing cache from {persistent_cache_path}",
+ persistent_cache_path=persistent_cache_path,
+ )
+ assert persistent_cache_path.exists(), f"Cache file {persistent_cache_path} does not exist."
+ existing_data = deserialize_from_json(persistent_cache_path.read_text())
+ async with temp_cache as cache:
+ for key, value in existing_data.items():
+ await cache.set(key, value)
+
+
+async def record_llm_responses_in_cache(temp_cache: AsyncCache, persistent_cache_path: Path) -> None:
+ logger.info(
+ "Updating cache (!!!) at {persistent_cache_path}",
+ persistent_cache_path=persistent_cache_path,
+ )
+ async with temp_cache as cache:
+ all_keys = await cache.get_all_keys()
+ data = await cache.get_all(all_keys)
+ if data:
+ persistent_cache_path.parent.mkdir(parents=True, exist_ok=True)
+ persistent_cache_path.write_text(serialize_to_json(data), encoding="utf-8")
+
+
+def _sanitize_snapshot_name(snapshot_name: str) -> str:
+ return snapshot_name.replace("/", "").replace("\\", "")
+
+
+def get_cache_file_from_snapshot_core(snapshot: SnapshotAssertion, suffix: str) -> Path:
+ # To prevent syrupy from cleaning the cache up immediately after written, we use this suffix and register it with pytest.
+ # Make sure to add a line like `--snapshot-ignore-file-extensions={suffix}` to the project's pytest.ini
+
+ # Goal here is a cache file per test, not per test-file.
+ test_file = Path(snapshot.test_location.filepath)
+ snapshot_dir = test_file.parent / "__snapshots__" / test_file.stem
+ snapshot_dir.mkdir(parents=True, exist_ok=True)
+ cache_file = snapshot_dir / f"{_sanitize_snapshot_name(snapshot.test_location.testname)}.{suffix}"
+ return cache_file
+
+
+def get_cache_file_from_snapshot(snapshot: SnapshotAssertion) -> Path:
+ return get_cache_file_from_snapshot_core(snapshot, "llm_cache_json")
+
+
+def get_count_tokens_cache_file_from_snapshot(snapshot: SnapshotAssertion) -> Path:
+ return get_cache_file_from_snapshot_core(snapshot, "count_tokens_cache_json")
diff --git a/imbue_core/imbue_core/log_utils.py b/imbue_core/imbue_core/log_utils.py
@@ -0,0 +1,146 @@
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+from typing import Any
+from typing import Callable
+from typing import Mapping
+from typing import TYPE_CHECKING
+
+import loguru
+
+from imbue_core.constants import ExceptionPriority
+from imbue_core.constants import HIGH_PRIORITY_LEVEL
+from imbue_core.constants import LOW_PRIORITY_LEVEL
+from imbue_core.constants import MEDIUM_PRIORITY_LEVEL
+
+if TYPE_CHECKING:
+ loguru_record = loguru.Record
+else:
+ loguru_record = dict[str, Any]
+FilterDict = dict[str | None, str | int | bool]
+FilterFunction = Callable[[loguru_record], bool]
+LOCATION_WIDTH = 60
+TRACE = "TRACE"
+
+# between DEBUG and INFO: https://loguru.readthedocs.io/en/stable/api/logger.html
+DETAIL = "DETAIL"
+DETAIL_LEVEL = 15
+
+# the first 4 chars are used for "tsk_" and the next ~7 bytes are used for the timestamp
+# thus we need at least a few extra characters to make sure we don't troll ourselves when two tasks are created
+# very close in time
+TASK_ID_MESSAGE_WIDTH = 16
+
+LOG_LEVEL_NO_COLOR_TUPLES = [
+ (DETAIL, DETAIL_LEVEL, "<fg 128,128,128>"),
+ (ExceptionPriority.LOW_PRIORITY.value, LOW_PRIORITY_LEVEL, "<yellow>"),
+ (ExceptionPriority.MEDIUM_PRIORITY.value, MEDIUM_PRIORITY_LEVEL, "<fg 255,127,0>"),
+ (ExceptionPriority.HIGH_PRIORITY.value, HIGH_PRIORITY_LEVEL, "<red>"),
+]
+
+
+def fix_full_location(record: "loguru.Record") -> str:
+ """
+ One goal of this function is to format the location in an IDE-friendly way,
+ so that control-clicking on the logged location opens the correct file
+ and puts the cursor at the correct line.
+
+ `record` looks like this:
+ ```
+ {
+ "elapsed": datetime.timedelta(seconds=5, microseconds=152312),
+ "exception": None,
+ "extra": {
+ "machine": "32de5bcafaa8",
+ "user": "user",
+ "agent_type": None,
+ "agent_id": None,
+ "parent_id": None,
+ "async_task_id": None,
+ "formatted_task_id": "",
+ "formatted_agent_id": "",
+ "sandbox_id": None,
+ "formatted_sandbox_id": "",
+ },
+ "file": (
+ name="error_dump_utils.py",
+ path="/thad/dropbox/Thad Hughes/src/generally_intelligent/computronium/computronium/common/error_dump_utils.py",
+ ),
+ "function": "write_exception",
+ "level": (name="INFO", no=20, icon="ℹ️"),
+ "line": 214,
+ "message": "Full traceback for is available at http://node-004.snake-blues.ts.net:7777/exceptions/thad__notebook_2025_01_28_legolas/1000__2025_01_28_17_47_19_170102__P000_W0000/C0000__user_100.110.58.95_5045/2025_03_06_09_59_10_188008_KeyboardInterrupt_traceback.txt",
+ "module": "error_dump_utils",
+ "name": "computronium.common.error_dump_utils",
+ "process": (id=3099015, name="MainProcess"),
+ "thread": (id=140434013706048, name="MainThread"),
+ "time": datetime(
+ 2025, 3, 6, 9, 59, 10, 920101, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=57600), "PST")
+ ),
+ }
+ ```
+ """
+ log_path = Path(record["file"].path)
+ try:
+ cwd = Path.cwd()
+ except FileNotFoundError:
+ cwd = Path("/")
+
+ if log_path.is_relative_to(cwd):
+ log_path = log_path.relative_to(cwd)
+ location: str = record["extra"].get("full_location", f"{str(log_path)}:{record['line']}:{record['function']}")
+ while len(location) > LOCATION_WIDTH and "/" in location:
+ location = location[location.find("/") + 1 :]
+ return location[-LOCATION_WIDTH:].rjust(LOCATION_WIDTH)
+
+
+def format_task_id(async_task_id: str) -> str:
+ return async_task_id[:TASK_ID_MESSAGE_WIDTH].rjust(TASK_ID_MESSAGE_WIDTH)
+
+
+def patch_log_context_in_place(record: "loguru.Record", format_task_id: Callable[[str], str] = format_task_id) -> None:
+ record["extra"]["full_location"] = fix_full_location(record)
+
+ async_task_id = None
+ try:
+ # get the task id
+ current_task = asyncio.current_task()
+ if current_task is not None:
+ async_task_id = current_task.get_name()
+ except RuntimeError:
+ # we're not in an asyncio event loop
+ pass
+
+ if async_task_id:
+ formatted_task_id = format_task_id(async_task_id)
+ record["extra"]["formatted_task_id"] = f" [{formatted_task_id}]"
+ record["extra"]["async_task_id"] = async_task_id
+
+
+# TODO: Consider moving all levels from computronium log_utils.py _ensure_levels_configured here
+def ensure_core_log_levels_configured(
+ additional_log_levels: Mapping[str, int] | None = None,
+) -> None:
+ from loguru import logger
+
+ logger.trace("configuring detail and ExceptionPriority log levels")
+ for level, no, color in LOG_LEVEL_NO_COLOR_TUPLES:
+ try:
+ logger.level(level, no=no, color=color)
+ except (TypeError, ValueError) as e:
+ is_level_already_set_thus_ok = "already exists, you can't update its severity no" in str(e)
+ if not is_level_already_set_thus_ok:
+ raise
+
+ if additional_log_levels is None:
+ return
+
+ logger.trace("configuring additional log levels {}", additional_log_levels)
+ for level_name, level_no in additional_log_levels.items():
+ try:
+ logger.level(level_name, no=level_no)
+ except (TypeError, ValueError) as e:
+ is_level_already_set_thus_ok = "already exists, you can't update its severity no" in str(e)
+ if not is_level_already_set_thus_ok:
+ raise
diff --git a/imbue_core/imbue_core/nested_evolver.py b/imbue_core/imbue_core/nested_evolver.py
@@ -0,0 +1,251 @@
+"""Evolver uses duck-typing to give the appearance of editing a frozen, nested structure of attrs classes and tuples, recording changes in a way that they can be applied to generate a newly frozen instance.
+
+One of the design goals is that mypy, autocomplete, and automatic refactoring work for the assignments made into these nested structures.
+
+See: https://imbue-ai.slack.com/archives/C05D0SM2RT5/p1726185313480779?thread_ts=1722865932.537289&cid=C05D0SM2RT5
+
+If you make changes here and then the tests fail with:
+```
+E RecursionError: maximum recursion depth exceeded
+!!! Recursion detected (same locals & position)
+```
+It's possible that you're accidentally invoking `Evolver.something_undefined` and that's causing the infinite recursion.
+Mypy cannot catch this when it thinks the type is an `Evolver` because the `Evolver` class has a `__getattr__` method that makes it look like any attribute access could be valid.
+"""
+
+import threading
+from typing import Any
+from typing import Callable
+from typing import Generic
+from typing import TypeGuard
+from typing import TypeVar
+from typing import cast
+
+import attr
+from pydantic import BaseModel
+
+from imbue_core.frozen_utils import FrozenDict
+from imbue_core.pydantic_utils import model_update
+
+_T = TypeVar("_T")
+ObjectType = TypeVar("ObjectType")
+
+
+def evolver(obj: _T) -> _T:
+ """Creates a wrapper around an immutable attrs object, tuple, or FrozenDict that records potentially nested attribute assignments.
+
+ The return type is our first white lie to the type system.
+ """
+ result = _Evolver[_T](obj)
+ # The cast is a little white lie to the type system to make type-checking, autocomplete, and refactoring work.
+ return cast(_T, result)
+
+
+def assign(dest: _T, src: Callable[[], _T]) -> None:
+ """Since mypy would complain about assignments to frozen attrs fields, use this function to make assignments.
+
+ The only reason src is a `Callable[[], _T]` instead of just `_T` is that it makes type checking signal attempts to assign
+ the wrong type to the field. Surprisingly, just using `(dest: _T, src: _T)` doesn't cause mypy to complain about type mismatch.
+ """
+ assert isinstance(dest, _Evolver) # Tricked you, type system!
+ dest_evolver: _Evolver[_T] = cast(_Evolver[_T], dest)
+ dest_evolver.assign(src())
+
+
+def chill(evolver: _T) -> _T:
+ """Produces a new frozen instance with the recorded changes applied.
+
+ The name `chill` is a play on the fact that original input was frozen, and we are now re-freezing it.
+ """
+ assert isinstance(evolver, _Evolver) # Tricked you, type system!
+ cast_evolver = cast(_Evolver[_T], evolver)
+ return cast_evolver.chill()
+
+
+_threading_local = threading.local()
+
+
+# TODO: since mutate and thaw are stateful, if you call one without the other, you run into problems.
+def thaw(obj: _T) -> _T:
+ global _threading_local
+ if hasattr(_threading_local, "evolved_obj"):
+ raise ValueError("Thaw does not support nested thawing.")
+ # pyre-ignore[16]: we're deliberately setting evolved_obj for the first time here
+ _threading_local.evolved_obj = evolver(obj)
+ return _threading_local.evolved_obj
+
+
+# TODO: mypy complains because the input isn't anything related to ObjectType, but the output is.
+# This also means the type checking doesn't quite work since it can't infer the return type of this function correctly
+def mutate(dest: _T, src: Callable[[], _T]) -> ObjectType: # type: ignore
+ assign(dest, src)
+ try:
+ # pyre-ignore[34]: we don't have generic functions yet, so pyre complains that ObjectType isn't in the input
+ evolved_obj: ObjectType = _threading_local.evolved_obj # pyre-ignore[16]: pyre doesn't know about evolved_obj
+ return chill(evolved_obj)
+ except AttributeError as e:
+ raise ValueError("You must call mutate on a thawed object") from e
+ finally:
+ delattr(_threading_local, "evolved_obj")
+
+
+def mutate_from_dict(dest: ObjectType, src: dict[str, Any]) -> ObjectType:
+ # Warning: using this function doesn't provide mypy type checking at the call site, but it allows a single interface for attrs and pydantic classes
+ # In most cases the above function should be used instead
+ evolved_obj = evolver(dest)
+ for key, value in src.items():
+ assign(getattr(evolved_obj, key), lambda: value)
+ return chill(evolved_obj)
+
+
+def evolver_isinstance(evolver: Any, cls: type[_T]) -> TypeGuard[_T]:
+ assert isinstance(evolver, _Evolver) # Tricked you, type system!
+ return evolver.isinstance(cls)
+
+
+class _RegularValue:
+ regular_value: Any
+
+ def __init__(self, value: Any) -> None:
+ self.regular_value = value
+
+
+class _AttrValue:
+ attr_value: Any
+ child_evolver_by_name: dict[str, "_Evolver[Any]"]
+
+ def __init__(self, value: Any) -> None:
+ self.attr_value = value
+ self.child_evolver_by_name = {}
+
+
+class _PydanticModelValue:
+ pydantic_model_value: Any
+ child_evolver_by_name: dict[str, "_Evolver[Any]"]
+
+ def __init__(self, value: Any) -> None:
+ self.pydantic_model_value = value
+ self.child_evolver_by_name = {}
+
+
+class _TupleValue:
+ tuple_evolvers: list["_Evolver[Any]"]
+
+ def __init__(self, value: tuple[Any, ...]) -> None:
+ # It may be premature to create evolvers for all the elements of the tuple, but it's easier.
+ self.tuple_evolvers = [evolver(item) for item in value]
+
+
+class _FrozenDictValue:
+ frozen_dict_evolvers: dict[Any, "_Evolver[Any]"]
+
+ def __init__(self, value: dict[Any, Any]) -> None:
+ # It may be premature to create evolvers for all the elements of dict, but it's easier.
+ self.frozen_dict_evolvers = {k: evolver(v) for k, v in value.items()}
+
+
+class _Evolver(Generic[_T]):
+ # pyre-ignore[13]: pyre is confused by the trickery here
+ _value: _RegularValue | _AttrValue | _TupleValue | _FrozenDictValue | _PydanticModelValue
+
+ def __init__(self, initial_value: _T) -> None:
+ super().__init__()
+ self.assign(initial_value)
+
+ def assign(self, new_value: _T) -> None:
+ """Assign a new value to this Evolver, recording a change to the frozen structure to be later applied during `chill()`."""
+
+ if attr.has(type(new_value)):
+ self._value = _AttrValue(new_value)
+ elif isinstance(new_value, BaseModel):
+ self._value = _PydanticModelValue(new_value)
+ elif isinstance(new_value, tuple):
+ self._value = _TupleValue(new_value)
+ elif isinstance(new_value, FrozenDict):
+ self._value = _FrozenDictValue(new_value)
+ else:
+ self._value = _RegularValue(new_value)
+
+ def __getattr__(self, item: str) -> "_Evolver[Any]":
+ """Access Evolvers for nested members of a frozen attrs object."""
+ try:
+ value = self._value
+ if isinstance(value, _AttrValue):
+ if item not in value.child_evolver_by_name:
+ child_obj = getattr(value.attr_value, item)
+ result = evolver(child_obj)
+ assert isinstance(result, _Evolver), "Expose a lie to the type system."
+ value.child_evolver_by_name[item] = result
+ return value.child_evolver_by_name[item]
+ elif isinstance(value, _PydanticModelValue):
+ if item not in value.child_evolver_by_name:
+ child_obj = getattr(value.pydantic_model_value, item)
+ result = evolver(child_obj)
+ assert isinstance(result, _Evolver), "Expose a lie to the type system."
+ value.child_evolver_by_name[item] = result
+ return value.child_evolver_by_name[item]
+ raise TypeError(
+ f"You're trying to access field {item=} on an object of {type(value)=} that doesn't have that field (should have been a mypy error)."
+ )
+ except BaseException as e:
+ if hasattr(_threading_local, "evolved_obj"):
+ # pyre-ignore[16]: pyre is suspicious of the trickery here
+ if getattr(_threading_local, "evolved_obj") == self:
+ delattr(_threading_local, "evolved_obj")
+ raise e
+
+ # TODO: It wouldn't be terribly difficult to support "appending" to the tuple as well, by appending to this list.
+ def __getitem__(self, key: Any) -> "_Evolver[Any]":
+ """Access Evolvers for the elements of a tuple or dict."""
+ value = self._value
+ if isinstance(value, _TupleValue):
+ assert isinstance(key, int)
+ return value.tuple_evolvers[key]
+ elif isinstance(value, _FrozenDictValue):
+ if key not in value.frozen_dict_evolvers:
+ # Presumably we're going to evolver_assign to this very soon.
+ cast(_FrozenDictValue, self._value).frozen_dict_evolvers[key] = _Evolver(_RegularValue(None))
+ return cast(_FrozenDictValue, self._value).frozen_dict_evolvers[key]
+ raise TypeError(
+ f"You're using [square_brackets] access {key=} on an object of {type(self._value)=} that doesn't support this (should have been a mypy error)."
+ )
+
+ def chill(self) -> _T:
+ """Recursively apply the recorded changes to the original object and return a new frozen instance."""
+ if isinstance(self._value, _AttrValue):
+ new_children: dict[str, Any] = {
+ name: chill(child) for name, child in self._value.child_evolver_by_name.items()
+ }
+ assert attr.has(self._value.attr_value.__class__)
+ return cast(
+ _T,
+ attr.evolve(cast(Any, cast(_AttrValue, self._value).attr_value), **new_children),
+ )
+ elif isinstance(self._value, _PydanticModelValue):
+ return cast(
+ _T,
+ model_update(
+ self._value.pydantic_model_value,
+ update={name: chill(child) for name, child in self._value.child_evolver_by_name.items()},
+ ),
+ )
+ elif isinstance(self._value, _TupleValue):
+ return cast(_T, tuple(evolver.chill() for evolver in self._value.tuple_evolvers))
+ elif isinstance(self._value, _RegularValue):
+ return cast(_T, self._value.regular_value)
+ elif isinstance(self._value, _FrozenDictValue):
+ return cast(
+ _T,
+ FrozenDict({k: v.chill() for k, v in self._value.frozen_dict_evolvers.items()}),
+ )
+ raise ValueError(f"This Evolver has no value to evolve, {type(self._value)=}.")
+
+ def isinstance(self, cls: type[_T]) -> bool:
+ """Check if the object being evolved is an instance of the given class."""
+ if isinstance(self._value, _AttrValue):
+ return isinstance(self._value.attr_value, cls)
+ elif isinstance(self._value, _PydanticModelValue):
+ return isinstance(self._value.pydantic_model_value, cls)
+ elif isinstance(self._value, _FrozenDictValue):
+ return cls == FrozenDict
+ return False
diff --git a/imbue_core/imbue_core/py.typed b/imbue_core/imbue_core/py.typed
diff --git a/imbue_core/imbue_core/pydantic_serialization.py b/imbue_core/imbue_core/pydantic_serialization.py
@@ -0,0 +1,175 @@
+import threading
+from typing import Any
+from typing import TypeVar
+from typing import cast
+from typing import get_args
+
+from pydantic import BaseModel
+from pydantic import ConfigDict
+from pydantic import Discriminator
+from pydantic import GetCoreSchemaHandler
+from pydantic import Json
+from pydantic.alias_generators import to_camel
+from pydantic_core import core_schema as pyd_core_schema
+
+from imbue_core.frozen_utils import FrozenDict
+from imbue_core.nested_evolver import _Evolver
+from imbue_core.nested_evolver import chill
+from imbue_core.nested_evolver import evolver
+from imbue_core.serialization_types import Serializable
+
+T = TypeVar("T", bound=BaseModel)
+V = TypeVar("V")
+
+_threading_local = threading.local()
+
+
+class EvolvableModel:
+ # pyre-ignore[47]: pyre is not so easily tricked
+ def evolve(self: T, attribute: V, new_value: V) -> T:
+ # pyre-ignore[16]: pyre doesn't know about evolved_obj
+ assert _threading_local.evolved_obj is not None, ".ref() must be called before evolve"
+
+ assert isinstance(attribute, _Evolver) # Tricked you, type system!
+ dest_evolver: _Evolver[T] = cast(_Evolver[T], attribute)
+ dest_evolver.assign(new_value)
+
+ result = chill(_threading_local.evolved_obj)
+ _threading_local.evolved_obj = None
+ return result
+
+ # pyre-ignore[47]: pyre is not so easily tricked
+ def ref(self: T) -> T:
+ # pyre-ignore[16]: pyre doesn't know about evolved_obj
+ _threading_local.evolved_obj = evolver(self)
+ return _threading_local.evolved_obj
+
+
+class FrozenModel(EvolvableModel, BaseModel):
+ """
+ The base class for most internal data (that does not need to be serialized).
+
+ We generally prefer to keep data immutable in order to avoid side effects, race conditions, etc
+ """
+
+ model_config = ConfigDict(
+ frozen=True,
+ extra="forbid",
+ arbitrary_types_allowed=False,
+ )
+
+
+class MutableModel(BaseModel):
+ """
+ The base class for any internal data that strictly must be mutable. Should be used sparingly.
+ """
+
+ model_config = ConfigDict(
+ frozen=False,
+ extra="forbid",
+ # FIXME: go back to preventing arbitrary types once we're done converting
+ # arbitrary_types_allowed=False,
+ arbitrary_types_allowed=True,
+ )
+
+
+class SerializableModel(EvolvableModel, BaseModel, Serializable):
+ """
+ The base class for all data that can be serialized to/from JSON.
+ """
+
+ model_config = ConfigDict(
+ frozen=True,
+ ser_json_bytes="base64",
+ val_json_bytes="base64",
+ alias_generator=to_camel,
+ validate_by_alias=True,
+ validate_by_name=True,
+ # any extra values will end up in the __pydantic_extra__ field
+ # this is effectively required for backwards compatibility
+ # IMPORTANT: note that, by default, we clear this below! These types are ONLY for backwards compatibility
+ extra="allow",
+ # this is also effectively required for backwards compatibility
+ arbitrary_types_allowed=True,
+ )
+
+ # this is a place where we might way to do any backwards compatibility related logic
+ def model_post_init(self, __context: Any) -> None:
+ pydantic_extra = self.__pydantic_extra__
+ assert pydantic_extra is not None
+ pydantic_extra.clear()
+
+
+def model_dump(obj: BaseModel, is_camel_case: bool = False) -> dict:
+ return obj.model_dump(by_alias=is_camel_case)
+
+
+def model_load(model_type: type[T], data: dict) -> T:
+ return model_type.model_validate(data)
+
+
+def model_dump_json(obj: BaseModel | Json, is_camel_case: bool = False) -> str:
+ # pyre-fixme[16]: pyre complains that obj can be pydantic.types.AnyType, which has no model_dump_json
+ return obj.model_dump_json(by_alias=is_camel_case)
+
+
+def model_load_json(model_type: type[T], data: str) -> T:
+ return model_type.model_validate_json(data)
+
+
+# this is mostly here for the default cases.
+# When you want to upgrade a model (and keep it backwards compatible), you can make a custom discriminator
+# (eg, that looks for the old type name or converts the old class names)
+def build_discriminator(
+ field_name: str = "object_type",
+ additional_types_and_string_representations: tuple[tuple[type, str], ...] = (),
+) -> Discriminator:
+ """
+ Build a discriminator function for a Pydantic model.
+
+ Args:
+ field_name (str): The name of the field to use as the discriminator.
+ additional_types_and_string_representations (Tuple[Tuple[Type, str], ...]): Register additional types to the discriminator.
+
+ Returns:
+ Callable[[T | dict], str]: A function that takes an instance of T or a dictionary and returns the value of the
+ specified field.
+ """
+
+ def discriminator(obj: T | dict) -> str:
+ for (
+ model_type,
+ string_representation,
+ ) in additional_types_and_string_representations:
+ if isinstance(obj, model_type):
+ return string_representation
+ if isinstance(obj, dict):
+ if field_name not in obj:
+ return obj[to_camel(field_name)]
+ return obj[field_name]
+ return getattr(obj, field_name)
+
+ return Discriminator(discriminator=discriminator)
+
+
+class PydanticFrozenDictAnnotation:
+ @classmethod
+ def __get_pydantic_core_schema__(
+ cls, source_type: Any, handler: GetCoreSchemaHandler
+ ) -> pyd_core_schema.CoreSchema:
+ def validate_from_dict(d: dict | FrozenDict) -> FrozenDict:
+ return FrozenDict(d)
+
+ frozendict_schema = pyd_core_schema.chain_schema(
+ [
+ # pyre-ignore[16]: pyre is confused by using dict as a type like this
+ handler.generate_schema(dict[*get_args(source_type)]),
+ pyd_core_schema.no_info_plain_validator_function(validate_from_dict),
+ pyd_core_schema.is_instance_schema(FrozenDict),
+ ]
+ )
+ return pyd_core_schema.json_or_python_schema(
+ json_schema=frozendict_schema,
+ python_schema=frozendict_schema,
+ serialization=pyd_core_schema.plain_serializer_function_ser_schema(dict),
+ )
diff --git a/imbue_core/imbue_core/pydantic_utils.py b/imbue_core/imbue_core/pydantic_utils.py
@@ -0,0 +1,39 @@
+from typing import Any
+from typing import TypeVar
+
+from pydantic import BaseModel
+
+T = TypeVar("T", bound=BaseModel)
+
+
+def model_update(model: T, update: dict[str, Any]) -> T:
+ """
+ Update a Pydantic model with a dictionary of updates.
+ Validation is performed to ensure items in the update dictionary are valid fields in the model.
+ Use the Evolver class (imbue_core/imbue_core/nested_evolver.py) for type checking
+
+ Args:
+ model (BaseModel): The original Pydantic model.
+ update (Dict[str, Any]): A dictionary of updates to apply to the model.
+
+ Returns:
+ BaseModel: A new instance of the model with the updates applied.
+
+ """
+ update_dict_fields = update.keys()
+ model_fields = set(model.__class__.model_fields)
+ extra_fields = update_dict_fields - model_fields
+ if extra_fields:
+ raise ValueError(f"Invalid fields: {extra_fields}")
+ return fields_only_model_copy(model, update=update)
+
+
+def fields_only_model_copy(model: T, update: dict[str, Any] = {}) -> T:
+ """
+ Create a copy of a Pydantic model with only the fields defined in the model.
+
+ (Specifically, do not copy cached properties.)
+
+ """
+ fields = {name: update.get(name, getattr(model, name)) for name in model.__class__.model_fields}
+ return model.__class__(**fields)
diff --git a/imbue_core/imbue_core/repo_state.py b/imbue_core/imbue_core/repo_state.py
@@ -0,0 +1,109 @@
+from enum import StrEnum
+from functools import cached_property
+from typing import Annotated
+from typing import Any
+
+from pydantic import Field
+from pydantic import Tag
+from pydantic import computed_field
+
+from imbue_core.frozen_utils import FrozenDict
+from imbue_core.pydantic_serialization import PydanticFrozenDictAnnotation
+from imbue_core.pydantic_serialization import SerializableModel
+from imbue_core.pydantic_serialization import build_discriminator
+
+ResourceURL = str
+
+
+class ConflictType(StrEnum):
+ MERGE = "MERGE"
+ REBASE = "REBASE"
+ CHERRY_PICK = "CHERRY_PICK"
+ APPLY = "APPLY"
+ REVERT = "REVERT"
+ BISECT = "BISECT"
+
+
+class RepoOperation(SerializableModel):
+ pass
+
+ @computed_field
+ @cached_property
+ def is_empty(self) -> bool:
+ """Whether this repo operation leaves the repo unchanged.
+
+ Defaults to False. But should be overridden by subclasses as appropriate.
+ """
+ return False
+
+
+class ConflictedRepoOperation(RepoOperation):
+ object_type: str = "ConflictedRepoOperation"
+
+ blob_content_by_hash: Annotated[FrozenDict[str, bytes], PydanticFrozenDictAnnotation]
+ index_content: bytes
+ modified_file_contents_by_path: Annotated[FrozenDict[str, bytes], PydanticFrozenDictAnnotation]
+ conflict_type: ConflictType
+ special_git_file_contents_by_path: Annotated[FrozenDict[str, bytes], PydanticFrozenDictAnnotation]
+
+
+class CleanRepoOperation(RepoOperation):
+ """
+ A clean repo operation is a repo operation that has no conflicts.
+
+ It is a contains the staged diff, the unstaged diff, and the combination of the previous two.
+ """
+
+ object_type: str = "CleanRepoOperation"
+ combined_diff: str
+ staged_diff: str = ""
+ unstaged_diff: str = ""
+
+ # FIXME: this is now doing validation -- should be converted to the pydantic way of doing this!
+ def model_post_init(self, __context: Any) -> None:
+ super().model_post_init(__context)
+ if self.combined_diff.strip() != "":
+ assert (
+ self.staged_diff.strip() != "" or self.unstaged_diff.strip() != ""
+ ), "combined diff is not empty, so staged and unstaged diffs must be non-empty"
+
+ @computed_field
+ @cached_property
+ def is_empty(self) -> bool:
+ return self.combined_diff.strip() == ""
+
+
+class RepoState(SerializableModel):
+ git_hash: str
+ repo_operation: (
+ Annotated[CleanRepoOperation, Tag("CleanRepoOperation")]
+ | Annotated[ConflictedRepoOperation, Tag("ConflictedRepoOperation")]
+ ) = Field(discriminator=build_discriminator())
+
+ @computed_field
+ @cached_property
+ def is_conflicted(self) -> bool:
+ return isinstance(self.repo_operation, ConflictedRepoOperation)
+
+ @computed_field
+ @cached_property
+ def has_operations(self) -> bool:
+ repo_operation = self.repo_operation
+ return isinstance(repo_operation, ConflictedRepoOperation) or repo_operation.combined_diff.strip() != ""
+
+ @computed_field
+ @cached_property
+ def type_name(self) -> str:
+ return self.__class__.__name__
+
+ def build_with_new_commit(self, git_hash: str) -> "RepoState":
+ return RepoState(git_hash=git_hash, repo_operation=self.repo_operation)
+
+
+GIT_FILE_PATH_NAMES_BY_CONFLICT_TYPE: dict[ConflictType, tuple[str, ...]] = {
+ ConflictType.MERGE: ("MERGE_HEAD", "AUTO_MERGE", "MERGE_MSG", "MERGE_MODE"),
+ ConflictType.REBASE: ("REBASE_HEAD",),
+ ConflictType.CHERRY_PICK: ("CHERRY_PICK_HEAD",),
+ ConflictType.APPLY: (),
+ ConflictType.REVERT: ("REVERT_HEAD",),
+}
diff --git a/imbue_core/imbue_core/retry_utils.py b/imbue_core/imbue_core/retry_utils.py
@@ -0,0 +1,30 @@
+from typing import Callable
+
+from loguru import logger
+from tenacity import RetryCallState
+
+
+def _log_before_sleep(retry_state: RetryCallState, log_fn: Callable[[str], None]) -> None:
+ fn_name = getattr(retry_state.fn, "__name__", "unknown")
+ sleep_time = retry_state.next_action.sleep if retry_state.next_action is not None else 0
+ outcome = retry_state.outcome
+ if outcome is not None:
+ exception = outcome.exception()
+ error_message = type(exception).__name__ + ": " + str(exception)
+ else:
+ error_message = "unknown"
+ log_fn(
+ f"Retrying {fn_name} in {sleep_time:.2f} seconds, attempt {retry_state.attempt_number} after error: {error_message}"
+ )
+
+
+def log_before_sleep(retry_state: RetryCallState) -> None:
+ _log_before_sleep(retry_state, logger.debug)
+
+
+def log_error_before_sleep(retry_state: RetryCallState) -> None:
+ _log_before_sleep(retry_state, logger.error)
+
+
+def log_trace_before_sleep(retry_state: RetryCallState) -> None:
+ _log_before_sleep(retry_state, logger.trace)
diff --git a/imbue_core/imbue_core/s3_uploader.py b/imbue_core/imbue_core/s3_uploader.py
@@ -0,0 +1,213 @@
+import threading
+import time
+import typing
+import uuid
+from concurrent.futures import ThreadPoolExecutor
+from datetime import datetime
+from datetime import timezone
+
+import boto3
+from botocore import UNSIGNED
+from botocore.config import Config
+from loguru import logger
+
+if typing.TYPE_CHECKING:
+ # type: ignore[import-not-found]: pyre on modal does't believe in mypy_boto3_s3
+ from mypy_boto3_s3 import Client
+else:
+ Client = object
+
+from pydantic import PrivateAttr
+
+from imbue_core.pydantic_serialization import FrozenModel
+
+EXTRAS_UPLOADED_FILES_KEY = "uploaded_files"
+
+PRODUCTION_UPLOADS_BUCKET = "traceback-uploads-production"
+STAGING_UPLOADS_BUCKET = "traceback-uploads-staging"
+
+DEFAULT_REGION = "us-west-2"
+MAXIMUM_QUEUED_S3_UPLOADS = (
+ 50 # rather arbitrary but better to err on the side of caution when going from the current unbounded
+)
+
+
+class _S3Uploader(FrozenModel):
+ bucket: str
+ region: str
+ maximum_concurrency: int = MAXIMUM_QUEUED_S3_UPLOADS
+
+ _s3_client: Client = PrivateAttr() # type: ignore[valid-type]
+
+ # protects access to the thread collections
+ _thread_pool: ThreadPoolExecutor = PrivateAttr()
+ _thread_limiter: threading.Semaphore = PrivateAttr()
+
+ def model_post_init(self, context) -> None:
+ # NOTE: we use an unsigned client to avoid the need to provide AWS credentials.
+ self._s3_client = boto3.client("s3", region_name=self.region, config=Config(signature_version=UNSIGNED))
+ self._thread_pool = ThreadPoolExecutor(max_workers=None, thread_name_prefix=f"s3_upload")
+ # Unfortunately, there's no safe access to the queue size of the thread pool so calculating that number precisely
+ # using a semaphore. Each queued up uploads acquires a single value and returns it only after its thread is done
+ # interacting with S3. The value of the semaphore at any given time is the number of available work slots, and
+ # it cannot go negative. The semaphore is bounded purely to track that there are no more releases than acquisitions.
+ self._thread_limiter = threading.BoundedSemaphore(self.maximum_concurrency)
+
+ def _upload_thread(self, key: str, contents: bytes) -> None:
+ try:
+ logger.debug("Uploading to s3://{}/{}", self.bucket, key)
+ # NOTE: we use put_object instead of upload_file because we don't want multipart uploads
+ # multipart uploads are not allowed for unsigned clients
+ self._s3_client.put_object(
+ Bucket=self.bucket,
+ Key=key,
+ Body=contents,
+ )
+ logger.debug("Done uploading to s3://{}/{}", self.bucket, key) # XXX remove before merge
+ except Exception as e:
+ logger.info("Failed to upload {} to S3: {}", key, e)
+ # if re-raised, who would even catch this exception?
+ finally:
+ self._thread_limiter.release()
+
+ def s3_uri_from_key(self, key: str) -> str:
+ return f"s3://{self.bucket}/{key}"
+
+ def upload_if_possible(self, key: str, contents: bytes) -> str | None:
+ """Returns the S3 URL of the upload or None if the upload is not possible"""
+ if not self._thread_limiter.acquire(timeout=0):
+ logger.debug(
+ "Skipping upload to {key}, maximum concurrent uploads in progress already (limit={limit})",
+ key=key,
+ limit=self.maximum_concurrency,
+ )
+ return None
+
+ try:
+ self._thread_pool.submit(self._upload_thread, key, contents)
+ except Exception as e:
+ # we have to release the semaphore since the thread didn't start
+ # this shouldn't ever happen but the docs for `.submit` don't promise
+ # anything
+ self._thread_limiter.release()
+ logger.debug("Failed to queue a thread for an upload to {key}: {e}", key=key, e=e)
+ return None
+
+ return self.s3_uri_from_key(key)
+
+ def wait_for_all_uploads(self, timeout: float | None, is_shutting_down: bool) -> bool:
+ """Waits for all the uploads that may still be in progress or queued.
+
+ When is_shutting_down is True, the function will block until all uploads are completed and will disable any
+ future use of the uploader.
+
+ The is_shutting_down parameter is meant to be overridden only in tests as a way to checkpoint before
+ proceeding to schedule more work.
+ """
+ deadline = None
+ if timeout is not None:
+ deadline = time.monotonic() + timeout
+
+ # tracks the number of work slots from semaphore that this function already acquired
+ # if all self.maximum_concurrency values are collected then we guarantee that no other
+ # work is queue or in progress
+ n = 0
+
+ # a fast pass to collect available tickets before we start waiting and log messages to the user.
+ while self._thread_limiter.acquire(timeout=0):
+ n += 1
+
+ while (deadline is None or time.monotonic() < deadline) and n < self.maximum_concurrency:
+ if is_shutting_down:
+ logger.info(
+ "Please stand by: waiting for remaining uploads to finish! Still uploading: {} reports",
+ self.maximum_concurrency - n,
+ )
+ timeout = None if deadline is None else deadline - time.monotonic()
+ if self._thread_limiter.acquire(timeout=timeout):
+ n += 1
+
+ all_done = n == self.maximum_concurrency
+ if is_shutting_down:
+ # block more work from getting scheduled in case we didn't gobble up all the slots
+ # this may also make the semaphore out of sync, as cancelled queued features will not
+ # release theirs
+ self._thread_pool.shutdown(wait=False, cancel_futures=True)
+ if not all_done:
+ logger.info(
+ "Failed to upload the S3 reports after timeout reached (timeout={}), {} reports still uploading",
+ timeout,
+ self.maximum_concurrency - n,
+ )
+ elif n > 0:
+ # allow further work
+ logger.debug("Letting go of {n} slots", n=n)
+ self._thread_limiter.release(n)
+
+ return all_done
+
+
+# FIXME: move the methods below to error-handling specific module and get rid of this global variable if possible
+_S3_UPLOADER: _S3Uploader | None = None
+
+
+def setup_s3_uploads(is_production: bool = False) -> None:
+ """Set up S3 upload settings."""
+ global _S3_UPLOADER
+ if _S3_UPLOADER is not None:
+ logger.debug("S3 upload settings already initialized, skipping setup")
+ return
+ if is_production:
+ bucket_name = PRODUCTION_UPLOADS_BUCKET
+ else:
+ bucket_name = STAGING_UPLOADS_BUCKET
+ _S3_UPLOADER = _S3Uploader(bucket=bucket_name, region=DEFAULT_REGION)
+
+
+def get_s3_upload_key(key_prefix: str, key_suffix: str) -> str:
+ """Get a URL for an S3 upload."""
+ key = (
+ "_".join(
+ [
+ key_prefix,
+ datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S"),
+ uuid.uuid4().hex,
+ ]
+ )
+ + key_suffix
+ )
+ return key
+
+
+def get_s3_upload_url(key: str) -> str | None:
+ """Get a URL for an S3 upload."""
+ if _S3_UPLOADER is None:
+ logger.info("S3 upload settings not initialized. Skipping upload.")
+ return None
+ return _S3_UPLOADER.s3_uri_from_key(key)
+
+
+def upload_to_s3_with_key(key: str, contents: bytes) -> str | None:
+ """Upload a file to S3 and return the S3 URL. Returns None if upload is not possible."""
+ if _S3_UPLOADER is None:
+ logger.info("S3 upload settings not initialized. Skipping upload.")
+ return None
+ return _S3_UPLOADER.upload_if_possible(key, contents)
+
+
+def upload_to_s3(key_prefix: str, key_suffix: str, contents: bytes) -> str | None:
+ """Upload a file to S3 in the background."""
+ if _S3_UPLOADER is None:
+ logger.info("S3 upload settings not initialized. Skipping upload.")
+ return None
+
+ key = get_s3_upload_key(key_prefix, key_suffix)
+ return upload_to_s3_with_key(key, contents)
+
+
+def wait_for_s3_uploads(timeout: float | None, is_shutting_down: bool) -> bool | None:
+ logger.info("Checking whether S3 uploads are still in progress!")
+ if _S3_UPLOADER is None:
+ return None
+
+ return _S3_UPLOADER.wait_for_all_uploads(timeout=timeout, is_shutting_down=is_shutting_down)
diff --git a/imbue_core/imbue_core/sculptor/__init__.py b/imbue_core/imbue_core/sculptor/__init__.py
@@ -0,0 +1,4 @@
+"""
+This package defines Sculptor types and utilities that need to be shared with other projects,
+currently imbue_cli and imbue_verify.
+"""
diff --git a/imbue_core/imbue_core/sculptor/state/chat_state.py b/imbue_core/imbue_core/sculptor/state/chat_state.py
@@ -0,0 +1,188 @@
+from enum import StrEnum
+from typing import Annotated
+from typing import Any
+from typing import Literal
+
+from pydantic import Field
+from pydantic import Tag
+
+from imbue_core.agents.data_types.ids import AgentMessageID
+from imbue_core.agents.data_types.ids import TaskID
+from imbue_core.ids import ToolUseID
+from imbue_core.imbue_cli.action import ActionOutput as ImbueCLIActionOutput
+from imbue_core.pydantic_serialization import SerializableModel
+from imbue_core.pydantic_serialization import build_discriminator
+
+# ========================
+# Chat Type Definitions
+# ========================
+
+
+class ContentBlock(SerializableModel):
+ object_type: str = Field(..., description="Type discriminator for content blocks")
+ type: str = Field(..., description="Type discriminator for content blocks")
+
+
+class TextBlock(ContentBlock):
+ object_type: str = "TextBlock"
+ type: Literal["text"] = "text"
+ text: str
+
+
+class ContextSummaryBlock(ContentBlock):
+ object_type: str = "ContextSummaryBlock"
+ type: Literal["context_summary"] = "context_summary"
+ text: str
+
+
+class ResumeResponseBlock(ContentBlock):
+ object_type: str = "ResumeResponseBlock"
+ type: Literal["resume_response"] = "resume_response"
+
+
+class ForkedToBlock(ContentBlock):
+ object_type: str = "ForkedToBlock"
+ type: Literal["forked_to"] = "forked_to"
+ forked_to_task_id: TaskID
+
+
+class ForkedFromBlock(ContentBlock):
+ object_type: str = "ForkedFromBlock"
+ type: Literal["forked_from"] = "forked_from"
+ forked_from_task_id: TaskID
+
+
+class CommandBlock(ContentBlock):
+ object_type: str = "CommandBlock"
+ type: Literal["command"] = "command"
+ command: str
+ is_automated: bool = Field(default=False, description="Whether the command is automated")
+
+
+ToolInput = dict[str, Any]
+
+
+class ToolUseBlock(ContentBlock):
+ object_type: str = "ToolUseBlock"
+ type: Literal["tool_use"] = "tool_use"
+ id: ToolUseID = Field(..., description="Unique identifier for this tool use")
+ name: str = Field(..., description="Name of the tool being used")
+ input: ToolInput = Field(default_factory=ToolInput, description="Input parameters for the tool")
+
+
+class ToolResultContent(SerializableModel):
+ """Base class for tool result content with type discriminator"""
+
+ content_type: str = Field(..., description="Type discriminator for tool result content")
+
+
+class SimpleToolContent(ToolResultContent):
+ """Generic tool content, or information to reconstruct diff tool content"""
+
+ content_type: Literal["simple"] = "simple"
+ text: str = Field(..., description="The tool output as text")
+ tool_input: ToolInput
+ tool_content: Any
+
+
+class GenericToolContent(ToolResultContent):
+ """Generic content for most tools - just a string"""
+
+ content_type: Literal["generic"] = "generic"
+ text: str = Field(..., description="The tool output as text")
+
+
+class DiffToolContent(ToolResultContent):
+ """Content for diff-producing tools (Write, Edit, MultiEdit)"""
+
+ content_type: Literal["diff"] = "diff"
+ diff: str = Field(..., description="The git diff string")
+ file_path: str = Field(..., description="The file that was modified")
+
+
+class ImbueCLIToolContent(ToolResultContent):
+ """Content for Imbue CLI MCP results that may be composed of multiple actions"""
+
+ content_type: Literal["imbue_cli"] = "imbue_cli"
+
+ action_outputs: list[ImbueCLIActionOutput] = Field(
+ ..., description="List of action outputs from the Imbue CLI tool"
+ )
+
+
+ToolResultContentType = GenericToolContent | DiffToolContent | ImbueCLIToolContent
+
+
+class ToolResultBlockSimple(ContentBlock):
+ object_type: str = "ToolResultBlockSimple"
+ type: Literal["tool_result_simple"] = "tool_result_simple"
+ tool_use_id: ToolUseID = Field(..., description="ID of the corresponding tool use")
+ tool_name: str = Field(..., description="Name of the tool that was used")
+ invocation_string: str = Field(..., description="String representation of how the tool was invoked")
+ content: SimpleToolContent | ImbueCLIToolContent = Field(..., description="Result content from the tool execution")
+ is_error: bool = Field(default=False, description="Whether the tool execution resulted in an error")
+
+
+class ToolResultBlock(ContentBlock):
+ object_type: str = "ToolResultBlock"
+ type: Literal["tool_result"] = "tool_result"
+ tool_use_id: ToolUseID = Field(..., description="ID of the corresponding tool use")
+ tool_name: str = Field(..., description="Name of the tool that was used")
+ invocation_string: str = Field(..., description="String representation of how the tool was invoked")
+ content: ToolResultContentType = Field(..., description="Result content from the tool execution")
+ is_error: bool = Field(default=False, description="Whether the tool execution resulted in an error")
+
+
+class WarningBlock(ContentBlock):
+ object_type: str = "WarningBlock"
+ type: Literal["warning"] = "warning"
+ message: str = Field(..., description="Warning message")
+ traceback: str | None = Field(..., description="Warning traceback")
+ warning_type: str | None = Field(..., description="Type of warning, i.e. name of the exception that was raised")
+
+
+class ErrorBlock(ContentBlock):
+ object_type: str = "ErrorBlock"
+ type: Literal["error"] = "error"
+ message: str = Field(..., description="Error message")
+ traceback: str = Field(..., description="Error traceback")
+ error_type: str = Field(..., description="Type of error, i.e. name of the exception that was raised")
+
+
+class FileBlock(ContentBlock):
+ object_type: str = "FileBlock"
+ type: Literal["file"] = "file"
+ source: str = Field(..., description="A file path on the users local machine.")
+
+
+ContentBlockTypes = Annotated[
+ (
+ Annotated[TextBlock, Tag("TextBlock")]
+ | Annotated[CommandBlock, Tag("CommandBlock")]
+ | Annotated[ToolUseBlock, Tag("ToolUseBlock")]
+ | Annotated[ToolResultBlock, Tag("ToolResultBlock")]
+ | Annotated[ErrorBlock, Tag("ErrorBlock")]
+ | Annotated[WarningBlock, Tag("WarningBlock")]
+ | Annotated[ContextSummaryBlock, Tag("ContextSummaryBlock")]
+ | Annotated[ResumeResponseBlock, Tag("ResumeResponseBlock")]
+ | Annotated[FileBlock, Tag("FileBlock")]
+ | Annotated[ForkedToBlock, Tag("ForkedToBlock")]
+ | Annotated[ForkedFromBlock, Tag("ForkedFromBlock")]
+ ),
+ build_discriminator(),
+]
+
+
+class ChatMessageRole(StrEnum):
+ USER = "USER"
+ ASSISTANT = "ASSISTANT"
+
+
+class ChatMessage(SerializableModel):
+ """Chat message with content blocks. A ChatMessage corresponds to a single turn in the conversation."""
+
+ role: ChatMessageRole
+ id: AgentMessageID
+ content: tuple[ContentBlockTypes, ...]
+ snapshot_id: str | None = None
+ did_snapshot_fail: bool = False
diff --git a/imbue_core/imbue_core/sculptor/state/messages.py b/imbue_core/imbue_core/sculptor/state/messages.py
@@ -0,0 +1,141 @@
+import datetime
+from enum import StrEnum
+from typing import Annotated
+from typing import Literal
+
+from pydantic import Field
+from pydantic import Tag
+
+from imbue_core.agents.data_types.ids import AgentMessageID
+from imbue_core.ids import AssistantMessageID
+from imbue_core.pydantic_serialization import SerializableModel
+from imbue_core.pydantic_serialization import build_discriminator
+from imbue_core.sculptor.state.chat_state import ContentBlockTypes
+from imbue_core.sculptor.telemetry import PosthogEventPayload
+from imbue_core.sculptor.telemetry_constants import ConsentLevel
+from imbue_core.sculptor.telemetry_utils import with_consent
+from imbue_core.sculptor.telemetry_utils import without_consent
+from imbue_core.time_utils import get_current_time
+
+
+class LLMModel(StrEnum):
+ CLAUDE_4_OPUS = "CLAUDE-4-OPUS"
+ CLAUDE_4_SONNET = "CLAUDE-4-SONNET"
+ CLAUDE_4_HAIKU = "CLAUDE-4-HAIKU"
+ GPT_5_1_CODEX = "GPT-5.1-CODEX"
+ GPT_5_1_CODEX_MINI = "GPT-5.1-CODEX-MINI"
+ GPT_5_1 = "GPT-5.1"
+ GPT_5_2 = "GPT-5.2"
+
+
+# ==================================
+# Backend Message Type Definitions
+# ==================================
+
+
+class AgentMessageSource(StrEnum):
+ """
+ Messages can come the AGENT (in-container LLM), USER (chat messages & direct interactions), SCULPTOR_SYSTEM (multifaceted sculptor app and service code) and RUNNER (the process controlling a task on the server.)
+ """
+
+ # Messages coming directly from the agent from inside the environment.
+ AGENT = "AGENT"
+
+ # Messages coming directly from a user interacting with the interface, ie chat
+ USER = "USER"
+
+ # Messages coming from sculptor-mediated actions and automations, like local sync updates or manual sync operations.
+ # If there is ambiguity, (ie, "the user _did_ click a button but we did a lot of magic in the resolution") prefer SCULPTOR_SYSTEM.
+ SCULPTOR_SYSTEM = "SCULPTOR_SYSTEM"
+
+ # Messages coming from the task runner wrapper, such as environment shutdown.
+ # conceptually a subset of SCULPTOR_SYSTEM
+ RUNNER = "RUNNER"
+
+
+class Message(SerializableModel):
+ """Base class for all messages sent to or from the agent and user."""
+
+ # used to dispatch and discover the type of message
+ object_type: str
+ # the unique ID of the message, used to track it across the system and prevent duplicates.
+ # FIXME: get rid of the explicit passing of message_id
+ message_id: AgentMessageID = Field(default_factory=AgentMessageID)
+ # the source of the message, which can be either the agent, user, or runner.
+ source: AgentMessageSource
+ # roughly when the message was created, in UTC.
+ # note that this is approximate due to clock skew -- these messages are created on different machines.
+ # you should *not* sort by this field -- instead, rely on the order in which the messages are received.
+ approximate_creation_time: datetime.datetime = Field(default_factory=get_current_time)
+
+ # if the message is ephemeral, it will be logged but not saved to the database
+ # if it is persistent, it will be logged AND saved to the database
+ @property
+ def is_ephemeral(self) -> bool:
+ raise NotImplementedError("All messages must be subclassed off of PersistentMessage or EphemeralMessage")
+
+
+class PersistentMessage(Message):
+ @property
+ def is_ephemeral(self) -> bool:
+ return False
+
+
+class PersistentUserMessage(PersistentMessage, PosthogEventPayload):
+ """
+ One of two base classes for messages sent from the user.
+ Persistent user messages are saved to the database.
+ Persistent user messages are queued in the task runner before they are sent to the agent.
+ """
+
+ # Override inherited fields with consent annotations
+ # TODO (moishe): if other classes that derive from Message also start getting logged,
+ # change the base Message class to derive from PosthogEventPayload. For now, doing
+ # that is overkill and requires lots of annotations of irrelevant classes.
+ #
+ # TODO (mjr): We should really have `PersistentHoggableMessage` and `EphemeralHoggableMessage` or something
+ object_type: str = without_consent(description="Type discriminator for user messages")
+ message_id: AgentMessageID = without_consent(
+ default_factory=AgentMessageID,
+ description="Unique identifier for the user message",
+ )
+ source: AgentMessageSource = without_consent(default=AgentMessageSource.USER)
+ approximate_creation_time: datetime.datetime = without_consent(
+ default_factory=get_current_time,
+ description="Approximate UTC timestamp when user message was created",
+ )
+
+
+class ChatInputUserMessage(PersistentUserMessage):
+ object_type: str = without_consent(default="ChatInputUserMessage")
+ text: str = with_consent(ConsentLevel.LLM_LOGS, description="User input text content")
+ model_name: LLMModel = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ default=None,
+ description="Selected LLM model for the chat request",
+ )
+ files: list[str] = with_consent(
+ ConsentLevel.LLM_LOGS,
+ default_factory=list,
+ description="List of file paths (images, PDFs, etc., stored in Electron app folder) attached to this message",
+ )
+
+
+class PersistentAgentMessage(PersistentMessage):
+ """Base class for messages sent from the agent."""
+
+ source: AgentMessageSource = AgentMessageSource.AGENT
+
+
+class ResponseBlockAgentMessage(PersistentAgentMessage):
+ object_type: str = "ResponseBlockAgentMessage"
+ role: Literal["user", "assistant", "system"]
+ assistant_message_id: AssistantMessageID
+ content: tuple[ContentBlockTypes, ...]
+
+
+ConversationMessageUnion = Annotated[
+ Annotated[ResponseBlockAgentMessage, Tag("ResponseBlockAgentMessage")]
+ | Annotated[ChatInputUserMessage, Tag("ChatInputUserMessage")],
+ build_discriminator(),
+]
diff --git a/imbue_core/imbue_core/sculptor/telemetry.py b/imbue_core/imbue_core/sculptor/telemetry.py
@@ -0,0 +1,809 @@
+"""This module exposes an interface for instrumenting telemetry in Sculptor. It's implemented in Imbue Core so that it
+may be re-used between both Sculptor and Imbue CLI.
+
+To use this module well, you MUST:
+
+* call either init_posthog or init_anonymous_posthog on application or lifetime startup.
+* Make sure to call shutdown_posthog() on application close.
+
+* emit_posthog_event() is a low-level library function to send an event to PostHog. If you are a product developer on
+ Sculptor, you should probably use fire_posthog_event instead.
+
+Similary you can call
+
+* init_sentry()
+* and MUST call flush_sentry_and_exit_program()
+
+For some reason, flush_sentry_and_exit_program() also shuts down posthog. We will figure this out.
+"""
+
+import os
+import traceback
+from collections import defaultdict
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Any
+from typing import Callable
+from typing import Generator
+from typing import Generic
+from typing import Mapping
+from typing import Optional
+from typing import Protocol
+from typing import TypeVar
+from typing import cast
+from typing import runtime_checkable
+
+import sentry_sdk
+from loguru import logger
+from posthog import Posthog
+from posthog.scopes import identify_context
+from posthog.scopes import new_context
+from pydantic import BaseModel
+from pydantic import ConfigDict
+from pydantic import Field
+from pydantic import ValidationError
+from pydantic import create_model
+from pydantic.fields import FieldInfo
+from sentry_sdk.types import Event
+from sentry_sdk.types import Hint
+
+from imbue_core.agents.data_types.ids import TaskID
+from imbue_core.async_monkey_patches import inject_exception_and_log
+from imbue_core.async_monkey_patches import log_exception
+from imbue_core.async_monkey_patches import pre_filter_exception
+from imbue_core.common import is_running_within_a_pytest_tree
+from imbue_core.constants import ExceptionPriority
+from imbue_core.pydantic_serialization import SerializableModel
+from imbue_core.pydantic_utils import model_update
+from imbue_core.sculptor.telemetry_constants import ConsentLevel
+from imbue_core.sculptor.telemetry_constants import ProductComponent
+from imbue_core.sculptor.telemetry_constants import SculptorPosthogEvent
+from imbue_core.sculptor.telemetry_constants import UserAction
+from imbue_core.sculptor.telemetry_utils import with_consent
+from imbue_core.sculptor.telemetry_utils import without_consent
+from imbue_core.sculptor.user_config import PrivacySettings
+from imbue_core.sculptor.user_config import UserConfig
+
+# This file is written into the state directory by the Sculptor server inside the task container
+# to provide it with the telemetry info for the task.
+TELEMETRY_TASK_INFO_JSON_STATE_FILE = "telemetry_task_info.json"
+
+
+class TelemetryInfo(SerializableModel):
+ """Information needed for setting up telemetry.
+
+ This data structure is generated once in the Sculptor server,
+ and gets propagated elsewhere (such as to Imbue CLI).
+ """
+
+ # Putting the User Config into this object is a smell. The UserConfig can and will change idependently of this
+ # model, and that can lead to all sorts of issues. Consider refactoring this code.
+ user_config: UserConfig
+ sculptor_version: str
+ sculptor_git_sha: str
+ sculptor_execution_instance_id: str
+ posthog_token: str
+ posthog_api_host: str
+ sentry_dsn: str
+
+
+class TelemetryProjectInfo(SerializableModel):
+ """Used to communicate project-level information tasks inside containers."""
+
+ telemetry_info: TelemetryInfo
+ project_id: str
+
+ # Does not contain a token -- that should come through the environment.
+ gitlab_mirror_repo_url: str | None
+ original_git_repo_url: str | None
+
+
+class TelemetryTaskInfo(SerializableModel):
+ """Used to communicate task-level information tasks inside containers."""
+
+ telemetry_project_info: TelemetryProjectInfo
+ task_id: TaskID
+
+
+class PosthogEventPayload(SerializableModel):
+ """A base model for PostHog events that validates the presence of
+ 'consent_level' metadata on each field.
+ """
+
+ def __init_subclass__(cls, **kwargs: Any) -> None:
+ super().__init_subclass__(**kwargs) # pyre-fixme[6]: pyre can't type check this untyped dict
+
+ # Run validation after subclass is defined
+ cls._validate_class()
+
+ @classmethod
+ def _validate_class(cls) -> None:
+ """
+ Checks that every field has a 'consent_level' in its JSON schema metadata.
+ Issues a UserWarning if the metadata is missing.
+ """
+ for field_name in cls.__annotations__.keys():
+ field_info = cls.__dict__[field_name]
+ # Check that we're using pydantic.Field
+ assert isinstance(field_info, FieldInfo), "Field {} does not extend pydantic.Field".format(field_name)
+ # Get the extra schema info, defaulting to an empty dict if it's None
+ extra_schema: dict[str, Any] = {}
+ match field_info.json_schema_extra:
+ # If it's a callable we can call it to get the FieldInfo
+ case None:
+ pass
+ case dict() as d:
+ extra_schema = d
+ case func if callable(func):
+ maybe_extra_schema = func({})
+ # TODO: func is supposed to be -> None, so the following should in theory never happen...
+ if maybe_extra_schema is not None:
+ extra_schema = cast(dict[str, Any], maybe_extra_schema)
+ case _:
+ pass
+
+ assert (
+ "consent_level" in extra_schema
+ ), """Field '{}' in '{}' is missing the
+'consent_level' metadata. Please use the decorator
+with_consent or without_consent to populate the field annotation:
+`json_schema_extra={{'consent_level': ...}}`""".format(
+ field_name, cls.__name__
+ )
+
+
+# All data models sent to PostHog MUST define consent annotations and subclass PosthogEventPayload.
+T = TypeVar("T", bound=PosthogEventPayload)
+
+
+# Potentially we could have a ratchet test to remind folks to use
+# consent decorators.
+class PosthogEventModel(SerializableModel, Generic[T]):
+ """
+ Represents a PostHog event, with each field tagged
+ with the minimum consent level required for logging.
+ """
+
+ # Always defined fields
+ name: SculptorPosthogEvent = without_consent(description="Name of event, give it meaning!")
+ component: ProductComponent = without_consent(description="App component")
+
+ # User Activity field
+ action: UserAction | None = with_consent(ConsentLevel.PRODUCT_ANALYTICS)
+
+ # Task ID - should be set if this event is associated with a task.
+ task_id: str | None = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ description="The task id if this event is task-specific",
+ )
+
+ # Payload field with consent level
+ payload: T | None = without_consent(description="PostHog Event payload Model")
+
+
+def _create_posthog_event_payload_event_data_class(
+ additional_field_definitions: Mapping[str, Any] | None = None,
+) -> type[PosthogEventPayload]:
+ """Generates a subclass of PosthogEventPayload with the type annotations and consent declarations set up.
+
+ The PosthogEventPayload type can very based on the Event, so we are going to provide a mechanism to call this for
+ any Posthog Event that needs it.
+ """
+ field_definitions = {}
+
+ additional_field_definitions = additional_field_definitions or {}
+
+ for field_name, field_info in UserConfig.model_fields.items():
+ field_type = field_info.annotation | None if field_info.annotation else None
+ field_definitions[field_name] = (field_type, field_info)
+
+ for field_name, field_tuple in additional_field_definitions.items():
+ field_definitions[field_name] = field_tuple
+
+ field_definitions["sculptor_version"] = (Optional[str], without_consent())
+
+ return create_model(
+ "TelemetryInfoEventData",
+ __base__=PosthogEventPayload,
+ **field_definitions,
+ )
+
+
+# When we don't know what kind of Payload to use, we use this, which is the bare minimum TelemetryInfo data.
+TelemetryInfoEventData: type[PosthogEventPayload] = _create_posthog_event_payload_event_data_class()
+
+
+# For every Event, we define additional fields that it might have.
+# NOTE: This will need to be refactored into a proper covariant pattern, but this works for now.
+SCULPTOR_POSTHOG_EVENT_TO_PAYLOAD_TYPE = defaultdict(
+ lambda: TelemetryInfoEventData,
+ {
+ SculptorPosthogEvent.ONBOARDING_EMAIL_CONFIRMATION: _create_posthog_event_payload_event_data_class(
+ {"did_opt_in_to_marketing": (Optional[bool], without_consent())}
+ )
+ },
+)
+
+
+def make_telemetry_event_data(telemetry_info: TelemetryInfo) -> PosthogEventPayload:
+ user_config_data = telemetry_info.user_config.model_dump()
+ return TelemetryInfoEventData(**user_config_data)
+
+
+@runtime_checkable
+class PosthogProtocol(Protocol):
+ """
+ A protocol satisfied by the Posthog client class and a stub implementation.
+ """
+
+ host: str
+
+ def capture(
+ self,
+ distinct_id=None,
+ event=None,
+ properties=None,
+ timestamp=None,
+ uuid=None,
+ groups=None,
+ send_feature_flags=False,
+ disable_geoip=None,
+ ) -> None:
+ """
+ Capture a telemetry event.
+ """
+
+ def identify(self, identifier, properties=None) -> None:
+ """
+ Identifies a user.
+ """
+
+ def alias(
+ self,
+ previous_id=None,
+ distinct_id=None,
+ context=None,
+ timestamp=None,
+ uuid=None,
+ disable_geoip=None,
+ ) -> None:
+ """
+ Links two users together, distinct_id -(maps)-> previous_id
+ """
+
+ def shutdown(self) -> None:
+ """
+ Flush all messages and cleanly shut down the client.
+ """
+
+ def capture_exception(self, exception: BaseException, distinct_id=None, properties=None) -> None:
+ """Capture an exception event."""
+
+
+# TODO: Should this inherit from PosthogProtocol?
+class StubPosthog:
+ host: str = "stub"
+
+ def capture(
+ self,
+ distinct_id=None,
+ event=None,
+ properties=None,
+ timestamp=None,
+ uuid=None,
+ groups=None,
+ send_feature_flags=False,
+ disable_geoip=None,
+ ) -> None:
+ # Do nothing for now.
+ #
+ # If we want to test calls to posthog.capture later,
+ # we can augment this method to save the arguments internally.
+ pass
+
+ def identify(self, identifier, properties=None) -> None:
+ pass
+
+ def alias(
+ self,
+ previous_id=None,
+ distinct_id=None,
+ context=None,
+ timestamp=None,
+ uuid=None,
+ disable_geoip=None,
+ ) -> None:
+ pass
+
+ def shutdown(self) -> None:
+ pass
+
+ def capture_exception(self, exception: BaseException, distinct_id=None, properties=None) -> None:
+ pass
+
+
+class PosthogUserInstance(BaseModel):
+ model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True)
+ posthog_instance: PosthogProtocol
+ is_anonymous: bool
+
+ # Every PosthogUserInstance needs to access the UserConfig, this is a function which returns that.
+ user_config_accessor: Callable[[], UserConfig]
+
+ def access_user_config(self) -> UserConfig:
+ return self.user_config_accessor()
+
+
+class AnonymousPosthogUserInstance(PosthogUserInstance):
+ """This specific case is used to store a user when they happen to be initted."""
+
+ initial_user_id: str
+
+
+# This private in-memory cached value is set or updated whenever we
+# * begin with an anonymous user (init_posthog)
+# * begin with an identified user (init_anonymous_posthog)
+# * convert an identified user into an anonymous user (identify_user)
+_POSTHOG_USER_INSTANCE: PosthogUserInstance | AnonymousPosthogUserInstance | None = None
+
+
+def is_posthog_identified() -> bool:
+ return _POSTHOG_USER_INSTANCE is not None and not _POSTHOG_USER_INSTANCE.is_anonymous
+
+
+# This used to be cached, but we wanted the user to be able to change telemetry preferences within a container.
+def _get_telemetry_task_info_if_inside_container() -> TelemetryTaskInfo | None:
+ """Mock this for testing.
+
+ It is arranged thus because `get_telemetry_task_info_if_inside_container` is imported in many places,
+ and to mock, monkeypatch would need to replace definitions at those locations.
+
+ With this, monkeypatch only needs to replace this function."""
+ telemetry_info_path = Path("/imbue_addons/state") / TELEMETRY_TASK_INFO_JSON_STATE_FILE
+ if telemetry_info_path.exists():
+ try:
+ telemetry_task_info = TelemetryTaskInfo.model_validate_json(telemetry_info_path.read_text())
+ return telemetry_task_info
+ except ValidationError as e:
+ log_exception(
+ e,
+ "Telemetry info file {telemetry_info_path} invalid, not initializing Posthog.",
+ telemetry_info_path=telemetry_info_path,
+ )
+ return None
+
+
+def get_telemetry_task_info_if_inside_container() -> TelemetryTaskInfo | None:
+ """Loads the telemetry task info from the expected location in the container.
+ This is a no-op if the file doesn't exist."""
+ return _get_telemetry_task_info_if_inside_container()
+
+
+# TODO (CAP-636): Remove upstream git repo logic once GitLab mirroring is completed.
+def get_original_git_repo_url_if_inside_container() -> str | None:
+ telemetry_task_info = get_telemetry_task_info_if_inside_container()
+ if telemetry_task_info and telemetry_task_info.telemetry_project_info.original_git_repo_url:
+ return telemetry_task_info.telemetry_project_info.original_git_repo_url
+ return None
+
+
+def init_posthog(
+ info: TelemetryInfo,
+ source: str,
+ user_config_accessor: Callable[[], UserConfig] | None = None,
+ is_anonymous: bool = False,
+) -> None:
+ """Initialize Posthog for a _known_ user.
+
+ After this function is called,
+ get_user_posthog_instance and posthog_context can be used.
+
+ This function lives here so that the Sculptor backend and Imbue CLI can initialize PostHog in the same way.
+
+ Args:
+ user_config_accessor: Primarily exists to provide the PosthogUser instance a way to get the _latest_ user config at any time.
+ """
+ global _POSTHOG_USER_INSTANCE
+ if _POSTHOG_USER_INSTANCE is not None:
+ raise RuntimeError("Posthog endpoint already initialized.")
+
+ posthog: Posthog | StubPosthog
+
+ # TODO: Try to remove this test-specific code if possible.
+ if is_running_within_a_pytest_tree():
+ posthog = StubPosthog()
+ else:
+ posthog = Posthog(
+ info.posthog_token,
+ host=info.posthog_api_host,
+ super_properties={
+ "source": source,
+ "sculptor_version": info.sculptor_version,
+ "session": {
+ # In theory we should go through the accessor here,
+ # but at init time, when the user start the first time,
+ # they should be the same.
+ "instance_id": info.user_config.instance_id,
+ "execution_instance_id": info.sculptor_execution_instance_id,
+ },
+ },
+ )
+ if not is_anonymous:
+ posthog.identify(
+ info.user_config.user_id,
+ {"email": info.user_config.user_email},
+ )
+
+ if is_anonymous:
+ _POSTHOG_USER_INSTANCE = AnonymousPosthogUserInstance(
+ posthog_instance=posthog, # pyre-fixme[6]: pyre seems confused by PosthogProtocol
+ is_anonymous=True,
+ initial_user_id=info.user_config.user_id,
+ user_config_accessor=user_config_accessor or (lambda: info.user_config),
+ )
+ else:
+ _POSTHOG_USER_INSTANCE = PosthogUserInstance(
+ posthog_instance=posthog, # pyre-fixme[6]: pyre seems confused by PosthogProtocol
+ is_anonymous=False,
+ user_config_accessor=user_config_accessor or (lambda: info.user_config),
+ )
+
+
+def identify_posthog_user(user_config_accessor: Callable[[], UserConfig]) -> None:
+ """Update the initialized PostHog instance with user identity.
+
+ At this point, the previous posthog instance should be an anonymous PostHog instance.
+ This means logs emitted prior to this call will create a new person entry and not
+ populate `person.properties.email` field.
+
+ We will use PostHog client's alias function to associate two unique ids together to
+ the same person entry.
+ """
+ global _POSTHOG_USER_INSTANCE
+ if _POSTHOG_USER_INSTANCE is None:
+ logger.error("Posthog endpoint not initialized")
+ return
+ if not _POSTHOG_USER_INSTANCE.is_anonymous:
+ logger.error("Posthog endpoint already identified with user")
+ return
+
+ if not isinstance(_POSTHOG_USER_INSTANCE, AnonymousPosthogUserInstance):
+ raise RuntimeError("Anonymous PosthogUser instance expected")
+
+ # At this point, the previous DataModel holds the anonymous instance id generated.
+ initial_user_id = _POSTHOG_USER_INSTANCE.initial_user_id
+
+ # We can preserve the previously running posthog instance.
+ posthog = _POSTHOG_USER_INSTANCE.posthog_instance
+
+ _POSTHOG_USER_INSTANCE = PosthogUserInstance(
+ posthog_instance=posthog,
+ is_anonymous=False,
+ user_config_accessor=user_config_accessor,
+ )
+
+ latest_user_config = _POSTHOG_USER_INSTANCE.access_user_config()
+
+ # Identify should only be called once per instance lifetime.
+ posthog.identify(
+ initial_user_id,
+ {"email": latest_user_config.user_email},
+ )
+ posthog.alias(previous_id=latest_user_config.user_id, distinct_id=initial_user_id)
+
+ logger.info(
+ "Associating identified user {} with current instance initial user {}",
+ latest_user_config.user_id,
+ initial_user_id,
+ )
+
+
+def get_user_posthog_instance() -> PosthogUserInstance | None:
+ """Returns the global PostHog client or None if it has not been configured"""
+ return _POSTHOG_USER_INSTANCE
+
+
+@contextmanager
+def posthog_context(
+ posthog_user_instance: PosthogUserInstance | None = None,
+) -> Generator[PosthogProtocol, None, None]:
+ """A context manager that creates a PostHog context with the appropriate user ID.
+
+ Must be called after init_posthog or with a passed in posthog_user_instance.
+
+ TODO: Can we delete this in favor of emit_posthog_event? If not, explain here when to use this.
+ TODO: Do we actually need to yield the PosthogProtocol?
+ """
+ posthog_user_instance = posthog_user_instance or _POSTHOG_USER_INSTANCE
+ assert posthog_user_instance is not None
+ with new_context():
+ # Not having a distinct ID is troublesome...
+ current_user_id = posthog_user_instance.access_user_config().user_id
+ identify_context(current_user_id)
+ try:
+ yield posthog_user_instance.posthog_instance
+ except Exception as e:
+ log_exception(e, "Error in logging to posthog")
+
+
+def is_consent_allowable(required_consent: ConsentLevel | None, privacy_settings: PrivacySettings) -> bool:
+ """Check the appropriate value of user consent fields to establish allowable consent."""
+ if required_consent is None:
+ return True
+ elif required_consent == ConsentLevel.NONE:
+ return True
+ elif required_consent == ConsentLevel.ERROR_REPORTING:
+ return privacy_settings.is_error_reporting_enabled
+ elif required_consent == ConsentLevel.PRODUCT_ANALYTICS:
+ return privacy_settings.is_product_analytics_enabled
+ elif required_consent == ConsentLevel.LLM_LOGS:
+ return privacy_settings.is_llm_logs_enabled
+ elif required_consent == ConsentLevel.SESSION_RECORDING:
+ return privacy_settings.is_session_recording_enabled
+ elif required_consent == ConsentLevel.NEVER_PERSIST:
+ return False
+ else:
+ logger.info("Unexpected consent level: {}", required_consent)
+ return False
+
+
+def filter_model_by_consent(model: SerializableModel, privacy_settings: PrivacySettings) -> SerializableModel:
+ """Recursively filter a SerializableModel based on consent toggles.
+
+ Args:
+ model: The model to filter
+ user_config: The user's configuration with consent toggles
+
+ Returns:
+ SerializableModel with None for fields that don't meet the consent requirements.
+ Tries to handle ValidationError gracefully by creating compatible models.
+ """
+ updates: dict[str, SerializableModel | list[SerializableModel] | None] = {}
+
+ for field_name, field_info in model.__class__.model_fields.items():
+ field_value = getattr(model, field_name)
+
+ # Retrieve the metadata we attached using the decorator
+ metadata = field_info.json_schema_extra or {}
+ required_level = metadata.get("consent_level")
+
+ # A field without a consent level is considered public OR
+ # Include the field if the user's consent level is sufficient
+ if is_consent_allowable(required_level, privacy_settings):
+ # If the field value is also a SerializableModel, recursively filter it
+ if isinstance(field_value, SerializableModel):
+ updates[field_name] = filter_model_by_consent(field_value, privacy_settings)
+ elif isinstance(field_value, list):
+ filtered_list = []
+ for item in field_value:
+ if isinstance(item, SerializableModel):
+ filtered_list.append(filter_model_by_consent(item, privacy_settings))
+ else:
+ filtered_list.append(item)
+ updates[field_name] = filtered_list
+ else:
+ updates[field_name] = field_value
+ else:
+ # Set field to None if consent is not allowable
+ updates[field_name] = None
+
+ try:
+ # Try the standard approach first
+ return model_update(model, updates)
+ except ValidationError:
+ # If validation fails due to non-optional fields being set to None,
+ # create a new model where those fields are Optional
+
+ field_definitions: dict[str, tuple[type[Any] | None, FieldInfo | None]] = {}
+ for field_name, field_info in model.__class__.model_fields.items():
+ if field_name in updates and updates[field_name] is None:
+ # Make this field Optional since we're setting it to None, preserving metadata
+ field_definitions[field_name] = (
+ field_info.annotation | None,
+ Field(default=None, json_schema_extra=field_info.json_schema_extra),
+ )
+ else:
+ # Keep original field definition
+ field_definitions[field_name] = (field_info.annotation, field_info)
+
+ # Create a new model class with filtered fields made Optional
+ base_class = (
+ model.__class__
+ if hasattr(model.__class__, "__bases__") and model.__class__.__bases__
+ else SerializableModel
+ )
+ filtered_model_class = create_model(
+ f"{model.__class__.__name__}Filtered",
+ __base__=base_class,
+ **field_definitions, # pyre-ignore[6]: pyre can't check this since it's an untyped dict
+ )
+ return filtered_model_class(**updates) # pyre-ignore[6]: pyre can't check this since it's an untyped dict
+
+
+def emit_posthog_event(posthog_event: PosthogEventModel[Any]) -> None:
+ """Filters properties from a Pydantic model instance based on the user's given consent level.
+
+ This can be called both from inside the imblue-cli task container, or the Sculptor backend.
+ If invoked from inside the imbue-cli task container, task_id is added to the event_data by calling
+ get_telemetry_task_info_when_inside_container().
+
+ If you are in the Sculptor backend, you should probably use fire_posthog_event instead, as that ensures you're using
+ a known event, and attaches correct context.
+ """
+ posthog_user_instance = get_user_posthog_instance()
+
+ if posthog_user_instance:
+ user_config_instance = posthog_user_instance.access_user_config()
+ privacy_settings = user_config_instance.privacy_settings
+
+ with posthog_context(posthog_user_instance):
+ if not is_consent_allowable(ConsentLevel.NONE, privacy_settings):
+ # User did not opt-into any data collection.
+ # We should not log user-identifiable PostHog events.
+ return
+ elif posthog_event.action is not None and not is_consent_allowable(
+ ConsentLevel.PRODUCT_ANALYTICS, privacy_settings
+ ):
+ # If this is a user_activity event but user has not consented
+ # to product analytics logging level, do not emit event.
+ return
+ event_name = posthog_event.name.value
+
+ # Use the recursive filtering function
+ try:
+ filtered_model = filter_model_by_consent(posthog_event, privacy_settings)
+ properties = filtered_model.model_dump()
+ except Exception as e:
+ logger.info("Failed to filter posthog event: {}", e)
+ # We could also choose to drop the entire event, or replace payload with an error message.
+ properties = posthog_event.model_dump()
+
+ # some events don't have a payload, but they should still be logged.
+ if properties.get("payload"):
+ payload = properties["payload"]
+ if not any(value is not None for value in payload.values()):
+ logger.debug("No payload data to log for event of type {}", event_name)
+ return
+
+ # Check for task-specific telemetry info and add it to the properties
+ telemetry_task_info = get_telemetry_task_info_if_inside_container()
+ if telemetry_task_info:
+ # I think we will further change where we put this.
+ properties["task_id"] = str(telemetry_task_info.task_id)
+
+ posthog_user_instance.posthog_instance.capture(event=event_name, properties=properties)
+
+
+def shutdown_posthog() -> None:
+ """Flush all messages and cleanly shut down the client."""
+ posthog_instance = get_user_posthog_instance()
+ if posthog_instance is not None:
+ posthog_instance.posthog_instance.shutdown()
+
+
+class PosthogExceptionPayload(PosthogEventPayload):
+ exception_name: str = with_consent(ConsentLevel.ERROR_REPORTING, description="The name of the raised exception.")
+ exception_value: str = with_consent(ConsentLevel.ERROR_REPORTING, description="The value of the raised exception.")
+ exception_traceback: str | None = with_consent(
+ ConsentLevel.ERROR_REPORTING,
+ description="Formatted traceback of the raised exception.",
+ )
+ message: str | None = with_consent(
+ ConsentLevel.ERROR_REPORTING,
+ description="The message that accompanies the raised exception.",
+ )
+
+
+def get_exception_payload(
+ exception: BaseException,
+ message: str | None = None,
+ include_traceback: bool = False,
+) -> PosthogExceptionPayload:
+ formatted_traceback = "".join(traceback.format_exception(type(exception), exception, exception.__traceback__))
+ return PosthogExceptionPayload(
+ exception_name=type(exception).__name__,
+ exception_value=str(exception),
+ exception_traceback=formatted_traceback if include_traceback else None,
+ message=message,
+ )
+
+
+def send_exception_to_posthog(
+ error_source: SculptorPosthogEvent,
+ exception: BaseException,
+ message: str | None = None,
+ include_traceback: bool = False,
+ component: ProductComponent = ProductComponent.CROSS_COMPONENT,
+ task_id: TaskID | None = None,
+) -> None:
+ """Sends error details to PostHog for telemetry purposes.
+
+ The idea is that for some exceptions, we don't want to send them to Sentry because we're not able to act on them anyway.
+ But we should still keep an eye on how often they happen so we send them to PostHog instead.
+ """
+
+ # TODO: do we want to include this filtering even if we're sending it to posthog rather than sentry?
+ should_skip = pre_filter_exception(exception, message)
+ if should_skip:
+ return
+
+ emit_posthog_event(
+ PosthogEventModel(
+ name=error_source,
+ component=component,
+ payload=get_exception_payload(exception, message, include_traceback),
+ task_id=str(task_id) if task_id else None,
+ )
+ )
+
+ inject_exception_and_log(exception, message or "", priority=ExceptionPriority.LOW_PRIORITY)
+
+
+def flush_sentry_and_exit_program(exit_code: int, final_message: str) -> None:
+ """Flush Sentry events and then immediately exit the program with a final message.
+
+ We enforce the final message so that the last line that the user sees is relevant to the shutdown.
+ """
+ sentry_sdk.flush()
+ shutdown_posthog()
+ logger.info(final_message)
+ os._exit(exit_code)
+
+
+def mirror_exception_to_posthog(event: Event, hint: Hint) -> Event:
+ """Helper/utility function to mirror an exception from Sentry to PostHog.
+
+ When this is wired up to the before_send hook in Sentry, it will send a correctly-shaped event to PostHog, and annotate the Sentry event with the PostHog user id.
+ """
+ # Only mirror error events
+ if event.get("level") in ("warning", "error", "fatal") and hint and hint.get("exc_info"):
+ logger.info("We are going to mirror this exception to posthog")
+ _, exc_value, _ = hint["exc_info"]
+ # Attach useful Sentry context as PostHog properties
+ props = {
+ "$exception_level": event.get("level"),
+ "sentry_event_id": event.get("event_id"),
+ "sentry_issue_id": event.get("contexts", {}).get("trace", {}).get("trace_id"),
+ "tags": event.get("tags"),
+ "release": event.get("release"),
+ "environment": event.get("environment"),
+ "log_message": event.get("logentry", {}).get("message"),
+ }
+
+ user_posthog_instance = get_user_posthog_instance()
+ if user_posthog_instance:
+ user_config = user_posthog_instance.access_user_config()
+ try:
+ user_posthog_instance.posthog_instance.capture_exception(
+ exc_value,
+ # We're relying on the fact that we are in a single-user context to always have a distinct_id.
+ distinct_id=user_config.user_id,
+ properties=props,
+ )
+
+ event.setdefault("tags", {})
+ assert "tags" in event, "Only to shut up typechecker below"
+
+ event["tags"]["posthog_exception_mirrored"] = "true"
+
+ event.setdefault("extra", {})
+ assert "extra" in event, "Only to shut up typechecker below"
+
+ event["extra"]["posthog_user_id"] = (user_config.user_id,)
+
+ posthog_app_domain = user_posthog_instance.posthog_instance.host.replace(
+ ".i.posthog.com", ".posthog.com"
+ )
+
+ event["extra"]["posthog_user_link"] = f"{posthog_app_domain}/persons/{user_config.user_id}"
+
+ except Exception as e:
+ # We don't want to trigger an infinite loop of exceptions if PostHog is down. We're sending a message to
+ # Sentry after all.
+ logger.debug("Failed to mirror exception to PostHog: {}", e)
+ finally:
+ # We must return the event in all code paths to ensure sentry continues.
+ return event
+
+ # We must return the event in all code paths to ensure sentry continues.
+ return event
diff --git a/imbue_core/imbue_core/sculptor/telemetry_constants.py b/imbue_core/imbue_core/sculptor/telemetry_constants.py
@@ -0,0 +1,216 @@
+from enum import Enum
+
+
+class ConsentLevel(Enum):
+ """Defines the hierarchy of user consent levels."""
+
+ NONE = 0
+ ERROR_REPORTING = 1 # PostHog and Sentry’s error reporting
+ PRODUCT_ANALYTICS = 2 # PostHog’s pageview and autocapture events
+ LLM_LOGS = 3 # Capability logging
+ SESSION_RECORDING = 4 # PostHog and Sentry’s session recording
+
+ NEVER_PERSIST = "never_persist"
+
+
+class ProductComponent(Enum):
+ AGENT_TASK = "agent_task"
+ CHECKS = "checks"
+ TASK = "task"
+ ONBOARDING = "onboarding"
+ STARTUP = "startup"
+ ENVIRONMENT_SETUP = "environment_setup"
+ FIX = "fix"
+ CLAUDE_CODE = "claude_code"
+ IMBUE_VERIFY = "imbue_verify"
+ IMBUE_CLI = "imbue_cli"
+ AUTH = "auth"
+ DATABASE = "database"
+ LOCAL_SYNC = "local_sync"
+ MANUAL_SYNC = "manual_sync"
+ # CROSS_COMPONENT is for logging concerns that are not local to a specific component.
+ CROSS_COMPONENT = "cross_component"
+ CONFIGURATION = "configuration"
+
+
+class UserAction(Enum):
+ CLICKED = "clicked"
+ CALLED = "called"
+ # more to be defined later
+
+
+# Adding a new event? Please see _get_posthog_token_and_api_host for information about
+# using the developer posthog instance as you build/test your event.
+class SculptorPosthogEvent(Enum):
+ """
+ DO NOT MUTATE the string values!
+
+ Mark as deprecated enums when no longer used.
+ """
+
+ # TESTING
+ TEST_EVENT = "test_event"
+
+ # ONBOARDING
+ ONBOARDING_INITIALIZATION = "onboarding_initialization"
+ ONBOARDING_CONFIGURATION_WIZARD = "onboarding_configuration_wizard"
+ ONBOARDING_EMAIL_CONFIRMATION = "onboarding_email_confirmation"
+ ONBOARDING_TELEMETRY_CONSENT = "onboarding_telemetry_consent"
+ ONBOARDING_STARTUP_CHECKS = "onboarding_startup_checks"
+ ONBOARDING_USER_CONFIG_SETTINGS = "onboarding_user_config_settings" # Deprecated, use the following one:
+ ONBOARDING_USER_CONFIG_SETTINGS_LOADED = "onboarding_user_config_settings_loaded"
+ ONBOARDING_COMPLETED = "onboarding_completed"
+
+ ONBOARDING_ANTHROPIC_API_KEY_SET = "onboarding_anthropic_api_key_set"
+ ONBOARDING_ANTHROPIC_CREDENTIALS_EXIST = (
+ "onboarding_anthropic_credentials_exist" # This only means that oauth completed.
+ )
+ ONBOARDING_ANTHROPIC_OAUTH_STARTED = "onboarding_anthropic_oauth_started"
+ ONBOARDING_ANTHROPIC_OAUTH_CANCELLED = "onboarding_anthropic_oauth_cancelled"
+ ONBOARDING_ANTHROPIC_AUTHORIZED = (
+ "onboarding_anthropic_authorized" # We've successfully authorized, whether via Oauth or API key
+ )
+ ONBOARDING_OPENAI_AUTHORIZED = "onboarding_openai_authorized"
+ ONBOARDING_DOCKER_INSTALLED = "onboarding_docker_installed"
+ ONBOARDING_DOCKER_STARTED = "onboarding_docker_started"
+ ONBOARDING_GIT_INSTALLED = "onboarding_git_installed"
+
+ # STARTUP
+ STARTUP_REMOTE_URL = "startup_remote_url"
+ DESKTOP_BACKEND_STARTED = "desktop_backend_started"
+
+ # Settings, configuration and preferences
+ USER_CONFIG_SETTINGS_EDITED = "user_config_settings_edited"
+
+ # TASK
+ TASK_PREDICT_BRANCH_NAME = "task_predict_branch_name"
+ TASK_START_MESSAGE = "task_start_message"
+ TASK_START_REQUESTED = "task_start_requested"
+ TASK_FORK_REQUESTED = "task_fork_requested"
+ TASK_RUN_TASK_STARTED = "task_run_task_started"
+ TASK_USER_MESSAGE = "task_user_message"
+ TASK_USER_COMMAND = "task_user_command"
+ TASK_USER_FEEDBACK = "task_user_feedback"
+
+ # ENVIRONMENT SETUP
+ ENVIRONMENT_SETUP_REUSED_EXISTING_ENVIRONMENT = "environment_setup_reused_existing_environment"
+ ENVIRONMENT_SETUP_FAILED_TO_REUSE_EXISTING_ENVIRONMENT = "environment_setup_failed_to_reuse_existing_environment"
+ ENVIRONMENT_SETUP_IMAGE_CREATION_STARTED = "environment_setup_image_creation_started"
+ ENVIRONMENT_SETUP_USING_EXISTING_IMAGE = "environment_setup_using_existing_image"
+ ENVIRONMENT_SETUP_IMAGE_CREATION_FINISHED = "environment_setup_image_creation_finished"
+ ENVIRONMENT_SETUP_IMAGE_ENSURED = "environment_setup_image_ensured"
+ ENVIRONMENT_SETUP_HARD_OVERWROTE_WORKSPACE = "environment_setup_hard_overwrote_workspace"
+ ENVIRONMENT_SETUP_DOCKER_CONTROL_PLANE_ALREADY_DOWNLOADED = (
+ "environment_setup_docker_control_plane_already_downloaded"
+ )
+ ENVIRONMENT_SETUP_DOCKER_CONTROL_PLANE_DOWNLOAD_FINISHED = (
+ "environment_setup_docker_control_plane_download_finished"
+ )
+ ENVIRONMENT_SETUP_WAITING_FOR_CONTROL_PLANE_SETUP = "environment_setup_waiting_for_control_plane_setup"
+ ENVIRONMENT_SETUP_DOCKER_STARTED_EXISTING_CONTAINER = "environment_setup_docker_started_existing_container"
+ ENVIRONMENT_SETUP_DOCKER_CONTAINER_CREATED = "environment_setup_docker_container_created"
+ ENVIRONMENT_SETUP_DOCKER_CONTAINER_FINISHED_SETUP = "environment_setup_docker_container_finished_setup"
+ ENVIRONMENT_SETUP_REPO_ARCHIVE_CREATED = "environment_setup_repo_archive_created"
+ ENVIRONMENT_SETUP_IMAGE_CREATED = "environment_setup_image_created"
+ ENVIRONMENT_SETUP_LOCAL_DOCKERFILE_BUILT = "environment_setup_local_dockerfile_built"
+ ENVIRONMENT_SETUP_FELL_BACK_TO_DEFAULT_DEVCONTAINER = "environment_setup_fell_back_to_default_devcontainer"
+ ENVIRONMENT_SETUP_WRAPPER_DOCKERFILE_BUILT = "environment_setup_wrapper_dockerfile_built"
+
+ # TOOL READINESS
+ TOOL_READINESS_EVENT_COMPLETED = "tool_readiness_event_completed"
+
+ # AGENT_TASK
+ AGENT_TASK_ENVIRONMENT_SETUP_FINISHED = "agent_task_environment_setup_finished"
+ AGENT_TASK_GIT_SETUP_FINALIZED = "agent_task_git_setup_finalized"
+ AGENT_TASK_RUNNING_IN_ENVIRONMENT = "agent_task_running_in_environment"
+ AGENT_TASK_RECEIVED_FIRST_TOKEN_FROM_AGENT = "agent_task_received_first_token_from_agent"
+
+ # FIX
+ FIX_ISSUE_SELECT = "fix_issue_select"
+
+ # AGENT RESPONSES
+ AGENT_INIT = "agent_init"
+ AGENT_ASSISTANT_MESSAGE = "agent_assistant_message"
+ AGENT_TOOL_RESULT = "agent_tool_result"
+ AGENT_SESSION_END = "agent_session_end"
+
+ # USER MESSAGES
+ USER_CHAT_INPUT = "user_chat_input"
+ USER_COMMAND_INPUT = "user_command_input"
+ USER_WRITE_FILE = "user_write_file"
+ USER_STOP_AGENT = "user_stop_agent"
+ USER_INTERRUPT_PROCESS = "user_interrupt_process"
+ USER_FORK_AGENT = "user_fork_agent"
+ USER_REMOVE_QUEUED_MESSAGE = "user_remove_queued_message"
+ USER_GIT_COMMIT_AND_PUSH = "user_git_commit_and_push"
+ USER_GIT_PULL = "user_git_pull"
+ USER_COMPACT_TASK_MESSAGE = "user_compact_task_message"
+ USER_CONFIGURATION_DATA = "user_configuration_data"
+ PROJECT_CONFIGURATION_DATA = "project_configuration_data"
+ COMPACTION_SUCCESS = "compaction_success"
+
+ # CHECKS
+ CHECK_STARTED = "check_started"
+ USER_STOP_CHECK_MESSAGE = "user_stop_check_message"
+ USER_RESTART_CHECK_MESSAGE = "user_restart_check_message"
+
+ # SYSTEM MESSAGES (eg Local * Manual Sync)
+ LOCAL_SYNC_SETUP_STARTED = "local_sync_setup_started"
+ LOCAL_SYNC_SETUP_AND_ENABLED = "local_sync_setup_and_enabled"
+ LOCAL_SYNC_UPDATE_PENDING = "local_sync_update_pending"
+ LOCAL_SYNC_UPDATE_COMPLETED = "local_sync_update_completed"
+ LOCAL_SYNC_UPDATE_PAUSED = "local_sync_update_paused"
+ LOCAL_SYNC_DISABLED = "local_sync_disabled"
+ MANUAL_SYNC_MERGE_INTO_USER_ATTEMPTED = "manual_sync_merge_into_user_attempted"
+ MANUAL_SYNC_MERGE_INTO_AGENT_ATTEMPTED = "manual_sync_merge_into_agent_attempted"
+ RUNNER_RESUME_USER_MESSAGE = "runner_resume_user_message"
+ WARNING_AGENT_MESSAGE = "warning_agent_message"
+
+ # This is poorly named; it refers to starting a claude -p command in the environment
+ # CLAUDE MESSAGES
+ CLAUDE_COMMAND = "claude_command"
+
+ # This is poorly named; it refers to starting a codex exec command in the environment
+ # CODEX MESSAGES
+ CODEX_COMMAND = "codex_command"
+
+ # IMBUE VERIFY
+ IMBUE_VERIFY_CALLED = "imbue_verify_called"
+ TRIMMED_IMBUE_VERIFY_CALLED = "trimmed_imbue_verify_called"
+ IMBUE_VERIFY_FAILED = "imbue_verify_failed"
+
+ # IMBUE CLI
+ IMBUE_CLI_START = "imbue_cli_start"
+ IMBUE_CLI_CHECK_INITIATED = "imbue_cli_check_initiated"
+
+ # LOGIN
+ LOGIN_INITIATED = "login_initiated"
+ LOGIN_SUCCEEDED = "login_succeeded"
+
+ # DATABASE
+ DB_WRITE = "db_write"
+
+ # RUNTIME TRACKING
+ RUNTIME_MEASUREMENT = "runtime_measurement"
+
+ # SPACE USAGE TRACKING
+ SNAPSHOT_SIZE_MEASUREMENT = "snapshot_size_measurement"
+ IMAGE_INFORMATION = "image_information"
+
+ # EXCEPTIONS
+ # NOTE: if you're adding a new call to log_error_to_posthog, you should most likely add a new value here!
+ # this is the only way that we determine where the error originated (unless you set include_traceback=True),
+ # so we don't want to reuse these without a good reason
+ IRRECOVERABLE_EXCEPTION = (
+ "irrecoverable_exception" # only use this if we have no other information on the error's source
+ )
+ SENTRY_EXCEPTION_DATA_COLLECTION_TOO_SLOW = "sentry_exception_data_collection_too_slow"
+ CLAUDE_TRANSIENT_ERROR = "claude_transient_error"
+ DATABASE_LOCK_ACQUISITION_TIMEOUT = "database_lock_acquisition_timeout"
+ INCOMPATIBLE_DATABASE_LIKELY_FROM_DOWNGRADE = "incompatible_database_likely_from_downgrade"
+ FAILED_TO_PARSE_LLM_RESPONSE_WHEN_GENERATING_ISSUES = "failed_to_parse_llm_response_when_generating_issues"
+ INVALID_FILE_PATH_FROM_LLM_IN_ISSUE_LOCATION = "invalid_file_path_from_llm_in_issue_location"
+ TASK_FAILED_WITH_EXPECTED_ERROR = "task_failed_with_expected_error"
+ AGENT_RUNNER_FAILED_BECAUSE_DOCKER_IS_DOWN = "agent_runner_failed_because_docker_is_down"
+ FAILED_TO_SNAPSHOT_IMAGE_DURING_SHUTDOWN = "failed_to_snapshot_image_during_shutdown"
+ THREAD_IRRECOVERABLE_EXCEPTION = "thread_irrecoverable_exception"
diff --git a/imbue_core/imbue_core/sculptor/telemetry_utils.py b/imbue_core/imbue_core/sculptor/telemetry_utils.py
@@ -0,0 +1,56 @@
+from typing import Any
+
+from pydantic import Field
+
+from imbue_core.sculptor.telemetry_constants import ConsentLevel
+
+
+def _with_consent_level(
+ level: ConsentLevel,
+ default_factory: Any | None = None,
+ default: Any | None = None,
+ **kwargs: Any,
+) -> Any:
+ """A Pydantic Field factory to annotate a field with a consent level.
+ It attaches the level as metadata within the field's JSON schema extras.
+ """
+ if default_factory is not None:
+ assert default is None, "Cannot specify both default and default_factory"
+ # pyre-fixme[6]: pyre is confused by the dict literal here, especially since level is not a JsonValue
+ return Field(
+ default_factory=default_factory,
+ json_schema_extra={"consent_level": level},
+ **kwargs,
+ )
+
+ # pyre-fixme[6]: pyre is confused by the dict literal here, especially since level is not a JsonValue
+ return Field(default, json_schema_extra={"consent_level": level}, **kwargs)
+
+
+def with_consent(
+ level: ConsentLevel,
+ default_factory: Any | None = None,
+ default: Any | None = None,
+ **kwargs: Any,
+) -> Any:
+ """A Pydantic Field factory to annotate a field with a consent level.
+ It attaches the level as metadata within the field's JSON schema extras.
+ """
+ return _with_consent_level(level, default_factory=default_factory, default=default, **kwargs)
+
+
+def without_consent(default: Any | None = None, default_factory: Any | None = None, **kwargs: Any) -> Any:
+ """A Pydantic Field factory to annotate a field without a consent level."""
+ return _with_consent_level(ConsentLevel.NONE, default_factory=default_factory, default=default, **kwargs)
+
+
+def never_log(default: Any | None = None, default_factory: Any | None = None, **kwargs: Any) -> Any:
+ """A Pydantic Field factory to annotate a field that should never be logged.
+ This is used for in-memory or temporary data that should not be stored long-term.
+ """
+ return _with_consent_level(
+ ConsentLevel.NEVER_PERSIST,
+ default_factory=default_factory,
+ default=default,
+ **kwargs,
+ )
diff --git a/imbue_core/imbue_core/sculptor/user_config.py b/imbue_core/imbue_core/sculptor/user_config.py
@@ -0,0 +1,252 @@
+import sys
+from enum import StrEnum
+from typing import Any
+
+from pydantic import Field
+from pydantic.alias_generators import to_camel
+
+from imbue_core.pydantic_serialization import SerializableModel
+from imbue_core.sculptor.telemetry_constants import ConsentLevel
+from imbue_core.sculptor.telemetry_utils import never_log
+from imbue_core.sculptor.telemetry_utils import with_consent
+from imbue_core.sculptor.telemetry_utils import without_consent
+
+_DEFAULT_MODIFIER_KEY = "Cmd" if sys.platform == "darwin" else "Ctrl"
+
+
+class UpdateChannel(StrEnum):
+ """Update channel for receiving Sculptor updates."""
+
+ STABLE = "STABLE"
+ ALPHA = "ALPHA"
+
+
+class PrivacySettings(SerializableModel):
+ """This model contains a subset of the the privacy fields that we support."""
+
+ is_error_reporting_enabled: bool = Field(False, description="Whether to enable error reporting, i.e. Sentry")
+ is_product_analytics_enabled: bool = Field(
+ False, description="Whether to enable product analytics, e.g. through PostHog"
+ )
+ is_llm_logs_enabled: bool = Field(False, description="Whether to enable LLM logs spooling to our systems")
+ is_session_recording_enabled: bool = Field(False, description="Whether to enable session recording")
+ is_repo_backup_enabled: bool = Field(False, description="Whether to enable repo backup")
+ is_full_contribution: bool = Field(
+ False,
+ description="Synthetic field to let us know if the user has selected full contribution. This includes 'full LLM logs, including code' to train our agent.",
+ )
+ telemetry_consent_level: str = Field("", description="Telemetry level description")
+
+
+class UserConfig(SerializableModel):
+ """Most configuration for user and for Sculptor app behavior should go here.
+
+ All required fields must be provided or validation will fail.
+
+ When you add a new field, you should add it as a field with a default value so that it is backwards compatible.
+ """
+
+ user_email: str = without_consent(..., description="User email address")
+ user_full_name: str | None = without_consent(None, description="Full name of the user")
+ user_git_username: str = without_consent(..., description="Git User name")
+ user_id: str = without_consent(..., description="User ID")
+ anonymous_access_token: str = never_log(
+ ..., description="Unique and local anonymous access token for imbue_gateway"
+ )
+ organization_id: str = without_consent(..., description="Organization ID")
+ instance_id: str = without_consent(..., description="Instance ID")
+ is_error_reporting_enabled: bool = without_consent(False, description="Whether to enable error reporting")
+ is_product_analytics_enabled: bool = without_consent(False, description="Whether to enable product analytics")
+ is_llm_logs_enabled: bool = without_consent(False, description="Whether to enable LLM logs")
+ is_session_recording_enabled: bool = without_consent(False, description="Whether to enable session recording")
+ is_repo_backup_enabled: bool = without_consent(False, description="Whether to enable repo backup")
+ is_full_contribution: bool = without_consent(
+ False,
+ description="Synthetic field to let us know if the user has selected full contribution. This includes 'full LLM logs, including code' to train our agent.",
+ )
+ telemetry_consent_level: str = without_consent("", description="Telemetry level description")
+ # For now, we give users the option to opt-out of syncing their Claude settings with Sculptor.
+ is_claude_configuration_synchronized: bool = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ default=True,
+ description="Whether user's local Claude Code configuration is synchronized with Sculptor.",
+ )
+ anthropic_api_key: str | None = never_log(None, description="Anthropic API key")
+ openai_api_key: str | None = never_log(None, description="OpenAI API key")
+ gemini_api_key: str | None = never_log(None, description="Gemini API key")
+ is_privacy_policy_consented: bool = without_consent(
+ False, description="Whether the user consented to our privacy policy"
+ )
+ is_telemetry_level_set: bool = without_consent(
+ False, description="Whether the user consented to our telemetry level"
+ )
+ # App configuration:
+ app_theme: str = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ default="system",
+ description="App theme: light, dark, or system",
+ )
+ does_send_message_shortcut_include_modifier: bool = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ default=True,
+ description="True if the send message shortcut includes the modifier key. Eg. Cmd+Enter instead of Enter alone.)",
+ )
+ new_agent_shortcut: str = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ default=f"{_DEFAULT_MODIFIER_KEY}+N",
+ description="Shortcut for creating a new agent",
+ )
+ search_agents_shortcut: str = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ default=f"{_DEFAULT_MODIFIER_KEY}+K",
+ description="Shortcut for searching agents",
+ )
+ toggle_sidebar_shortcut: str = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ default=f"{_DEFAULT_MODIFIER_KEY}+S",
+ description="Shortcut for toggling the sidebar",
+ )
+ global_hotkey: str = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ default="",
+ description="Global hotkey to open Sculptor",
+ )
+ default_llm: str | None = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ default=None,
+ description="Default LLM model for new agents. If None, then most recently used LLM will be used.",
+ )
+ has_seen_pairing_mode_modal: bool = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ default=False,
+ description="Whether the user has seen the pairing mode modal",
+ )
+ are_suggestions_enabled: bool = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ default=True,
+ description="Whether to enable the suggestions feature",
+ )
+ imbue_verify_run_frequency: str = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ default="auto",
+ description="Frequency for running Imbue Verify: auto or manual",
+ )
+ imbue_verify_token_usage_requirement: str = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ default="low",
+ description="Token threshold for running Imbue Verify: none, low, medium, or high",
+ )
+ is_forking_beta_feature_on: bool = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ default=False,
+ description="Whether to enable the forking beta feature",
+ )
+ is_pairing_mode_stashing_beta_feature_on: bool = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ default=False,
+ description="Whether to enable the pairing mode stashing beta feature",
+ )
+ is_pairing_mode_warning_before_stash_enabled: bool = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ default=True,
+ description="Whether to show a warning dialog before stashing changes when starting pairing mode",
+ )
+ are_dev_suggestions_on: bool = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ default=False,
+ description="Whether to enable the dev suggestions pane",
+ )
+ is_scout_beta_feature_on: bool = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ default=False,
+ description="Whether to enable the scout beta feature",
+ )
+
+ # NOTE: The electron frontend might read this value directly in configFallback.ts. Please remember to keep them in sync.
+ update_channel: UpdateChannel = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ default=UpdateChannel.STABLE,
+ description="Update channel for receiving Sculptor updates (stable or alpha)",
+ )
+ max_snapshot_size_bytes: int = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ default=50 * 1024 * 1024,
+ description="Maximum snapshot size in bytes.",
+ )
+ min_free_disk_gb: float = with_consent(
+ ConsentLevel.PRODUCT_ANALYTICS,
+ default=2.0,
+ description="The minimum free disk space before Sculptor will stop allowing new tasks and messages",
+ )
+
+ @property
+ def is_imbue_user(self) -> bool:
+ return self.user_email.endswith("@imbue.com")
+
+ @property
+ def free_disk_gb_warn_limit(self) -> float:
+ return self.min_free_disk_gb * 3.0
+
+ @property
+ def privacy_settings(self) -> PrivacySettings:
+ """Retrieves the subset of fields associated with Privacy Settings"""
+ return PrivacySettings(
+ is_error_reporting_enabled=self.is_error_reporting_enabled,
+ is_product_analytics_enabled=self.is_product_analytics_enabled,
+ is_llm_logs_enabled=self.is_llm_logs_enabled,
+ is_session_recording_enabled=self.is_session_recording_enabled,
+ is_repo_backup_enabled=self.is_repo_backup_enabled,
+ is_full_contribution=self.is_full_contribution,
+ telemetry_consent_level=self.telemetry_consent_level,
+ )
+
+ @property
+ def sentry_user_context(self) -> dict[str, str]:
+ """Returns a dictionary of user context information for Sentry error reporting."""
+ return {
+ "id": self.user_id, # this is conveniently the same id as used by posthog client
+ "email": self.user_email,
+ "username": self.user_email, # traditionally what we have been setting as username
+ }
+
+
+# At Runtime, ensure that all fields in PrivacySettings are also in UserConfig
+for field in PrivacySettings.model_fields:
+ assert field in UserConfig.model_fields, f"PrivacySettings field {field} is missing from UserConfig"
+
+
+def _generate_user_config_field_enum() -> type[StrEnum]:
+ """Generate UserConfigField enum from UserConfig model fields"""
+ fields = {}
+ for field_name in UserConfig.model_fields.keys():
+ # Convert field name to SCREAMING_SNAKE_CASE for enum constant
+ enum_name = field_name.upper()
+ fields[enum_name] = to_camel(field_name)
+ # pyre thinks this is an instance of a StrEnum because it doesn't understand enums
+ return StrEnum("UserConfigField", fields) # pyre-ignore[7, 19]
+
+
+UserConfigField: type[StrEnum] = _generate_user_config_field_enum()
+
+
+def calculate_user_config_prior_values(
+ old_config: UserConfig, new_config: UserConfig, privacy_settings: PrivacySettings
+) -> dict[str, Any]:
+ from imbue_core.sculptor.telemetry import is_consent_allowable
+
+ old_dict = old_config.model_dump()
+ new_dict = new_config.model_dump()
+ prior_values: dict[str, Any] = {}
+
+ for field_name in old_dict:
+ if old_dict[field_name] != new_dict[field_name]:
+ field_info = UserConfig.model_fields.get(field_name)
+ if field_info:
+ metadata = field_info.json_schema_extra or {}
+ required_level = metadata.get("consent_level")
+ if is_consent_allowable(required_level, privacy_settings):
+ prior_values[field_name] = old_dict[field_name]
+ else:
+ prior_values[field_name] = None
+
+ return prior_values
diff --git a/imbue_core/imbue_core/secrets_utils.py b/imbue_core/imbue_core/secrets_utils.py
@@ -0,0 +1,81 @@
+import os
+import pathlib
+
+from pydantic import SecretStr
+
+
+class Secret(SecretStr):
+ """Pydantic-aware secret wrapper that hides values in logs."""
+
+ def __str__(self) -> str:
+ return "[redacted]"
+
+ __repr__ = __str__
+
+ def unwrap(self) -> str:
+ return self.get_secret_value()
+
+
+class YouAreBeingTooFancyInYourSettingsFile(Exception):
+ pass
+
+
+def parse_secrets_file(filepath: str | pathlib.Path) -> dict[str, str]:
+ """Parse bashenv_secrets.sh-style file into a dict.
+ We should REALLY NOT BE DOING THIS EVER but unfortunately that's not the case so at least let's only do it once here
+
+ Not a great parser; will break in probably many scenarios but end-of-line comments are one that comes to mind
+ """
+ out: dict[str, str] = {}
+ with open(filepath) as f:
+ for line in f:
+ if "$" in line:
+ raise YouAreBeingTooFancyInYourSettingsFile(
+ "Yeah, don't do that. This .sh file is meant to be simple definitions, it should not use any features of bash or sh, including string interpolation via $"
+ )
+ if "#" in line:
+ if not line.startswith("#"):
+ raise YouAreBeingTooFancyInYourSettingsFile("Put comments at the start of the line")
+ continue
+ if "\\" in line:
+ raise YouAreBeingTooFancyInYourSettingsFile("No line continuations or other character escapes allowed")
+ if line.startswith("export "):
+ k, v = line.strip("export ").strip().split("=", maxsplit=1)
+ k = k.strip()
+ if k != k.upper():
+ raise YouAreBeingTooFancyInYourSettingsFile(f"Key {k} must be uppercase")
+ v = v.strip()
+ if v.startswith('"'):
+ if not v.endswith('"'):
+ raise YouAreBeingTooFancyInYourSettingsFile(f"Value {v} must end with a double quote")
+ v = v[1:-1]
+ if v.startswith("'"):
+ if not v.endswith("'"):
+ raise YouAreBeingTooFancyInYourSettingsFile(f"Value {v} must end with a single quote")
+ v = v[1:-1]
+ out[k] = v
+ elif line.strip():
+ raise YouAreBeingTooFancyInYourSettingsFile(
+ f"All lines must start with 'export ', but this line did not: {line}"
+ )
+ return out
+
+
+# TODO: this is gross and bad--we should make better handling for secrets.
+# Right now we read the necessary secrets out of the bashenv files
+def get_secret(secret_name: str) -> str | None:
+ value = os.environ.get(secret_name)
+ if value is not None:
+ return value
+ secrets_files = (
+ "science/secrets/environment_vars/bashenv.sh",
+ "science/secrets/environment_vars/bashenv_secrets.sh",
+ "science/secrets/environment_vars/common_vars.sh",
+ )
+ for file in secrets_files:
+ if os.path.exists(file):
+ secrets = parse_secrets_file(file)
+ value = secrets.get(secret_name, None)
+ if value is not None:
+ return value
+ return None
diff --git a/imbue_core/imbue_core/section.py b/imbue_core/imbue_core/section.py
@@ -0,0 +1,131 @@
+"""
+Provides a context manager for logging a potentially time-consuming process, or a "section".
+
+- Prints logs at start and end of a section.
+
+- Prints a Markdown-like heading for nested sections: "#" for top-level sections, "##" for one level down, and so on.
+
+- Emits structured logs for easier query.
+
+The SectionWrapper context manager can be used to time the __enter__ and __exit__ methods of an existing context manager.
+"""
+
+import contextlib
+import threading
+import time
+from asyncio import CancelledError
+from types import TracebackType
+from typing import TypeVar
+
+from loguru import logger
+
+_monotonic_base = time.monotonic()
+
+
+def _monotonic_time() -> float:
+ """A wrapper around time.monotonic() to make the return values a bit smaller and easier to read by a human."""
+ return time.monotonic() - _monotonic_base
+
+
+class _ThreadLocal(threading.local):
+ def __init__(self) -> None:
+ self.next_section_level: int = 0
+
+
+_thread_local = _ThreadLocal()
+
+
+class Section(contextlib.ContextDecorator):
+ def __init__(self, message: str, log_level: int | str = "INFO") -> None:
+ # TODO: loguru doesn't properly display integer log levels like e.g. logging.INFO
+ self.message = message
+ self.log_level = log_level
+
+ def __enter__(self) -> "Section":
+ level = _thread_local.next_section_level
+ _thread_local.next_section_level += 1
+ self.header = "#" * (level + 1) # pyre-ignore[16]
+ self.start_monotonic_time = _monotonic_time() # pyre-ignore[16]
+ start_clock_time = time.time()
+ self.section = { # pyre-ignore[16]
+ "name": self.message,
+ "level": level,
+ "start_monotonic_time": self.start_monotonic_time,
+ "start_clock_time": start_clock_time,
+ }
+ logger.log(
+ self.log_level,
+ f"{self.header} Start: {self.message}",
+ section=self.section,
+ )
+ return self
+
+ def __exit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_val: BaseException | None,
+ exc_tb: TracebackType | None,
+ ) -> None:
+ _thread_local.next_section_level -= 1
+ finish_monotonic_time = _monotonic_time()
+ finish_clock_time = time.time()
+ # pyre-ignore[16]: we set this on __enter__
+ duration_seconds = finish_monotonic_time - self.start_monotonic_time
+ section = self.section | { # pyre-ignore[16]: we set this on __enter__
+ "finish_monotonic_time": finish_monotonic_time,
+ "finish_clock_time": finish_clock_time,
+ "duration_seconds": duration_seconds,
+ }
+ self.elapsed = duration_seconds # pyre-ignore[16]
+
+ header = self.header # pyre-ignore[16]: we set this on __enter__
+
+ if exc_val is None:
+ logger.log(
+ self.log_level,
+ f"{header} Done: {self.message} (took {duration_seconds:.2f} seconds)",
+ section=section | {"result": "success"},
+ )
+ else:
+ if isinstance(exc_val, CancelledError):
+ logger.log(
+ self.log_level,
+ f"{header} Cancelled: {self.message} (took {duration_seconds:.2f} seconds)",
+ section=section | {"result": "cancelled"},
+ )
+ else:
+ logger.log(
+ self.log_level,
+ f"{header} Failed: {self.message} (within {duration_seconds:.2f} seconds)",
+ section=section | {"result": "failed"},
+ )
+
+
+T = TypeVar("T")
+
+
+# pyre-ignore[24]: pyre doesn't understand AbstractContextManager
+class SectionWrapper(contextlib.AbstractContextManager[T]):
+ # pyre-ignore[24]
+ def __init__(
+ self,
+ cm: contextlib.AbstractContextManager[T],
+ enter_message: str,
+ exit_message: str,
+ ) -> None:
+ self._cm = cm
+ self._enter_message = enter_message
+ self._exit_message = exit_message
+
+ def __enter__(self) -> T:
+ with Section(self._enter_message):
+ return self._cm.__enter__()
+
+ def __exit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_val: BaseException | None,
+ exc_tb: TracebackType | None,
+ ) -> None:
+ with Section(self._exit_message):
+ self._cm.__exit__(exc_type, exc_val, exc_tb)
diff --git a/imbue_core/imbue_core/sentry_loguru_handler.py b/imbue_core/imbue_core/sentry_loguru_handler.py
@@ -0,0 +1,367 @@
+"""
+inlines sentry_sdk.integrations.loguru and sentry_sdk.integrations.logging, so we can make some changes.
+i'm intentionally keeping most of the old logic so this still behaves roughly as expected/documented.
+
+we probably could/should go through and fully streamline this though to do just what we need.
+
+The changes so far (could be out of date):
+- adds `strip_extra` to the breadcrumb handler
+- adds `add_extra_info_hook` to the event handler, with a watchdog to make sure it doesn't slow things down
+"""
+
+import asyncio
+import enum
+import logging
+from concurrent.futures import Future
+from concurrent.futures import ThreadPoolExecutor
+from concurrent.futures import wait
+from datetime import datetime
+from datetime import timezone
+from fnmatch import fnmatch
+from typing import Any
+from typing import Callable
+from typing import Iterable
+from typing import Sequence
+
+import sentry_sdk
+from loguru import logger
+from sentry_sdk import new_scope
+
+# "This disables recording (both in breadcrumbs and as events) calls to a logger of a specific name. Among other uses, many of our integrations
+# use this to prevent their actions being recorded as breadcrumbs. Exposed to users as a way to quiet spammy loggers."
+# We have to import it so that existing setters work properly
+from sentry_sdk.integrations.logging import _IGNORED_LOGGERS
+from sentry_sdk.types import Event
+from sentry_sdk.types import Hint
+from sentry_sdk.utils import current_stacktrace
+from sentry_sdk.utils import event_from_exception
+from sentry_sdk.utils import to_string
+
+from imbue_core.constants import HIGH_PRIORITY_LEVEL
+from imbue_core.constants import LOW_PRIORITY_LEVEL
+from imbue_core.constants import MEDIUM_PRIORITY_LEVEL
+from imbue_core.s3_uploader import EXTRAS_UPLOADED_FILES_KEY
+
+# for formatting the log message. we don't want the timestamp/level because sentry already tracks that,
+# and it messes up event grouping since this string becomes the event title.
+SENTRY_LOG_FORMAT = "{name}:{function}:{line} - {message}"
+
+
+class SentryLoguruLoggingLevels(enum.IntEnum):
+ TRACE = 5
+ DEBUG = 10
+ INFO = 20
+ SUCCESS = 25
+ WARNING = 30
+ # Additional loguru levels for sentry hot-wiring that we also present with custom colors in the console.
+ # The mapping to sentry levels for both breadcrumbs and reporting is done in map_to_sentry_name()
+ LOW_PRIORITY = LOW_PRIORITY_LEVEL # pyre-ignore[8]: pyre doesn't understand enums
+ MEDIUM_PRIORITY = MEDIUM_PRIORITY_LEVEL # pyre-ignore[8]: pyre doesn't understand enums
+ HIGH_PRIORITY = HIGH_PRIORITY_LEVEL # pyre-ignore[8]: pyre doesn't understand enums
+ ERROR = 40
+ CRITICAL = 50
+
+ def map_to_sentry_name(self) -> str:
+ # Sentry only understands and respects "debug", "info", "warning", "error", "critical", "fatal"
+ match self:
+ case SentryLoguruLoggingLevels.TRACE | SentryLoguruLoggingLevels.DEBUG:
+ return "debug"
+ case SentryLoguruLoggingLevels.INFO | SentryLoguruLoggingLevels.SUCCESS:
+ return "info"
+ case SentryLoguruLoggingLevels.LOW_PRIORITY:
+ return "info"
+ case SentryLoguruLoggingLevels.MEDIUM_PRIORITY | SentryLoguruLoggingLevels.WARNING:
+ return "warning"
+ case SentryLoguruLoggingLevels.HIGH_PRIORITY | SentryLoguruLoggingLevels.ERROR:
+ return "error"
+ case SentryLoguruLoggingLevels.CRITICAL:
+ return "critical"
+ case _:
+ return ""
+
+
+class _BaseHandler(logging.Handler):
+ COMMON_RECORD_ATTRS = frozenset(
+ (
+ "args",
+ "created",
+ "exc_info",
+ "exc_text",
+ "filename",
+ "funcName",
+ "levelname",
+ "levelno",
+ "linenno",
+ "lineno",
+ "message",
+ "module",
+ "msecs",
+ "msg",
+ "name",
+ "pathname",
+ "process",
+ "processName",
+ "relativeCreated",
+ "stack",
+ "tags",
+ "taskName",
+ "thread",
+ "threadName",
+ "stack_info",
+ )
+ )
+
+ def _can_record(self, record: logging.LogRecord) -> bool:
+ """Prevents ignored loggers from recording"""
+ for logger in _IGNORED_LOGGERS:
+ if fnmatch(record.name, logger):
+ return False
+ return True
+
+ def _extra_from_record(self, record: logging.LogRecord) -> dict[str, object]:
+ return {
+ k: v
+ for k, v in vars(record).items()
+ if k not in self.COMMON_RECORD_ATTRS and (not isinstance(k, str) or not k.startswith("_"))
+ }
+
+ def _logging_to_event_level(self, record: logging.LogRecord) -> str:
+ try:
+ return SentryLoguruLoggingLevels(record.levelno).map_to_sentry_name()
+ except ValueError:
+ return record.levelname.lower() if record.levelname else ""
+
+
+def _wrap_callback(callback: Callable) -> None:
+ try:
+ callback()
+ except Exception as e:
+ log_error_inside_sentry(e, "Sentry callback raised")
+
+
+class SentryEventHandler(_BaseHandler):
+ """A logging handler that emits Sentry events for each log record."""
+
+ def __init__(
+ self,
+ level: int = logging.NOTSET,
+ add_extra_info_hook: Callable[[Event, Hint], tuple[Event, Hint, tuple[Callable, ...]]] | None = None,
+ ) -> None:
+ super().__init__(level=level)
+ self.add_extra_info_hook = add_extra_info_hook
+ self.add_extra_info_previously_timed_out = False
+ self._executor: ThreadPoolExecutor | None = ThreadPoolExecutor()
+ self._futures: list[Future] = []
+
+ def emit(self, record: logging.LogRecord) -> Any:
+ self.format(record)
+ return self._emit(record)
+
+ def schedule_callbacks(self, callbacks: Sequence[Callable]) -> None:
+ executor = self._executor
+ if executor is not None:
+ logger.info(f"Sentry event handler registered {len(callbacks)} callbacks with an executor")
+ for callback in callbacks:
+ future = executor.submit(lambda c=callback: _wrap_callback(c))
+ self._futures.append(future)
+ else:
+ logger.debug(
+ f"Sentry event handler failed to register {len(callbacks)} callbacks because no executor was found"
+ )
+
+ def close(self) -> None:
+ executor = self._executor
+ if executor is not None:
+ executor.shutdown(wait=False)
+ wait(self._futures, timeout=5.0)
+ self._executor = None
+ super().close()
+
+ def _emit(self, record: logging.LogRecord) -> None:
+ if not self._can_record(record):
+ return
+
+ # Filter out KeyboardInterrupt and CancelledError exceptions from being logged to Sentry
+ if record.exc_info and record.exc_info[0] is not None:
+ exc_type = record.exc_info[0]
+ if exc_type is KeyboardInterrupt or exc_type is asyncio.CancelledError:
+ return
+
+ client = sentry_sdk.get_client()
+ if not client.is_active():
+ return
+
+ client_options = client.options
+
+ # exc_info might be None or (None, None, None)
+ #
+ # exc_info may also be any falsy value due to Python stdlib being
+ # liberal with what it receives and Celery's billiard being "liberal"
+ # with what it sends. See
+ # https://github.com/getsentry/sentry-python/issues/904
+ if record.exc_info and record.exc_info[0] is not None:
+ event, hint = event_from_exception(
+ record.exc_info,
+ client_options=client_options,
+ mechanism={"type": "logging", "handled": True},
+ )
+ elif (record.exc_info and record.exc_info[0] is None) or record.stack_info:
+ event: Event = {}
+ hint: Hint = {}
+ event["threads"] = {
+ "values": [
+ {
+ "stacktrace": current_stacktrace(
+ include_local_variables=client_options["include_local_variables"],
+ max_value_length=client_options["max_value_length"],
+ ),
+ "crashed": False,
+ "current": True,
+ }
+ ]
+ }
+ else:
+ event: Event = {}
+ hint: Hint = {}
+
+ hint["log_record"] = record
+
+ level = self._logging_to_event_level(record)
+ if level in {"debug", "info", "warning", "error", "critical", "fatal"}:
+ # standard levels that sentry understands, it ignores any other types
+ event["level"] = level # type: ignore[typeddict-item]
+
+ event["logger"] = record.name
+
+ # Log records from `warnings` module as separate issues
+ record_captured_from_warnings_module = record.name == "py.warnings" and record.msg == "%s"
+ if record_captured_from_warnings_module:
+ # use the actual message and not "%s" as the message
+ # this prevents grouping all warnings under one "%s" issue
+ msg = record.args[0] # type: ignore
+
+ event["logentry"] = {
+ "message": msg,
+ "params": (),
+ }
+
+ else:
+ event["logentry"] = {
+ # TODO: a bit lame, but we don't have access to the unformatted message, so we just reverse our current format...
+ "message": to_string(record.msg).split(" - ")[-1],
+ "params": record.args,
+ }
+
+ event["extra"] = self._extra_from_record(record)
+
+ if self.add_extra_info_hook:
+ event, hint, callbacks = self.add_extra_with_watchdog(event, hint, timeout=1)
+ self.schedule_callbacks(callbacks)
+
+ sentry_sdk.capture_event(event, hint)
+
+ def add_extra_with_watchdog(
+ self, event: Event, hint: Hint, timeout: float
+ ) -> tuple[Event, Hint, tuple[Callable, ...]]:
+ """Call the add_extra_info_hook with a watchdog so we can skip it if it's slow, and get another sentry error about that."""
+ if self.add_extra_info_previously_timed_out:
+ event.setdefault("extra", {})["add_extra_info_previously_timed_out"] = True
+ return event, hint, tuple()
+ if "attachments" not in hint:
+ hint["attachments"] = []
+ executor = ThreadPoolExecutor()
+ add_extra_info_hook = self.add_extra_info_hook
+ assert add_extra_info_hook is not None
+ future = executor.submit(add_extra_info_hook, event, hint)
+ try:
+ event, hint, callbacks = future.result(timeout=timeout)
+ executor.shutdown()
+ return event, hint, callbacks
+ except TimeoutError as e:
+ from imbue_core.sculptor.telemetry import ProductComponent
+ from imbue_core.sculptor.telemetry import SculptorPosthogEvent
+ from imbue_core.sculptor.telemetry import send_exception_to_posthog
+
+ send_exception_to_posthog(
+ SculptorPosthogEvent.SENTRY_EXCEPTION_DATA_COLLECTION_TOO_SLOW,
+ e,
+ component=ProductComponent.CROSS_COMPONENT,
+ )
+ # this will leave the thread still running; there's no real way to cancel it.
+ # we'll at least set this flag so future errors don't try to run the (bugged?) hook again.
+ executor.shutdown(wait=False)
+ self.add_extra_info_previously_timed_out = True
+
+ # continue with the main event without the extra info
+ return event, hint, tuple()
+
+
+class SentryBreadcrumbHandler(_BaseHandler):
+ """
+ A logging handler that records breadcrumbs for each log record.
+
+ Note that you do not have to use this class if the logging integration is enabled, which it is by default.
+ """
+
+ def __init__(self, level: int = logging.NOTSET, strip_extra: bool = False) -> None:
+ super().__init__(level=level)
+ self.strip_extra = strip_extra
+
+ def emit(self, record: logging.LogRecord) -> Any:
+ # is this needed? keeping in case there are side effects that we want to trigger here
+ self.format(record)
+ return self._emit(record)
+
+ def _emit(self, record: logging.LogRecord) -> None:
+ if not self._can_record(record):
+ return
+
+ sentry_sdk.add_breadcrumb(self._breadcrumb_from_record(record), hint={"log_record": record})
+
+ def _breadcrumb_from_record(self, record: logging.LogRecord) -> dict[str, Any]:
+ return {
+ "type": "log",
+ "level": self._logging_to_event_level(record),
+ "category": record.name,
+ "message": record.message,
+ "timestamp": datetime.fromtimestamp(record.created, timezone.utc),
+ "data": self._extra_from_record(record) if not self.strip_extra else {},
+ }
+
+
+def log_error_inside_sentry(
+ exception: Exception,
+ message: str,
+ extra: dict[str, str | int] | None = None,
+ additional_s3_uploads: Iterable[str] | None = None,
+) -> None:
+ """Log an error to sentry that happens during processing of a sentry event.
+
+ This needs to be done very carefully to ensure it won't fail - we don't want to have to have a fallback-fallback handler.
+ The caller should ensure everything passed into this is small so there's no chance of size issues.
+ """
+ client = sentry_sdk.get_client()
+ # we want to get rid of any breadcrumbs, attachments, and other stuff that might have caused the original request to fail.
+ # this will obviously make it harder to debug; we may want to selectively add some of this back.
+ with new_scope() as scope:
+ scope.clear()
+ event, hint = event_from_exception(
+ exception,
+ client_options=client.options,
+ mechanism={"type": "watchdog", "handled": True},
+ )
+ event["message"] = message
+ if extra is not None:
+ if "extra" not in event:
+ event["extra"] = {}
+ for k, v in extra.items():
+ event["extra"][k] = v
+
+ # record any other files uploaded to s3
+ if additional_s3_uploads is not None:
+ event["extra"][EXTRAS_UPLOADED_FILES_KEY + "_erred"] = str(list(additional_s3_uploads))
+
+ # Note that new_scope() gives a new "current scope" but doesn't affect the global or isolation scope,
+ # which is where most info is actually stored. Typically all 3 scopes are merged before logging the event.
+ # So we'll make sure to call capture_event in such a way that this merging doesn't happen.
+ client.capture_event(event=event, hint=hint, scope=scope)
diff --git a/imbue_core/imbue_core/serialization.py b/imbue_core/imbue_core/serialization.py
@@ -0,0 +1,471 @@
+import builtins
+import datetime
+import inspect
+import json
+from enum import Enum
+from functools import cached_property
+from importlib import import_module
+from importlib.metadata import version
+from pathlib import PosixPath
+from traceback import format_tb
+from types import TracebackType
+from typing import Any
+from typing import Callable
+from typing import Hashable
+from typing import Iterable
+from typing import Mapping
+from typing import TypeVar
+from typing import cast
+from uuid import UUID
+
+from loguru import logger
+from typing_extensions import TypeAliasType
+from yasoo import Deserializer
+from yasoo import Serializer
+from yasoo.constants import ENUM_VALUE_KEY
+from yasoo.objects import DictWithSerializedKeys
+from yasoo.serialization import _convert_to_json_serializable
+from yasoo.utils import get_fields
+from yasoo.utils import is_obj_supported_primitive
+from yasoo.utils import normalize_type
+from yasoo.utils import resolve_types
+
+from imbue_core.async_monkey_patches import EXCEPTION_LOGGED_FLAG
+from imbue_core.fixed_traceback import FixedTraceback
+from imbue_core.pydantic_serialization import SerializableModel
+from imbue_core.serialization_types import Serializable
+
+assert (
+ version("yasoo") == "0.12.6"
+), "This code was written for yasoo 0.12.6 and requires inheriting / monkeypatching the deserializer, so you probably don't want to use any other version without fixing TupleDeserializer"
+
+T = TypeVar("T", bound=Hashable)
+
+
+class TupleDeserializer(Deserializer):
+ def _deserialize(
+ self,
+ data: bool | int | float | str | list[Any] | dict[str, Any] | None,
+ obj_type: type[T] | None,
+ type_key: str | None,
+ allow_extra_fields: bool,
+ external_globals: dict[str, Any],
+ ignore_custom_deserializer: bool = False,
+ ) -> object:
+ all_globals = dict(globals())
+ all_globals.update(external_globals)
+ if is_obj_supported_primitive(data):
+ return data
+ if isinstance(data, list):
+ list_types = self._get_list_types(obj_type, data)
+ return tuple([self._deserialize(d, t, type_key, allow_extra_fields, all_globals) for t, d in list_types])
+
+ assert isinstance(data, dict), f"Expected a dict, but got {type(data)}"
+
+ # load wrapped primitives
+ if type_key is not None:
+ type_data = data.get(type_key, None)
+
+ if type_data is not None and type_data.startswith("builtins.") and type_data != "builtins.dict":
+ return data["value"]
+
+ # TODO: we need to potentially handle `builtins.dict`
+ # if type_key is not None:
+ # type_data = data.get(type_key, None)
+ #
+ # # TODO: serialization currently breaks with builtin.dicts and dicts with non-string keys
+ # if type_data == "builtins.dict":
+ # raise NotImplementedError(
+ # "Only `FrozenMapping` is supported for dict serialization/deserialization, call `freeze_mapping` on your dict before serializing"
+ # )
+ # if type_data is not None and type_data.startswith("builtins.") and type_data != "builtins.dict":
+ # return data["value"]
+
+ # TODO: remove this hack. Many of our sqlite files (search s3_sqlite_path) have FrozenDicts
+ if isinstance(type_key, str) and data.get(type_key, None) == "flax.core.frozen_dict.FrozenDict":
+ data[type_key] = "imbue_core.frozen_utils.FrozenMapping"
+ # we deliberately pass in a `None` type_key sometimes, which results in just returning obj_type
+ obj_type = self._get_object_type(obj_type, data, type_key, all_globals) # pyre-ignore[6]
+ if type_key in data:
+ data.pop(type_key)
+ real_type, generic_args = normalize_type(obj_type, all_globals)
+ if external_globals and isinstance(real_type, type):
+ bases = {real_type}
+ while bases:
+ all_globals.update((b.__name__, b) for b in bases)
+ bases = {ancestor for b in bases for ancestor in b.__bases__}
+
+ if not ignore_custom_deserializer:
+ deserialization_method = self._custom_deserializers.get(obj_type, self._custom_deserializers.get(real_type))
+ if deserialization_method:
+ return deserialization_method(data)
+ for base_class, method in self._inheritance_deserializers.items():
+ if issubclass(real_type, base_class):
+ return method(data, real_type)
+
+ key_type = None
+ try:
+ # pyre-fixme[6]: obj_type needs to be Hashable, but pyre isn't sure that it is
+ fields = {f.name: f for f in get_fields(obj_type)}
+ except TypeError:
+ if obj_type is FixedTraceback:
+ return FixedTraceback.from_dict(data["value"])
+ if issubclass(real_type, Enum):
+ value = data[ENUM_VALUE_KEY]
+ if isinstance(value, str):
+ try:
+ return real_type[value]
+ except KeyError:
+ for e in real_type:
+ if e.name.lower() == value.lower():
+ return e
+ return real_type(value)
+ # TODO (49780118-61e5-446b-b44b-cabb3ffc0ba2): serialization currently breaks with builtin.dicts and dicts with non-string keys
+ # if you have weird keys in your dict this branch won't be hit and your object won't be properly deserialized
+ elif issubclass(real_type, Mapping):
+ key_type = generic_args[0] if generic_args else None
+ if self._is_mapping_dict_with_serialized_keys(key_type, data):
+ # pyre-fixme[9]: obj_type needs to be Hashable, but pyre doesn't realize that type[DictWithSerializedKeys] is ok
+ obj_type = DictWithSerializedKeys
+ # pyre-fixme[6]: arg of get_fields needs to be Hashable, but pyre doesn't realize that type[DictWithSerializedKeys] is ok
+ fields = {f.name: f for f in get_fields(DictWithSerializedKeys)}
+ value_type = generic_args[1] if generic_args else Any
+ fields["data"].field_type = dict[str, value_type] # type: ignore
+ else:
+ return self._load_mapping(
+ data,
+ real_type,
+ generic_args,
+ type_key,
+ allow_extra_fields,
+ all_globals,
+ )
+ elif issubclass(real_type, Iterable):
+ # If we got here it means data is not a list, so obj_type came from the data itself and is safe to use
+ return self._load_iterable(data, obj_type, type_key, allow_extra_fields, all_globals)
+ elif real_type != obj_type:
+ return self._deserialize(data, real_type, type_key, allow_extra_fields, external_globals)
+ else:
+ raise
+
+ self._check_for_missing_fields(data, fields, obj_type)
+ self._check_for_extraneous_fields(data, fields, obj_type, allow_extra_fields)
+ self._load_inner_fields(data, fields, type_key, allow_extra_fields, all_globals)
+ if obj_type is DictWithSerializedKeys:
+ return self._load_dict_with_serialized_keys(
+ obj_type(**data), key_type, type_key, allow_extra_fields, all_globals
+ )
+ kwargs = {k: v for k, v in data.items() if fields[k].init}
+ assert obj_type is not None
+ result = obj_type(**kwargs)
+ for k, v in data.items():
+ if k not in kwargs:
+ setattr(result, k, v)
+ return result
+
+
+# TODO: probably a good idea to ensure that all dicts are frozen as well...
+class FrozenSerializer(Serializer):
+ def __init__(self, force_serialization: bool, allow_unsafe_list_serialization: bool = False) -> None:
+ super().__init__()
+ self._force_serialization = force_serialization
+ self._allow_unsafe_list_serialization = allow_unsafe_list_serialization
+
+ def _serialize_iterable(
+ self,
+ obj: Iterable[object],
+ type_key: Any,
+ fully_qualified_types: Any,
+ preserve_iterable_types: Any,
+ stringify_dict_keys: Any,
+ ) -> list[object]:
+ if isinstance(obj, list):
+ if self._allow_unsafe_list_serialization:
+ logger.info("Converting list to tuple for serialization: {}", obj)
+ obj = tuple(obj)
+ else:
+ raise Exception(f"Lists are not allowed for serialization. Use tuples instead. Current iterable: {obj}")
+ assert isinstance(
+ obj, (tuple, frozenset, bytes)
+ ), f"All iterables should be tuples or frozenset. Received {obj}"
+ return cast(
+ list[object],
+ tuple(
+ self._serialize(
+ item,
+ type_key,
+ fully_qualified_types,
+ preserve_iterable_types,
+ stringify_dict_keys,
+ )
+ for item in obj
+ ),
+ )
+
+ # overriding this method just to get some better error messages out--previously it would just "type error" and
+ # moan about things like int64 not being serializable, which is fine, but it is nicer if the key is included
+ def serialize(
+ self,
+ obj: Any,
+ type_key: str | None = "__type",
+ fully_qualified_types: bool = True,
+ preserve_iterable_types: bool = False,
+ stringify_dict_keys: bool = True,
+ globals: dict[str, Any] | None = None,
+ ) -> bool | int | float | str | list | dict[str, Any] | None:
+ try:
+ if is_obj_supported_primitive(obj):
+ return obj # type: ignore
+
+ if globals:
+ self._custom_serializers = resolve_types(self._custom_serializers, globals) # type: ignore
+
+ result = self._serialize(
+ obj,
+ type_key,
+ fully_qualified_types,
+ preserve_iterable_types,
+ stringify_dict_keys,
+ inner=False,
+ )
+ try:
+ result = _convert_to_json_serializable(result)
+ except TypeError:
+ _convert_to_json_serializable_with_better_errors(result)
+ assert False, "previous method should have raised..."
+ return result # type: ignore
+ except Exception:
+ if self._force_serialization:
+ return repr(obj)
+ else:
+ raise
+
+
+JsonTypeAlias = TypeAliasType(
+ "JsonTypeAlias",
+ "dict[str, JsonTypeAlias] | list[JsonTypeAlias] | str | int | float | bool | None",
+)
+
+
+class SerializedException(SerializableModel):
+ """A serializable dataclass that represents an exception"""
+
+ exception: str
+ args: "tuple[SerializedException | JsonTypeAlias, ...]" # pyre-ignore[11]: pyre doesn't like TypeAliasType
+ traceback_dict: JsonTypeAlias
+ was_logged_by_log_exception: bool = False
+
+ @classmethod
+ def build(cls, exception: BaseException, traceback: TracebackType | None = None) -> "SerializedException":
+ if traceback is None:
+ traceback = exception.__traceback__
+ assert traceback is not None, " ".join(
+ (
+ "No traceback deriveable or as a concrete argument!",
+ f"You probably want to convert_to_serialized_exception in your except clause: {exception=}",
+ )
+ )
+ return SerializedException( # pyre-fixme[28]: pyre doesn't understand pydantic
+ exception=get_fully_qualified_name_for_error(exception),
+ args=tuple(_convert_serialized_exception_args(x, traceback) for x in exception.args),
+ traceback_dict=FixedTraceback.from_tb(traceback).as_dict(),
+ was_logged_by_log_exception=getattr(exception, EXCEPTION_LOGGED_FLAG, False),
+ )
+
+ @cached_property
+ def traceback(self) -> FixedTraceback | None:
+ traceback_dict = self.traceback_dict
+ if traceback_dict is None:
+ return None
+ return FixedTraceback.from_dict(traceback_dict)
+
+ @cached_property
+ def exception_module(self) -> str:
+ if "." in self.exception:
+ return self.exception.rsplit(".", maxsplit=1)[0]
+ return ""
+
+ @cached_property
+ def exception_type(self) -> str:
+ return self.exception.rsplit(".", maxsplit=1)[-1]
+
+ @cached_property
+ def exception_class(self) -> type[BaseException]:
+ if self.exception_module:
+ return cast(
+ type[BaseException],
+ getattr(import_module(self.exception_module), self.exception_type, None),
+ )
+ else:
+ return cast(type[BaseException], getattr(builtins, self.exception_type, None))
+
+ def construct_instance(self) -> BaseException:
+ try:
+ exception = self.exception_class(*cast(tuple[Serializable, ...], self.args))
+ except TypeError as e:
+ message_with_arg_info = (
+ f"Failed to construct exception {self.exception_class} with args {self.args}.",
+ "Ensure that the exception class is serializable and can be constructed with the provided args.",
+ )
+ raise TypeError(" ".join(message_with_arg_info)) from e
+
+ try:
+ setattr(exception, EXCEPTION_LOGGED_FLAG, True)
+ except AttributeError:
+ # We could not set the flag correctly
+ pass
+
+ return exception
+
+ def as_formatted_traceback(self) -> str:
+ if self.traceback is None:
+ traceback_str = ""
+ else:
+ # pyre-ignore[6]: pyre doesn't know that FixedTraceback is a traceback (since it's not a TracebackType)
+ traceback_str = "".join(format_tb(self.traceback))
+ return f"Traceback (most recent call last):\n{traceback_str}\n{self.exception}: {self.args}"
+
+
+def _convert_serialized_exception_args(error: Serializable, traceback: TracebackType | None = None) -> JsonTypeAlias:
+ if isinstance(error, BaseException):
+ return SerializedException.build(error, traceback=traceback)
+ elif isinstance(error, (list, tuple)):
+ return tuple(_convert_serialized_exception_args(x, traceback) for x in error)
+ return error
+
+
+def get_fully_qualified_name_for_error(e: BaseException) -> str:
+ if e.__class__.__module__ == "builtins":
+ return e.__class__.__name__
+ return f"{e.__class__.__module__}.{e.__class__.__name__}"
+
+
+def _convert_to_json_serializable_with_better_errors(
+ obj: Any, path: str = ""
+) -> int | float | str | list | dict | None:
+ if is_obj_supported_primitive(obj):
+ return obj # type: ignore
+ if isinstance(obj, Mapping):
+ return {
+ key: _convert_to_json_serializable_with_better_errors(value, f"{path}.{key}") for key, value in obj.items()
+ }
+ if isinstance(obj, Iterable):
+ return [_convert_to_json_serializable_with_better_errors(item, f"{path}[{i}]") for i, item in enumerate(obj)]
+ raise TypeError(f'Found object of type "{type(obj).__name__}" at {path} which cannot be serialized')
+
+
+SERIALIZER = FrozenSerializer(force_serialization=False, allow_unsafe_list_serialization=False)
+DESERIALIZER = TupleDeserializer()
+
+# note: you cannot change this without changing other calls to yasoo, this is its default
+TYPE_KEY = "__type"
+
+
+class SerializationError(Exception):
+ pass
+
+
+@SERIALIZER.register()
+def serialize_frozen_set(data: frozenset) -> dict:
+ value = SERIALIZER.serialize(tuple(data))
+ return {"value": value}
+
+
+@DESERIALIZER.register()
+def deserialize_frozen_set(data: dict) -> frozenset:
+ return frozenset(DESERIALIZER.deserialize(data["value"], tuple))
+
+
+@SERIALIZER.register()
+def serialize_uuid(data: UUID) -> dict:
+ return {"value": data.hex}
+
+
+@DESERIALIZER.register()
+def deserialize_uuid(data: dict) -> UUID:
+ return UUID(data["value"])
+
+
+@SERIALIZER.register()
+def serialize_traceback(data: FixedTraceback) -> dict:
+ return {"value": data.to_dict()}
+
+
+@DESERIALIZER.register()
+def deserialize_traceback(data: dict) -> FixedTraceback:
+ return FixedTraceback.from_dict(data["value"])
+
+
+@SERIALIZER.register()
+def serialize_posix_path(data: PosixPath) -> dict:
+ return {"value": str(data)}
+
+
+@DESERIALIZER.register()
+def deserialize_posix_path(data: dict) -> PosixPath:
+ return PosixPath(data["value"])
+
+
+@SERIALIZER.register()
+def serialize_datetime(data: datetime.datetime) -> dict:
+ return {
+ "time": data.astimezone(datetime.timezone.utc).timestamp(),
+ "tzaware": data.tzinfo is not None,
+ "__type": "datetime.datetime",
+ }
+
+
+@DESERIALIZER.register()
+def deserialize_datetime(data: dict) -> datetime.datetime:
+ return datetime.datetime.fromtimestamp(data["time"], datetime.timezone.utc if data.get("tzaware", None) else None)
+
+
+def serialize_to_dict(obj: Any) -> dict[str, Any]:
+ return cast(dict[str, Any], SERIALIZER.serialize(obj))
+
+
+def serialize_to_json(obj: Any, indent: int | None = None, sort_keys: bool = False) -> str:
+ try:
+ return json.dumps(SERIALIZER.serialize(obj), indent=indent, sort_keys=sort_keys)
+ except Exception as e:
+ raise SerializationError(str(e)) from e
+
+
+def deserialize_from_json(data: str) -> Any:
+ try:
+ return DESERIALIZER.deserialize(json.loads(data)) # pyre-ignore[20]: pyre doesn't understand deserialize
+ except Exception as e:
+ raise SerializationError(str(e)) from e
+
+
+def deserialize_from_dict(data: dict[str, Any]) -> Any:
+ try:
+ return DESERIALIZER.deserialize(data) # pyre-ignore[20]: pyre doesn't understand deserialize
+ except Exception as e:
+ raise SerializationError(str(e)) from e
+
+
+def deserialize_from_dict_with_type(data: dict[str, Any], obj_type: type[T]) -> T:
+ try:
+ result = DESERIALIZER.deserialize(data, obj_type=obj_type)
+ assert isinstance(result, obj_type), f"Expected an object of type {obj_type}, but got {result}"
+ return result
+ except Exception as e:
+ raise SerializationError(str(e)) from e
+
+
+def deserialize_from_json_with_type(data: str | bytes | bytearray, obj_type: type[T]) -> T:
+ try:
+ return deserialize_from_dict_with_type(json.loads(data), obj_type=obj_type)
+ except Exception as e:
+ raise SerializationError(str(e)) from e
+
+
+def get_serializable_properties(obj: Any) -> dict[str, Any]:
+ members = inspect.getmembers(type(obj))
+ marked_members = [name for name, member in members if is_serializable_property(member)]
+ return {name: getattr(obj, name) for name in marked_members}
+
+
+def is_serializable_property(func: Callable) -> bool:
+ return getattr(func, "_imbue_is_serializable_property", False)
diff --git a/imbue_core/imbue_core/serialization_types.py b/imbue_core/imbue_core/serialization_types.py
@@ -0,0 +1,3 @@
+# Related comment from Sam: "One of the reasons I don't really like using subclasses for things like this is that __init_subclass__ gets called before dataclass decorators are applied (so there's not really a great way to make sure that a class that inherits from Serializable is actually serializable). I think it's fine just as a marker for now, but might be worth thinking about at some point."
+class Serializable:
+ pass
diff --git a/imbue_core/imbue_core/simple_git.py b/imbue_core/imbue_core/simple_git.py
@@ -0,0 +1,215 @@
+"""This file implements a subset of the functionality found in git.py and compute_environment.py, but everything is synchronous."""
+
+import shlex
+import subprocess
+import time
+from pathlib import Path
+from typing import Sequence
+
+from loguru import logger
+
+from imbue_core.async_monkey_patches import log_exception
+from imbue_core.computing_environment.data_types import AnyPath
+from imbue_core.computing_environment.data_types import RunCommandError
+
+
+class SyncLocalGitRepo:
+ """
+ Provides different operations that you can perform over a git repository.
+
+ Implements a subset of our async LocalGitRepo, but also pulls in some of the functions from computing_environment.py
+ that normally would operate on a LocalGitRepo.
+
+ Over time, we can probably replace LocalGitRepo and computing_environment more or less fully, as we're migrating
+ code away from asyncio.
+
+ Feel free to move additional functions from computing_environment.py into this class as needed. Usually, you just need
+ to remove async/await keywords, and replace calls to compute_environment. member functions with self. calls.
+ """
+
+ _base_path: Path
+
+ def __init__(self, base_path: Path) -> None:
+ self._base_path = base_path
+
+ @property
+ def base_path(self) -> Path:
+ """The base path of the git repo."""
+ return self._base_path
+
+ def run_git(
+ self,
+ command: Sequence[str],
+ check: bool = True,
+ cwd: AnyPath | None = None,
+ is_error_logged: bool = True,
+ is_stripped: bool = True,
+ retry_on_git_lock_error: bool = True,
+ ) -> str:
+ """Run a git command in the repo.
+
+ Example:
+ ```
+ git_repo.run_git("status")
+ ```
+ """
+ command = ["git"] + list(command)
+ if not retry_on_git_lock_error:
+ result = self.run_command(command, check=check, is_error_logged=is_error_logged, cwd=cwd)
+ else:
+ result = self._run_command_with_retry_on_git_lock_error(
+ command, check=check, is_error_logged=is_error_logged, cwd=cwd
+ )
+ if is_stripped:
+ return result.strip()
+ return result
+
+ def run_command(
+ self,
+ command: Sequence[str],
+ check: bool = True,
+ secrets: dict[str, str] | None = None,
+ cwd: AnyPath | None = None,
+ is_error_logged: bool = True,
+ ) -> str:
+ """Run a command in the repo.
+
+ Note, this can be used to run any command, not just git.
+ """
+ command_string = shlex.join(command)
+ logger.trace(
+ f"Running command: {command_string=} from cwd={cwd or self.base_path} with {secrets=} {check=} {is_error_logged=}"
+ )
+ completed_proc = subprocess.run(
+ command,
+ cwd=cwd or self._base_path,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=secrets,
+ )
+ # note, need to be carefull not to strip() lines since whitespace may be important (e.g. for diffs)
+ # return joined lines since mostly we only use the output for logging, and this way we arn't
+ # passing around lots of lists. Also it's easy to parse by lines if needed
+ try:
+ stdout = completed_proc.stdout.decode("UTF-8")
+ except UnicodeDecodeError as e:
+ # If we don't encounter this, it likely means something was fixed upstream and we can safely delete
+ log_exception(
+ e,
+ "Command {command_string} failed to decode stdout, replacing any invalid bytes which could lead to problems later",
+ command_string=command_string,
+ )
+ stdout = completed_proc.stdout.decode("UTF-8", errors="replace")
+ stderr = completed_proc.stderr.decode("UTF-8")
+ if check and completed_proc.returncode != 0:
+ error_message = f"command run from cwd={self.base_path} failed with exit code {completed_proc.returncode} and stdout:\n{stdout}\nstderr:\n{stderr}"
+ if is_error_logged:
+ logger.error(
+ f"command attempted: '{command_string}' from cwd={self.base_path}\nerror message: {error_message}"
+ )
+ # this should not be None, but do this to satisfy type checker, int or None we throw the same error
+ returncode = completed_proc.returncode or -1
+ raise RunCommandError(
+ cmd=command_string,
+ stderr=stderr,
+ returncode=returncode,
+ cwd=cwd or self.base_path,
+ )
+ return stdout
+
+ def get_git_diff(
+ self,
+ commit_hash: str | None = None,
+ staged: bool = False,
+ is_error_logged: bool = True,
+ include_binary: bool = True,
+ ) -> str:
+ """Get the diff for the current repo state."""
+ # make sure `is_stripped=False` otherwise patch can be invalid
+ command = ["diff", "--full-index"]
+ if include_binary:
+ # Without --binary, diffs of binary files will just contain a summary statement such as "Binary files a/file.bin and b/file.bin differ".
+ # Such diffs cannot be applied, but are useful for inclusion in LLM prompts.
+ command.append("--binary")
+ if staged:
+ command.append("--staged")
+ if commit_hash:
+ command.append(commit_hash)
+ return self.run_git(command, is_stripped=False, is_error_logged=is_error_logged)
+
+ def get_untracked_files(self) -> tuple[str, ...]:
+ """Get the untracked files in the repo."""
+ result = self.run_git(["ls-files", "--others", "--exclude-standard"], is_error_logged=False)
+ return tuple([line.strip() for line in result.splitlines() if line.strip()])
+
+ def get_untracked_file_diff(self, file_path: str, include_binary: bool = True) -> str:
+ """Get the diff for a untracked file.
+
+ Note this function will raise a RunCommandError if the there is no diff for the untracked file or if there
+ is another error running the command. So it is best to use this function after checking that the file is untracked
+ using get_untracked_files function.
+ """
+ command = ["diff", "--no-index"]
+ if include_binary:
+ command.append("--binary")
+ untracked_diff = self.run_git(
+ command + ["/dev/null", str(file_path)],
+ # Unfortunately, `--no-index` implies `--exit-code`, which will cause git diff to return an exit code of 1
+ # if the diff is not empty. So we can't use check=True here. We'll check for an empty output to detect failures.
+ check=False,
+ is_error_logged=True,
+ is_stripped=False,
+ )
+ if not untracked_diff:
+ raise RunCommandError(f"Unable to diff untracked file {file_path}")
+ return untracked_diff
+
+ def is_commit_a_branch(self, commit_hash: str) -> bool:
+ """Check if the given git ref is a branch."""
+ try:
+ self.run_git(
+ ("show-ref", "--verify", "-q", f"refs/heads/{commit_hash}"),
+ is_error_logged=False,
+ check=True,
+ )
+ return True
+ except RunCommandError as e:
+ if e.returncode == 1:
+ return False
+ raise
+
+ def get_merge_base(self, branch_name: str, target_branch: str) -> str:
+ """Get the merge base of the given branch and target branch.
+
+ The merge base is the most recent commit that is on both branches.
+ """
+ return self.run_git(["merge-base", branch_name, target_branch], is_error_logged=False)
+
+ def _run_command_with_retry_on_git_lock_error(
+ self,
+ command: Sequence[str],
+ check: bool = True,
+ is_error_logged: bool = True,
+ cwd: AnyPath | None = None,
+ ) -> str:
+ max_retries = 50
+ retry_count = 0
+ retry_delay = 0.1 # seconds
+ while True:
+ try:
+ return self.run_command(
+ command,
+ check=check,
+ is_error_logged=is_error_logged and retry_count >= max_retries,
+ cwd=cwd,
+ )
+ except RunCommandError as e:
+ error_message = str(e)
+ if "fatal: Unable to create" in error_message and ".git/index.lock': File exists" in error_message:
+ if retry_count >= max_retries:
+ raise
+ time.sleep(retry_delay)
+ retry_count += 1
+ else:
+ raise
diff --git a/imbue_core/imbue_core/subprocess_utils.py b/imbue_core/imbue_core/subprocess_utils.py
@@ -0,0 +1,798 @@
+from __future__ import annotations
+
+import os
+import shlex
+import subprocess
+import time
+from functools import cached_property
+from io import BytesIO
+from pathlib import Path
+from threading import Event
+from typing import Callable
+from typing import Final
+from typing import IO
+from typing import Mapping
+from typing import Protocol
+from typing import Sequence
+
+import attr
+from loguru import logger
+from typing_extensions import Self
+
+from imbue_core.async_monkey_patches import log_exception
+from imbue_core.constants import ExceptionPriority
+from imbue_core.context_managers import call_on_exit
+from imbue_core.errors import ExpectedError
+from imbue_core.event_utils import CompoundEvent
+from imbue_core.event_utils import MutableEvent
+from imbue_core.event_utils import ReadOnlyEvent
+from imbue_core.log_utils import DETAIL
+from imbue_core.log_utils import TRACE
+from imbue_core.pydantic_serialization import FrozenModel
+
+# Received a shutdown signal
+SUBPROCESS_STOPPED_BY_REQUEST_EXIT_CODE = -9999
+
+SSH_ERROR_RETURN_CODE = 255
+
+
+class HasStdoutAndStderr(Protocol):
+ stdout: bytes
+ stderr: bytes
+
+
+class HasSshLogs(HasStdoutAndStderr, Protocol):
+ ssh_logs: str | None
+
+
+# Useful for streaming logs from a subprocess which may themselves be generated from log lines.
+def log_subprocess_output_line_for_sculptor(
+ output_line: str,
+ relog_loguru_lines: bool = False,
+ is_logging_without_loguru_formatting: bool = False,
+) -> None:
+ return log_subprocess_output_line(
+ output_line=output_line,
+ relog_loguru_lines=relog_loguru_lines,
+ is_logging_without_loguru_formatting=is_logging_without_loguru_formatting,
+ is_logging_traced=True,
+ )
+
+
+def log_subprocess_output_line(
+ output_line: str,
+ relog_loguru_lines: bool = False,
+ is_logging_without_loguru_formatting: bool = False,
+ # TODO: remove this -- Sculptor code should not be calling this function. Will be fixed in a followup PR.
+ is_logging_traced: bool = False,
+) -> None:
+ log_level = TRACE if is_logging_traced else DETAIL
+ output_line = output_line.rstrip("\n")
+ # very brittle parsing of log format for recursive logging: ef460144-072f-4b74-a712-0f728fdd3f50
+ if len(output_line) >= 36 and output_line[4] == "-" and output_line[7] == "-" and output_line[34:36] == "Tuple ":
+ # these lines have already been logged in the child; only relog them if we really want to.
+ if relog_loguru_lines:
+ logger.opt(raw=True).log(log_level, output_line.rstrip("\n") + "\n")
+ else:
+ if is_logging_without_loguru_formatting:
+ logger.opt(raw=True).log(log_level, output_line.rstrip("\n") + "\n")
+ else:
+ logger.log(log_level, "> " + output_line)
+
+
+def maybe_truncate_middle(output: str, size: int) -> str:
+ assert size > 1000, "This doesn't handle small sizes nicely"
+ if len(output) < size:
+ return output
+ # Note: not doing any sort of escaping or special formatting because this should only be for human consumption
+ truncate_message = f"\n... OUTPUT TRUNCATED DUE TO BEING OVER {size:_} CHARACTERS ...\n"
+ truncate_size = (size - len(truncate_message)) // 2 - 1
+ return output[:truncate_size] + truncate_message + output[-truncate_size:]
+
+
+def _stdout_str(has_stdout: "HasStdoutAndStderr") -> str:
+ return has_stdout.stdout.decode("utf-8", errors="replace")
+
+
+def _stderr_str(has_stderr: "HasStdoutAndStderr") -> str:
+ return has_stderr.stderr.decode("utf-8", errors="replace")
+
+
+def _create_output_from_stdout_and_stderr(
+ has_stdout_and_stderr: "HasStdoutAndStderr",
+) -> str:
+ return _stdout_str(has_stdout_and_stderr) + _stderr_str(has_stdout_and_stderr)
+
+
+def _create_output_from_stdout_and_stderr_and_ssh_logs(has_ssh_logs: HasSshLogs) -> str:
+ without_ssh_logs = _create_output_from_stdout_and_stderr(has_ssh_logs)
+ if has_ssh_logs.ssh_logs is None:
+ return without_ssh_logs
+ return has_ssh_logs.ssh_logs + without_ssh_logs
+
+
+@attr.s(auto_exc=True, auto_attribs=True)
+class CommandError(Exception):
+ returncode: int
+ stdout: bytes
+ stderr: bytes
+ command: str
+ is_output_already_logged: bool
+
+ stdout_str = property(_stdout_str)
+ stderr_str = property(_stderr_str)
+ output = cached_property(_create_output_from_stdout_and_stderr)
+
+ def __str__(self) -> str:
+ s = f"Command failed with return code {self.returncode}. command=`{self.command}`"
+ if not self.is_output_already_logged:
+ # TODO: Consider making truncation configurable
+ maybe_truncated_output = maybe_truncate_middle(self.output, 8_000)
+ s += f"\noutput:\n{maybe_truncated_output}"
+ return s
+
+
+@attr.s(auto_exc=True, auto_attribs=True)
+class RemoteCommandError(CommandError):
+ """Remotely executed command failure not due to ssh, meaning `returncode not in (0, 255)`."""
+
+ # TODO: include a machine on this so that we can print out the actual hostname!
+
+ # We make these errors slightly differently from CommandErrors -
+ # where those have the full command line as command, in this we set that to be just the remote command and this var will hold the ssh command.
+ ssh_command: str
+ ssh_logs: str | None
+
+ output = cached_property(_create_output_from_stdout_and_stderr_and_ssh_logs)
+
+ def full_command(self) -> str:
+ return self.ssh_command + " " + self.command
+
+ def __str__(self) -> str:
+ s = f"Remote command failed with returncode {self.returncode}. ssh_command= `{self.ssh_command}`, command=`{self.command}`"
+ if not self.is_output_already_logged:
+ maybe_truncated_output = maybe_truncate_middle(self.output, 8_000)
+ s += f"\noutput:\n{maybe_truncated_output}"
+ if self.ssh_logs is not None:
+ maybe_truncated_ssh_logs = maybe_truncate_middle(self.ssh_logs, 8_000)
+ s += f"\nssh_logs:\n{maybe_truncated_ssh_logs}"
+ return s
+
+
+@attr.s(auto_exc=True, auto_attribs=True)
+class SSHConnectionError(CommandError):
+ """Error for ssh connections that return 255.
+
+ No other shell commands should exit 255, as it will be conflated with ssh's error code.
+ """
+
+ # TODO: include a machine on this so that we can print out the actual hostname!
+
+ returncode = SSH_ERROR_RETURN_CODE
+ # We make these errors slightly differently from CommandErrors -
+ # where those have the full command line as command, in this we set that to be just the remote command and this var will hold the ssh command.
+ ssh_command: str
+
+ ssh_logs: str | None
+
+ output = cached_property(_create_output_from_stdout_and_stderr_and_ssh_logs)
+
+ _CONFLATION_WARNING = "WARNING: the remote command may have returned 255 rather than SSH -- Don't do that!"
+
+ def __str__(self) -> str:
+ return f"SSH failed to connect with returncode {self.returncode}. ssh_command= `{self.ssh_command}`, command=`{self.command}`, ssh_logs=`{self.ssh_logs}`\n{self._CONFLATION_WARNING}"
+
+
+@attr.s(auto_attribs=True, kw_only=True)
+class CompletedProcess:
+ """
+ Mostly a reimplementation of subprocess.CompletedProcess but allows us to deal with some GI-specific concerns.
+ A class to make process results easier to work with for us. We have a couple concerns that are different from typical:
+ We run commands over SSH a lot and care about making sure that those errors clearly show both the command being run and the host being run on.
+ """
+
+ returncode: int
+ stdout: bytes
+ stderr: bytes
+ command: str
+ is_output_already_logged: bool
+
+ stdout_str = property(_stdout_str)
+ stderr_str = property(_stderr_str)
+ output = cached_property(_create_output_from_stdout_and_stderr)
+
+ def check(self) -> Self:
+ if self.returncode != 0:
+ error = CommandError(
+ command=self.command,
+ returncode=self.returncode,
+ stdout=self.stdout,
+ stderr=self.stderr,
+ is_output_already_logged=self.is_output_already_logged,
+ )
+ if "output" in self.__dict__:
+ # We've already calculated the output, so we can just set it here.
+ error.output = self.output
+ raise error
+ # So that this can be chained. For example,
+ # hostname = run_local_command("hostname").check().stdout
+ return self
+
+
+class ProcessError(ExpectedError):
+ def __init__(
+ self,
+ command: tuple[str, ...],
+ stdout: str,
+ stderr: str,
+ returncode: int | None = None,
+ is_output_already_logged: bool | None = False,
+ message: str | None = "Command failed with non-zero exit code",
+ ) -> None:
+ self.returncode = returncode
+ self.stdout = stdout
+ self.stderr = stderr
+ self.command = command
+ self.is_output_already_logged = is_output_already_logged
+ self.message = message
+
+ def describe(self, is_output_included: bool, output_truncation: int | None = 8_000) -> str:
+ command = " ".join(shlex.quote(arg) for arg in self.command)
+ s = f"{self.message} {self.returncode}. command=`{command}`"
+ if is_output_included:
+ # TODO: Consider making truncation configurable
+ output = self.stdout + "\n" + self.stderr
+ maybe_truncated_output = maybe_truncate_middle(output, output_truncation) if output_truncation else output
+ s += f"\noutput:\n{maybe_truncated_output}"
+ return s
+
+ def __str__(self) -> str:
+ return self.describe(is_output_included=True)
+
+
+class ProcessTimeoutError(ProcessError):
+ def __init__(
+ self,
+ command: tuple[str, ...],
+ stdout: str,
+ stderr: str,
+ is_output_already_logged: bool = False,
+ ) -> None:
+ super().__init__(
+ command,
+ stdout,
+ stderr,
+ None,
+ is_output_already_logged=is_output_already_logged,
+ message="Command timed out",
+ )
+
+
+class ProcessSetupError(ProcessError):
+ def __init__(
+ self,
+ command: tuple[str, ...],
+ stdout: str,
+ stderr: str,
+ is_output_already_logged: bool = False,
+ ) -> None:
+ super().__init__(
+ command,
+ stdout,
+ stderr,
+ None,
+ is_output_already_logged=is_output_already_logged,
+ message="Command failed to start",
+ )
+
+
+class FinishedProcess(FrozenModel):
+ """
+ Mostly a reimplementation of subprocess.CompletedProcess but allows us to deal with some GI-specific concerns.
+ A class to make process results easier to work with for us. We have a couple concerns that are different from typical:
+ We run commands over SSH a lot and care about making sure that those errors clearly show both the command being run and the host being run on.
+ """
+
+ returncode: int | None = None
+ stdout: str
+ stderr: str
+ command: tuple[str, ...]
+ is_timed_out: bool = False
+ is_output_already_logged: bool
+
+ def check(self) -> Self:
+ if self.is_timed_out:
+ raise ProcessTimeoutError(
+ command=self.command,
+ stdout=self.stdout,
+ stderr=self.stderr,
+ is_output_already_logged=self.is_output_already_logged,
+ )
+ if self.returncode != 0:
+ raise ProcessError(
+ command=self.command,
+ returncode=self.returncode,
+ stdout=self.stdout,
+ stderr=self.stderr,
+ is_output_already_logged=self.is_output_already_logged,
+ )
+ # So that this can be chained. For example,
+ # hostname = run_local_command("hostname").check().stdout
+ return self
+
+
+@attr.s(auto_attribs=True, kw_only=True)
+class RemoteCompletedProcess(CompletedProcess):
+ """
+ A remote completed process. Beyond CompletedProcess, includes ssh information.
+ """
+
+ ssh_command: str
+
+ # The ssh logs if they were split out.
+ ssh_logs: str | None = None
+
+ output = cached_property(_create_output_from_stdout_and_stderr_and_ssh_logs)
+
+ def check(self) -> Self:
+ if self.returncode == SSH_ERROR_RETURN_CODE:
+ ssh_connection_error = SSHConnectionError(
+ command=self.command,
+ returncode=SSH_ERROR_RETURN_CODE,
+ stdout=self.stdout,
+ stderr=self.stderr,
+ ssh_command=self.ssh_command,
+ is_output_already_logged=self.is_output_already_logged,
+ ssh_logs=self.ssh_logs,
+ )
+ if "output" in self.__dict__:
+ # We've already calculated the output, so we can just set it here.
+ ssh_connection_error.output = self.output
+ raise ssh_connection_error
+ if self.returncode != 0:
+ remote_command_error = RemoteCommandError(
+ command=self.command,
+ ssh_command=self.ssh_command,
+ returncode=self.returncode,
+ stdout=self.stdout,
+ stderr=self.stderr,
+ is_output_already_logged=self.is_output_already_logged,
+ ssh_logs=self.ssh_logs,
+ )
+ if "output" in self.__dict__:
+ # We've already calculated the output, so we can just set it here.
+ remote_command_error.output = self.output
+ raise remote_command_error
+ return self
+
+
+_READ_SIZE: Final[int] = 2**20
+
+
+@attr.s(auto_attribs=True)
+class PartialOutputContainer:
+ """A helper class to make reconstructing log lines returned by pipe.read() easier."""
+
+ buffer: BytesIO = attr.ib(factory=BytesIO)
+ # Note: This in-memory line could become huge if no newlines are output
+ in_progress_line: bytearray = attr.ib(factory=bytearray)
+ on_complete_line: Callable[[str], None] | None = None
+
+ def write(self, output: bytes) -> None:
+ """`output` is the output of pipe.read(), ie a string that may contain newlines."""
+ self.buffer.write(output)
+ on_complete_line = self.on_complete_line
+ if on_complete_line is None:
+ # If we don't have a callback, we don't need to do anything else.
+ return
+
+ lines = output.splitlines(keepends=True)
+ for line in lines:
+ self.in_progress_line.extend(line)
+ if line.endswith((b"\n", b"\r")):
+ on_complete_line(self.in_progress_line.decode("utf-8", errors="replace"))
+ self.in_progress_line.clear()
+
+ def get_complete_output(self) -> bytes:
+ return self.buffer.getvalue()
+
+
+@attr.s(auto_attribs=True)
+class OutputGatherer:
+ stdout: IO[bytes]
+ stderr: IO[bytes]
+ stdout_container: PartialOutputContainer
+ stderr_container: PartialOutputContainer
+ shutdown_event: ReadOnlyEvent
+
+ @classmethod
+ def build_from_popen(
+ cls,
+ popen: subprocess.Popen[bytes],
+ on_complete_line_from_stdout: Callable[[str], None] | None,
+ on_complete_line_from_stderr: Callable[[str], None] | None,
+ shutdown_event: ReadOnlyEvent,
+ ) -> Self:
+ stdout = popen.stdout
+ stderr = popen.stderr
+ assert stdout is not None
+ assert stderr is not None
+ # this makes reads on process.stdout nonblocking
+ os.set_blocking(stdout.fileno(), False)
+ os.set_blocking(stderr.fileno(), False)
+
+ return cls(
+ stdout=stdout,
+ stderr=stderr,
+ stdout_container=PartialOutputContainer(on_complete_line=on_complete_line_from_stdout),
+ stderr_container=PartialOutputContainer(on_complete_line=on_complete_line_from_stderr),
+ shutdown_event=shutdown_event,
+ )
+
+ def gather_output(self) -> None:
+ is_more_from_stdout = True
+ is_more_from_stderr = True
+ # We may drop some output if the shutdown event is set, but that's okay.
+ while not self.shutdown_event.is_set() and (is_more_from_stdout or is_more_from_stderr):
+ # We always attempt to read from both streams to avoid starvation.
+ partial_stdout = self.stdout.read(_READ_SIZE)
+ if partial_stdout is not None:
+ self.stdout_container.write(partial_stdout)
+ is_more_from_stdout = len(partial_stdout) == _READ_SIZE
+ else:
+ is_more_from_stdout = False
+ partial_stderr = self.stderr.read(_READ_SIZE)
+ if partial_stderr is not None:
+ self.stderr_container.write(partial_stderr)
+ is_more_from_stderr = len(partial_stderr) == _READ_SIZE
+ else:
+ is_more_from_stderr = False
+
+ def get_output(self) -> tuple[bytes, bytes]:
+ return (
+ self.stdout_container.get_complete_output(),
+ self.stderr_container.get_complete_output(),
+ )
+
+ def get_incomplete_lines(self) -> tuple[str, str]:
+ return self.stdout_container.in_progress_line.decode(
+ "utf-8", errors="replace"
+ ), self.stderr_container.in_progress_line.decode("utf-8", errors="replace")
+
+
+def _shutdown_popen(process: subprocess.Popen[bytes], command: str, shutdown_timeout_sec: float) -> int | None:
+ logger.debug(
+ f"run_local_command: aborting command (via sigterm to {process.pid}) due to signal...\n",
+ command=truncate_command(command, 500),
+ )
+ # this sends SIGTERM, which is "the normal way to politely ask a program to terminate"
+ process.terminate()
+ try:
+ process.wait(timeout=shutdown_timeout_sec)
+ return process.returncode
+ except subprocess.TimeoutExpired as e:
+ extra = {"command": command, "shutdown_timeout_sec": str(shutdown_timeout_sec)}
+ log_exception(
+ e,
+ "process didn't die within shutdown_timeout_sec of SIGTERM",
+ extra=extra,
+ priority=ExceptionPriority.LOW_PRIORITY,
+ )
+ # this sends SIGKILL which immediately kills the process
+ process.kill()
+ try:
+ process.wait(timeout=2)
+ return process.returncode
+ except subprocess.TimeoutExpired as e:
+ log_exception(
+ e,
+ "process didn't die after kill()",
+ extra=extra,
+ priority=ExceptionPriority.LOW_PRIORITY,
+ )
+ return None
+
+
+def _log_input_command(command: str) -> None:
+ input_lines = command.splitlines()
+ truncation_context = 2
+ is_worth_truncating = len(input_lines) > 3 * truncation_context
+ if is_worth_truncating:
+ input_lines = (
+ input_lines[:truncation_context]
+ + [" (...content truncated...)"]
+ + input_lines[-truncation_context:]
+ )
+ for line in input_lines:
+ logger.trace("< " + line)
+
+
+def _is_timeout(timeout_time: float | None = None) -> bool:
+ if timeout_time is None:
+ return False
+ else:
+ return time.time() > timeout_time
+
+
+def run_local_command(
+ command: str,
+ is_checked: bool = True,
+ timeout: float | None = None,
+ trace_output: bool = True,
+ cwd: Path | None = None,
+ trace_on_complete_line_callback: Callable[[str], None] | None = log_subprocess_output_line,
+ trace_log_context: Mapping[str, object] | None = None,
+ shutdown_event: Event | CompoundEvent | None = None,
+ shutdown_timeout_sec: float = 30.0,
+) -> CompletedProcess:
+ """
+ implementation notes:
+ - this function is really tricky to implement well! check with josh, bawr, or zack before making nontrivial changes
+ - the reason it's tricky is that we need to both monitor the shutdown event, while also reading the subprocess
+ output in realtime to allow realtime log tracing of the output.
+ - we previously had an implementation that used a helper thread to read the output, but never seemed to shutdown
+ cleanly and left a mess of warnings in the logs, even though it seemed to be implemented properly.
+ - the current implementation aims to use just the main thread to avoid this. but then you need to be very careful
+ to avoid anything blocking.
+ - thus we set the pipe to nonblocking mode so that reads are nonblocking, and we also don't use readline()
+ as that could potentially block/deadlock if the process prints long lines with no newlines.
+ - don't redirect the process output to a file, as then the command may detect an interactive terminal and use
+ line buffering.
+ - (bawr) DO NOT CHANGE STDIN TO ANYTHING BESIDES DEV NULL, that'll cause race conditions.
+ - potentially there's a cleaner implementation using asyncio, but better the devil you know, yknow?
+ """
+ trace_log_context = trace_log_context if trace_log_context is not None else {}
+ shutdown_event = shutdown_event or Event()
+
+ if shutdown_event.is_set():
+ result = CompletedProcess(
+ returncode=SUBPROCESS_STOPPED_BY_REQUEST_EXIT_CODE,
+ stdout=b"",
+ stderr=b"",
+ command=command,
+ is_output_already_logged=trace_output,
+ )
+ if is_checked:
+ result.check()
+ return result
+
+ if trace_output:
+ _log_input_command(command)
+
+ # with bufsize 0 and not setting text, encoding, or errors, the pipe objects will be RawIOBase.
+ # use read(2**30) or similar if using this for a nonblocking read.
+ # with nonzero bufsize, they will be BufferedIOBase. use read1() if using this for a nonblocking read.
+ # with text, encoding, or errors, they will be TextIOBase.
+ # this doesn't seem to play nice with nonblocking mode and read().
+ process = subprocess.Popen(
+ command,
+ cwd=cwd,
+ shell=True,
+ executable="/bin/bash",
+ bufsize=0,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env={**os.environ, "TERM": "dumb"},
+ )
+
+ gatherer = OutputGatherer.build_from_popen(
+ process,
+ on_complete_line_from_stdout=(trace_on_complete_line_callback if trace_output else None),
+ on_complete_line_from_stderr=(trace_on_complete_line_callback if trace_output else None),
+ shutdown_event=shutdown_event,
+ )
+
+ timeout_time = time.time() + timeout if timeout is not None else None
+
+ with logger.contextualize(**trace_log_context):
+ while not shutdown_event.wait(0.001) and not _is_timeout(timeout_time):
+ maybe_exit_code = process.poll()
+ gatherer.gather_output()
+ if maybe_exit_code is not None:
+ exit_code = maybe_exit_code
+ break
+ else:
+ # The shutdown event was set or a timeout limit has been reached,
+ # so we should shutdown the process.
+ _shutdown_popen(process, command, shutdown_timeout_sec)
+ exit_code = SUBPROCESS_STOPPED_BY_REQUEST_EXIT_CODE
+
+ stdout, stderr = gatherer.get_output()
+ result = CompletedProcess(
+ returncode=exit_code,
+ stdout=stdout,
+ stderr=stderr,
+ command=command,
+ is_output_already_logged=trace_output,
+ )
+ if is_checked:
+ result.check()
+
+ return result
+
+
+# NOTE: this function is largely duplicated with the above, but subtle changes to the types
+# most of the logic should be the same though, with the exception that we assume stdout and stderr are strings
+def run_local_command_modern_version(
+ command: Sequence[str],
+ is_checked: bool = True,
+ timeout: float | None = None,
+ trace_output: bool = False,
+ cwd: Path | None = None,
+ # this is called for each line, including the last line even if it doesn't end with a newline
+ # if there is no output, it is never called
+ trace_on_line_callback: Callable[[str, bool], None] | None = None,
+ trace_log_context: Mapping[str, object] | None = None,
+ shutdown_event: MutableEvent | None = None,
+ shutdown_timeout_sec: float = 30.0,
+ poll_time: float = 0.01,
+ env: Mapping[str, str] | None = None,
+ # This callback gets called once either the process is running or it failed to start.
+ # The argument is None on success, or the Exception on failure.
+ on_initialization_complete: Callable[[BaseException | None], None] = lambda success: None,
+) -> FinishedProcess:
+ """
+ implementation notes:
+ - this function is really tricky to implement well! check with josh, bawr, or zack before making nontrivial changes
+ - the reason it's tricky is that we need to both monitor the shutdown event, while also reading the subprocess
+ output in realtime to allow realtime log tracing of the output.
+ - we previously had an implementation that used a helper thread to read the output, but never seemed to shutdown
+ cleanly and left a mess of warnings in the logs, even though it seemed to be implemented properly.
+ - the current implementation aims to use just the main thread to avoid this. but then you need to be very careful
+ to avoid anything blocking.
+ - thus we set the pipe to nonblocking mode so that reads are nonblocking, and we also don't use readline()
+ as that could potentially block/deadlock if the process prints long lines with no newlines.
+ - don't redirect the process output to a file, as then the command may detect an interactive terminal and use
+ line buffering.
+ - (bawr) DO NOT CHANGE STDIN TO ANYTHING BESIDES DEV NULL, that'll cause race conditions.
+ - potentially there's a cleaner implementation using asyncio, but better the devil you know, yknow?
+ - if `env` is set, will overwrite contents passed into subprocess.Popen
+
+ raises ProcessError
+ """
+ with call_on_exit(on_initialization_complete):
+ trace_log_context = trace_log_context if trace_log_context is not None else {}
+ shutdown_event = shutdown_event or Event()
+ command_as_string = " ".join(shlex.quote(arg) for arg in command)
+ # NOTE: We create the process even when shutdown_event is already set.
+ # It will be terminated almost immediately after starting but the benefit is that the behavior stays consistent.
+
+ if trace_output:
+ _log_input_command(command_as_string)
+
+ # with bufsize 0 and not setting text, encoding, or errors, the pipe objects will be RawIOBase.
+ # use read(2**30) or similar if using this for a nonblocking read.
+ # with nonzero bufsize, they will be BufferedIOBase. use read1() if using this for a nonblocking read.
+ # with text, encoding, or errors, they will be TextIOBase.
+ # this doesn't seem to play nice with nonblocking mode and read().
+ try:
+ process = subprocess.Popen(
+ command,
+ cwd=cwd,
+ bufsize=0,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=env,
+ )
+ except (OSError, ValueError) as e:
+ # Raise setup error if process fails to start.
+ # OSError: subprocess.Popen fails to start the requested command
+ # ValueError: subprocess.Popen malformed arguments
+ raise ProcessSetupError(
+ command=tuple(command),
+ stdout="",
+ stderr="",
+ is_output_already_logged=trace_output,
+ ) from e
+
+ if trace_output:
+ # NOTE: We could probably condense the truth value of trace_output to
+ # just be bool(trace_on_line_callback). However, there's a lot of scary code
+ # that depends on these values that I didn't want to audit just now.
+ assert trace_on_line_callback, "Must pass trace_on_line_callback"
+ on_complete_line_from_stdout = lambda line: trace_on_line_callback(line, True)
+ on_complete_line_from_stderr = lambda line: trace_on_line_callback(line, False)
+ else:
+ on_complete_line_from_stdout = None
+ on_complete_line_from_stderr = None
+
+ gatherer = OutputGatherer.build_from_popen(
+ process,
+ on_complete_line_from_stdout=on_complete_line_from_stdout,
+ on_complete_line_from_stderr=on_complete_line_from_stderr,
+ shutdown_event=shutdown_event,
+ )
+
+ timeout_time = time.time() + timeout if timeout is not None else None
+
+ with logger.contextualize(**trace_log_context):
+ while not shutdown_event.wait(poll_time) and not _is_timeout(timeout_time):
+ maybe_exit_code = process.poll()
+ gatherer.gather_output()
+ if maybe_exit_code is not None:
+ exit_code = maybe_exit_code
+ break
+ else:
+ # The shutdown event was set or a timeout limit has been reached,
+ # so we should shutdown the process.
+ exit_code = _shutdown_popen(process, command_as_string, shutdown_timeout_sec)
+
+ stdout, stderr = gatherer.get_output()
+
+ # send the final incomplete lines as well
+ incomplete_stdout_line, incomplete_stderr_line = gatherer.get_incomplete_lines()
+ if incomplete_stdout_line:
+ if trace_on_line_callback:
+ trace_on_line_callback(incomplete_stdout_line, True)
+ if incomplete_stderr_line:
+ if trace_on_line_callback:
+ trace_on_line_callback(incomplete_stderr_line, False)
+
+ result = FinishedProcess(
+ returncode=exit_code,
+ stdout=stdout.decode("utf-8", errors="replace"),
+ stderr=stderr.decode("utf-8", errors="replace"),
+ command=tuple(command),
+ is_timed_out=_is_timeout(timeout_time),
+ is_output_already_logged=trace_output,
+ )
+ if is_checked:
+ result.check()
+
+ return result
+
+
+def run_remote_command(
+ machine_ssh_command: str,
+ remote_command: str,
+ is_checked: bool = True,
+ trace_output: bool = True,
+ trace_on_complete_line_callback: Callable[[str], None] = log_subprocess_output_line,
+ trace_log_context: Mapping[str, object] | None = None,
+ shutdown_event: Event | CompoundEvent | None = None,
+ shutdown_timeout_sec: float = 30.0,
+) -> RemoteCompletedProcess:
+ """
+ :raises SSHConnectionError: if `is_checked and returncode == 255` (the ssh reserved error code)
+ :raises RemoteCommandError: if `is_checked and returncode not in (0, 255)`
+ """
+ # Please please please use proper quoting tools
+ escaped_remote_command = shlex.quote(remote_command)
+ result = run_local_command(
+ f"{machine_ssh_command} {escaped_remote_command}",
+ is_checked=False,
+ trace_output=trace_output,
+ trace_on_complete_line_callback=trace_on_complete_line_callback,
+ trace_log_context=trace_log_context,
+ shutdown_event=shutdown_event,
+ shutdown_timeout_sec=shutdown_timeout_sec,
+ )
+
+ remote_result = RemoteCompletedProcess(
+ returncode=result.returncode,
+ stdout=result.stdout,
+ stderr=result.stderr,
+ ssh_command=machine_ssh_command,
+ command=remote_command,
+ is_output_already_logged=result.is_output_already_logged,
+ )
+ if is_checked:
+ remote_result.check()
+ return remote_result
+
+
+def truncate_command(command: str, num_chars: int = 2000) -> str:
+ """Truncates a command to include just the first `num_chars` of the first line."""
+ truncated = False
+ split_command = command.split("\n")
+ if len(split_command) > 1:
+ truncated = True
+ command = split_command[0]
+ if len(command) > num_chars:
+ truncated = True
+ command = command[:num_chars]
+ if truncated:
+ command += "... (truncated)"
+ return command
diff --git a/imbue_core/imbue_core/suggestions.py b/imbue_core/imbue_core/suggestions.py
@@ -0,0 +1,85 @@
+from typing import Annotated
+from typing import Any
+
+from pydantic import AnyUrl
+from pydantic import Field
+from pydantic import Tag
+
+from imbue_core.agents.data_types.ids import ObjectID
+from imbue_core.data_types import IdentifiedVerifyIssue
+from imbue_core.pydantic_serialization import SerializableModel
+from imbue_core.pydantic_serialization import build_discriminator
+
+
+class SuggestionAction(SerializableModel):
+ object_type: str
+ # more important -> lower number. Think about it as "my first priority is to..."
+ # different actions *may* share the same priority rank, in which case the ties will be broken by a canonical ordering of importance
+ priority_rank: int = 0
+
+
+class UseSuggestionAction(SuggestionAction):
+ object_type: str = "UseSuggestionAction"
+ content: str
+
+
+class VisitLinkSuggestionAction(SuggestionAction):
+ object_type: str = "VisitLinkSuggestionAction"
+ url: AnyUrl
+ link_text: str
+
+
+SuggestionActionTypes = Annotated[
+ Annotated[UseSuggestionAction, Tag("UseSuggestionAction")]
+ | Annotated[VisitLinkSuggestionAction, Tag("VisitLinkSuggestionAction")],
+ build_discriminator(),
+]
+
+
+# FIXME(johnny): move to imbue_core.imbue_cli.action
+class CheckOutputID(ObjectID):
+ tag: str = "chko"
+
+
+class Suggestion(SerializableModel):
+ # TODO: this is here because we're treating this like an issue, but we may not want to do that
+ object_type: str = "Suggestion"
+ # TODO: remove the default factory once we have properly migrated to the new check output protocol
+ # Also see sculptor/sculptor/tasks/handlers/run_agent/checks/check_process.py::275
+ id: CheckOutputID = Field(default_factory=CheckOutputID)
+ title: str = Field(min_length=1)
+ description: str = ""
+ # (if sculptor speaks true) pyre doesn't like float values for gt/ge/le because... it's looking for def __gt__(self: T, __other: T) -> bool and float has def __gt__(self, value: float, /) -> bool
+
+ # will probably be technically implemented by asking an LLM to come up with a number between 1 and 10,
+ # so probably really will range between 0.1 and 1.0
+ severity_score: float = Field(
+ ge=0.0, # pyre-ignore[6]
+ le=1.0, # pyre-ignore[6]
+ description="A score between 0.0 and 1.0 indicating how severe the issue is that this suggestion addresses.",
+ )
+ # unlike the severity, this is about how sure we are that this is a good suggestion,
+ # for example, you can be confident that there is a real problem (high confidence_score)
+ # but it might be about some edge case that doesn't matter (low severity_score)
+ confidence_score: float = Field(
+ ge=0.0, # pyre-ignore[6]
+ le=1.0, # pyre-ignore[6]s
+ description="A score between 0.0 and 1.0 indicating how confident we are that this suggestion addresses a real issue.",
+ )
+ # these are the possible actions that the user can take with this suggestion
+ # for right now, the only ones implemented are "USE" and "COPY"
+ actions: tuple[SuggestionActionTypes, ...]
+ original_issues: tuple[IdentifiedVerifyIssue, ...]
+
+
+# FIXME(johnny): move these to the right location, use the right types, etc -- these are just placeholders to demonstrate how this works
+# FIXME(johnny): migrate to just using the ActionOutputUnion from imbue_core.imbue_cli.action
+CheckOutputTypes = Annotated[
+ Annotated[Suggestion, Tag("Suggestion")],
+ build_discriminator(),
+]
+
+
+# FIXME(johnny): remove this once we move to using the ActionOutputUnion from imbue_core.imbue_cli.action
+def is_check_output(output: Any) -> bool:
+ return isinstance(output, Suggestion)
diff --git a/imbue_core/imbue_core/test_repo_utils.py b/imbue_core/imbue_core/test_repo_utils.py
@@ -0,0 +1,57 @@
+"""Utilities for tests involving a git repository."""
+
+import subprocess
+import tempfile
+from pathlib import Path
+from typing import Generator
+
+from imbue_core.common import get_temp_dir
+from imbue_core.git import LocalGitRepo
+from imbue_core.git import get_git_repo_root
+from imbue_core.test_utils import create_temp_dir
+
+
+def make_simple_test_git_repo() -> Generator[Path, None, None]:
+ """Create a temporary git repository for testing.
+
+ Creates a simple repository with two commits and two files.
+ - Initial commit with file1.txt
+ - Initial commit file2 with file2.txt
+ """
+ with tempfile.TemporaryDirectory() as temp_dir:
+ repo_path = Path(temp_dir)
+
+ # Initialize git repo
+ subprocess.run(["git", "init"], cwd=repo_path, check=True, capture_output=True)
+ subprocess.run(
+ ["git", "config", "user.email", "test@example.com"],
+ cwd=repo_path,
+ check=True,
+ )
+ subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo_path, check=True)
+
+ # Create initial commit
+ (repo_path / "file1.txt").write_text("initial content")
+ subprocess.run(["git", "add", "file1.txt"], cwd=repo_path, check=True)
+ subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=repo_path, check=True)
+ (repo_path / "file2.txt").write_text("initial content file 2")
+ subprocess.run(["git", "add", "file2.txt"], cwd=repo_path, check=True)
+ subprocess.run(["git", "commit", "-m", "Initial commit file2"], cwd=repo_path, check=True)
+
+ yield repo_path
+
+
+def make_mock_repo(path: Path, is_recreating: bool = False) -> Generator[LocalGitRepo, None, None]:
+ mock_repo = LocalGitRepo(base_path=path)
+ with create_temp_dir(root_dir=Path(get_temp_dir())) as temp_dir:
+ temp_repo = mock_repo.sync_copy_repo(temp_dir)
+ temp_repo.sync_configure_git(
+ git_user_name="AGI (Automated Software Inspector)",
+ git_user_email="the_true_AGI@running.pytest.com",
+ is_recreating=is_recreating,
+ )
+ yield temp_repo
+
+
+def make_test_data_mock_repo() -> Generator[LocalGitRepo, None, None]:
+ yield from make_mock_repo(get_git_repo_root() / "imbue/imbue/test_data/mock_repo", is_recreating=False)
diff --git a/imbue_core/imbue_core/test_utils.py b/imbue_core/imbue_core/test_utils.py
@@ -0,0 +1,88 @@
+import contextlib
+import shutil
+import tempfile
+import time
+from pathlib import Path
+from typing import AsyncGenerator
+from typing import Callable
+from typing import Generator
+
+from loguru import logger
+from syrupy.assertion import SnapshotAssertion
+
+from imbue_core.agents.llm_apis.data_types import CachedCostedLanguageModelResponse
+from imbue_core.agents.llm_apis.data_types import CachedCostedModelResponse
+from imbue_core.agents.llm_apis.data_types import CachedCountTokensResponse
+from imbue_core.agents.llm_apis.llm_testing_utils import check_llm_responses_in_cache
+from imbue_core.caching import AsyncCache
+from imbue_core.llm_testing_utils import get_cache_file_from_snapshot
+from imbue_core.llm_testing_utils import get_count_tokens_cache_file_from_snapshot
+from imbue_core.llm_testing_utils import preload_llm_cache
+from imbue_core.llm_testing_utils import record_llm_responses_in_cache
+
+
+def info_if_not_quiet(quiet: bool, message: str) -> None:
+ if not quiet:
+ logger.info(message)
+
+
+@contextlib.contextmanager
+def create_temp_dir(root_dir: Path) -> Generator[Path, None, None]:
+ with tempfile.TemporaryDirectory(dir=root_dir) as temp_dir:
+ yield Path(temp_dir)
+ shutil.rmtree(temp_dir)
+
+
+def wait_until(condition: Callable[[], bool], timeout: float = 5.0, interval: float = 0.5) -> None:
+ start_time = time.monotonic()
+ while True:
+ if condition():
+ return
+ if time.monotonic() - start_time > timeout:
+ raise TimeoutError("Condition not met within timeout period")
+ time.sleep(interval)
+
+
+async def make_llm_cache_with_snapshot_core(
+ snapshot: SnapshotAssertion,
+ json_cache_file: Path,
+ value_cls: type[CachedCostedModelResponse],
+ quiet: bool = True,
+ suffix: str = "",
+) -> AsyncGenerator[Path, None]:
+ info_if_not_quiet(quiet, f"Using llm_cache_pathfixture: {json_cache_file=}")
+
+ with tempfile.TemporaryDirectory() as cache_path_string:
+ cache_path = Path(cache_path_string)
+ cache_context = AsyncCache(cache_path, value_cls)
+
+ if not snapshot.session.update_snapshots:
+ if json_cache_file.exists():
+ await preload_llm_cache(json_cache_file, cache_context)
+
+ yield cache_path
+ info_if_not_quiet(quiet, "Finished with llm_cache_pathfixture, updating cache if needed.")
+
+ if snapshot.session.update_snapshots:
+ await record_llm_responses_in_cache(cache_context, json_cache_file)
+
+ await check_llm_responses_in_cache(snapshot, cache_context, suffix)
+ info_if_not_quiet(quiet, "Finished with llm_cache_pathfixture, checking cache.")
+
+
+async def make_llm_cache_with_snapshot(snapshot: SnapshotAssertion, quiet: bool = True) -> AsyncGenerator[Path, None]:
+ json_cache_file = get_cache_file_from_snapshot(snapshot)
+ async for path in make_llm_cache_with_snapshot_core(
+ snapshot, json_cache_file, CachedCostedLanguageModelResponse, quiet
+ ):
+ yield path
+
+
+async def make_count_tokens_cache_with_snapshot(
+ snapshot: SnapshotAssertion, quiet: bool = True
+) -> AsyncGenerator[Path, None]:
+ json_cache_file = get_count_tokens_cache_file_from_snapshot(snapshot)
+ async for path in make_llm_cache_with_snapshot_core(
+ snapshot, json_cache_file, CachedCountTokensResponse, quiet, "_count_tokens"
+ ):
+ yield path
diff --git a/imbue_core/imbue_core/testing_utils.py b/imbue_core/imbue_core/testing_utils.py
@@ -0,0 +1,189 @@
+import asyncio
+import inspect
+import os
+import shutil
+from contextlib import asynccontextmanager
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Any
+from typing import AsyncGenerator
+from typing import Callable
+from typing import ContextManager
+from typing import Coroutine
+from typing import Generator
+from typing import Iterable
+from typing import Protocol
+from typing import Sequence
+from typing import TYPE_CHECKING
+from typing import TypeVar
+from uuid import uuid4
+
+import anyio
+import pytest
+import pytest_asyncio
+from _pytest.fixtures import Config
+from _pytest.python import Function
+
+from imbue_core.common import get_temp_dir
+
+if TYPE_CHECKING:
+ # noinspection PyProtectedMember
+ from _pytest.fixtures import _ScopeName
+
+T = TypeVar("T")
+
+_TestFunc = Callable[..., None] | Callable[..., Coroutine[Any, Any, None]]
+
+
+def fixture(
+ fixture_function: Any | None = None,
+ *,
+ scope: "_ScopeName | Callable[[str, Config], _ScopeName]" = "function",
+ params: Iterable[object] | None = None,
+ autouse: bool = False,
+ ids: Sequence[object | None] | Callable[[Any], object | None] | None = None,
+) -> Any:
+ def decorator(function: Any) -> Any:
+ true_name = function.__name__[:-1]
+ if inspect.iscoroutinefunction(function):
+ return pytest_asyncio.fixture(
+ function,
+ name=true_name,
+ scope=scope,
+ params=params,
+ autouse=autouse,
+ ids=ids, # type: ignore
+ )
+ else:
+ return pytest.fixture(
+ function,
+ name=true_name,
+ scope=scope,
+ params=params,
+ autouse=autouse,
+ ids=ids,
+ )
+
+ if fixture_function is not None and callable(fixture_function):
+ return decorator(fixture_function)
+
+ return decorator
+
+
+def placeholder_param_for_mark(
+ marks: pytest.MarkDecorator | list[pytest.MarkDecorator],
+) -> object:
+ """Returns a param for annotating a fixture with marks.
+
+ It can be useful to add marks to a fixture that propagate to all functions that use it.
+ However, decorating a fixture function with "@pytest.mark.foo_bar" has no effect.
+
+ On the other hand, parameterized fixtures can have marks attached to each parameter;
+ thus a workaround is to parameterize the fixture with a single placeholder param.
+ Use this function like this:
+
+ @pytest.fixture(params=placeholder_param_for_mark(pytest.mark.foo_bar))
+
+ One slightly annoying side effect is that the param will show up in the test name.
+ We can get rid of it with pytest_collection_modifyitems,
+ but it's probably simpler to live with it.
+
+ You don't need this function if the fixture is already parameterized;
+ simply add the desired mark to the params:
+
+ @pytest.fixture(params=[pytest.param(0, marks=pytest.mark.foo_bar),
+ pytest.param(1, marks=pytest.mark.foo_bar)])
+
+ See https://github.com/pytest-dev/pytest/issues/1368 for more context.
+ """
+ return pytest.param("placeholder_param", marks=marks)
+
+
+def use(*args: Callable[..., Any]) -> Any:
+ true_names = [x.__name__[:-1] for x in args]
+ return pytest.mark.usefixtures(*true_names)
+
+
+def integration_test(function: _TestFunc) -> Any:
+ return pytest.mark.integration_test(function)
+
+
+def slow_integration_test(function: _TestFunc) -> Any:
+ return pytest.mark.slow_integration_test(function)
+
+
+class RequestFixture(Protocol[T]):
+ """Yes, there is a class called FixtureRequest, but the types are quite bad for it"""
+
+ node: Function
+ param: T
+
+
+@fixture
+def temp_file_path_() -> Generator[Path, None, None]:
+ with create_temp_file_path() as output:
+ yield output
+
+
+@fixture
+def temp_path_() -> Generator[Path, None, None]:
+ with temp_dir(get_temp_dir()) as output:
+ yield output
+
+
+def create_temp_file_path(cleanup: bool = True) -> ContextManager[Path]:
+ @contextmanager
+ def context() -> Generator[Path, None, None]:
+ random_id = uuid4()
+ output_path = os.path.join(get_temp_dir(), str(random_id))
+ try:
+ yield Path(output_path)
+ finally:
+ if cleanup and os.path.exists(output_path):
+ if os.path.isfile(output_path):
+ os.remove(output_path)
+ else:
+ shutil.rmtree(output_path)
+
+ # noinspection PyTypeChecker
+ return context()
+
+
+def temp_dir(base_dir: str, is_uuid_concatenated: bool = False) -> ContextManager[Path]:
+ @contextmanager
+ def context() -> Generator[Path, None, None]:
+ random_id = uuid4()
+ if is_uuid_concatenated:
+ output_path = Path(base_dir.rstrip("/") + "_" + str(random_id))
+ else:
+ output_path = Path(base_dir) / str(random_id)
+ output_path.mkdir(parents=True, exist_ok=True)
+ try:
+ yield output_path
+ finally:
+ if output_path.exists():
+ try:
+ shutil.rmtree(str(output_path))
+ except OSError:
+ os.unlink(str(output_path))
+
+ # noinspection PyTypeChecker
+ return context()
+
+
+@asynccontextmanager
+async def async_temp_dir(base_dir: str, is_uuid_concatenated: bool = False) -> AsyncGenerator[Path, None]:
+ random_id = uuid4()
+ if is_uuid_concatenated:
+ output_path = anyio.Path(base_dir.rstrip("/") + "_" + str(random_id))
+ else:
+ output_path = anyio.Path(base_dir) / str(random_id)
+ await output_path.mkdir(parents=True, exist_ok=True)
+ try:
+ yield Path(output_path)
+ finally:
+ if await output_path.exists():
+ try:
+ await asyncio.to_thread(shutil.rmtree, str(output_path))
+ except OSError:
+ await output_path.unlink()
diff --git a/imbue_core/imbue_core/time_utils.py b/imbue_core/imbue_core/time_utils.py
@@ -0,0 +1,5 @@
+import datetime
+
+
+def get_current_time() -> datetime.datetime:
+ return datetime.datetime.now(datetime.UTC)
diff --git a/imbue_core/pyproject.toml b/imbue_core/pyproject.toml
@@ -0,0 +1,67 @@
+[build-system]
+requires = ["setuptools", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "imbue-core"
+version = "0.0.9"
+description = "Utilities for imbue-desktop."
+readme = "README.md"
+authors = [
+ { name="Imbue", email="imbue@imbue.com" },
+]
+license = "MIT"
+# NOTE: This list is replicated in sculptor/pyproject.toml, and the copy there must be kept in sync.
+dependencies = [
+ "anyio",
+ "attrs",
+ "boto3>=1.38.27",
+ "cachetools",
+ "cattrs",
+ "diskcache>=5.6.3",
+ "grpclib>=0.4.7",
+ "httpx",
+ "inline-snapshot",
+ "loguru",
+ "pathspec",
+ "prometheus-client>=0.20.0",
+ "pydantic-settings",
+ "pydantic>=2.11.4",
+ "pygit2>=1.18.0",
+ "pylint==3.2.6",
+ "pygments>=2.0.0",
+ "pyhumps",
+ "pytest",
+ "pytest-asyncio",
+ "pytest-mock",
+ "syrupy",
+ "python-gitlab>=4.5.0",
+ "tblib==2.0.0", # pinning because Hammers code relies on "get_locals"
+ "tenacity>=8.2.2",
+ "toml",
+ "traceback-with-variables>=2.2.0",
+ "typeid-python",
+ "yasoo",
+
+ "anthropic~=0.54", # forcing a newer version that is not reliant on old httpx
+ "openai>=1.79.0",
+ "tiktoken",
+ "together",
+ "groq>=0.18.0",
+ "google-genai>=1.26.0",
+]
+requires-python = ">=3.11,<3.13"
+
+[project.urls]
+"homepage" = "https://imbue.com/"
+
+[tool.setuptools.package-data]
+"imbue" = ["py.typed"]
+
+[tool.setuptools.packages.find]
+include = ["imbue_core*"]
+
+[dependency-groups]
+dev = [
+ "moto>=4.1.12",
+]
diff --git a/imbue_core/uv.lock b/imbue_core/uv.lock
@@ -0,0 +1,1981 @@
+version = 1
+revision = 3
+requires-python = ">=3.11, <3.13"
+resolution-markers = [
+ "python_full_version >= '3.12'",
+ "python_full_version < '3.12'",
+]
+
+[[package]]
+name = "aiohappyeyeballs"
+version = "2.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" },
+]
+
+[[package]]
+name = "aiohttp"
+version = "3.13.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohappyeyeballs" },
+ { name = "aiosignal" },
+ { name = "attrs" },
+ { name = "frozenlist" },
+ { name = "multidict" },
+ { name = "propcache" },
+ { name = "yarl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" },
+ { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" },
+ { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" },
+ { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" },
+ { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" },
+ { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" },
+ { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" },
+ { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" },
+ { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" },
+]
+
+[[package]]
+name = "aiosignal"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "frozenlist" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "anthropic"
+version = "0.76.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "distro" },
+ { name = "docstring-parser" },
+ { name = "httpx" },
+ { name = "jiter" },
+ { name = "pydantic" },
+ { name = "sniffio" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6e/be/d11abafaa15d6304826438170f7574d750218f49a106c54424a40cef4494/anthropic-0.76.0.tar.gz", hash = "sha256:e0cae6a368986d5cf6df743dfbb1b9519e6a9eee9c6c942ad8121c0b34416ffe", size = 495483, upload-time = "2026-01-13T18:41:14.908Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/70/7b0fd9c1a738f59d3babe2b4212031c34ab7d0fda4ffef15b58a55c5bcea/anthropic-0.76.0-py3-none-any.whl", hash = "sha256:81efa3113901192af2f0fe977d3ec73fdadb1e691586306c4256cd6d5ccc331c", size = 390309, upload-time = "2026-01-13T18:41:13.483Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.12.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
+]
+
+[[package]]
+name = "astroid"
+version = "3.2.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9e/53/1067e1113ecaf58312357f2cd93063674924119d80d173adc3f6f2387aa2/astroid-3.2.4.tar.gz", hash = "sha256:0e14202810b30da1b735827f78f5157be2bbd4a7a59b7707ca0bfc2fb4c0063a", size = 397576, upload-time = "2024-07-20T12:57:43.26Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/80/96/b32bbbb46170a1c8b8b1f28c794202e25cfe743565e9d3469b8eb1e0cc05/astroid-3.2.4-py3-none-any.whl", hash = "sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25", size = 276348, upload-time = "2024-07-20T12:57:40.886Z" },
+]
+
+[[package]]
+name = "asttokens"
+version = "3.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" },
+]
+
+[[package]]
+name = "attrs"
+version = "25.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
+]
+
+[[package]]
+name = "backoff"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" },
+]
+
+[[package]]
+name = "black"
+version = "25.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "mypy-extensions" },
+ { name = "packaging" },
+ { name = "pathspec" },
+ { name = "platformdirs" },
+ { name = "pytokens" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c4/d9/07b458a3f1c525ac392b5edc6b191ff140b596f9d77092429417a54e249d/black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7", size = 659264, upload-time = "2025-12-08T01:40:52.501Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/60/ad/7ac0d0e1e0612788dbc48e62aef8a8e8feffac7eb3d787db4e43b8462fa8/black-25.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0cfa263e85caea2cff57d8f917f9f51adae8e20b610e2b23de35b5b11ce691a", size = 1877003, upload-time = "2025-12-08T01:43:29.967Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/dd/a237e9f565f3617a88b49284b59cbca2a4f56ebe68676c1aad0ce36a54a7/black-25.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a2f578ae20c19c50a382286ba78bfbeafdf788579b053d8e4980afb079ab9be", size = 1712639, upload-time = "2025-12-08T01:52:46.756Z" },
+ { url = "https://files.pythonhosted.org/packages/12/80/e187079df1ea4c12a0c63282ddd8b81d5107db6d642f7d7b75a6bcd6fc21/black-25.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e1b65634b0e471d07ff86ec338819e2ef860689859ef4501ab7ac290431f9b", size = 1758143, upload-time = "2025-12-08T01:45:29.137Z" },
+ { url = "https://files.pythonhosted.org/packages/93/b5/3096ccee4f29dc2c3aac57274326c4d2d929a77e629f695f544e159bfae4/black-25.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a3fa71e3b8dd9f7c6ac4d818345237dfb4175ed3bf37cd5a581dbc4c034f1ec5", size = 1420698, upload-time = "2025-12-08T01:45:53.379Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/39/f81c0ffbc25ffbe61c7d0385bf277e62ffc3e52f5ee668d7369d9854fadf/black-25.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:51e267458f7e650afed8445dc7edb3187143003d52a1b710c7321aef22aa9655", size = 1229317, upload-time = "2025-12-08T01:46:35.606Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/bd/26083f805115db17fda9877b3c7321d08c647df39d0df4c4ca8f8450593e/black-25.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31f96b7c98c1ddaeb07dc0f56c652e25bdedaac76d5b68a059d998b57c55594a", size = 1924178, upload-time = "2025-12-08T01:49:51.048Z" },
+ { url = "https://files.pythonhosted.org/packages/89/6b/ea00d6651561e2bdd9231c4177f4f2ae19cc13a0b0574f47602a7519b6ca/black-25.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:05dd459a19e218078a1f98178c13f861fe6a9a5f88fc969ca4d9b49eb1809783", size = 1742643, upload-time = "2025-12-08T01:49:59.09Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/f3/360fa4182e36e9875fabcf3a9717db9d27a8d11870f21cff97725c54f35b/black-25.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1f68c5eff61f226934be6b5b80296cf6939e5d2f0c2f7d543ea08b204bfaf59", size = 1800158, upload-time = "2025-12-08T01:44:27.301Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/08/2c64830cb6616278067e040acca21d4f79727b23077633953081c9445d61/black-25.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:274f940c147ddab4442d316b27f9e332ca586d39c85ecf59ebdea82cc9ee8892", size = 1426197, upload-time = "2025-12-08T01:45:51.198Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/60/a93f55fd9b9816b7432cf6842f0e3000fdd5b7869492a04b9011a133ee37/black-25.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:169506ba91ef21e2e0591563deda7f00030cb466e747c4b09cb0a9dae5db2f43", size = 1237266, upload-time = "2025-12-08T01:45:10.556Z" },
+ { url = "https://files.pythonhosted.org/packages/68/11/21331aed19145a952ad28fca2756a1433ee9308079bd03bd898e903a2e53/black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", size = 206191, upload-time = "2025-12-08T01:40:50.963Z" },
+]
+
+[[package]]
+name = "boto3"
+version = "1.42.37"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "botocore" },
+ { name = "jmespath" },
+ { name = "s3transfer" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a9/ef/0d6ceb88ae2b3638b956190a431e4a8a3697d5769d4bbbede8efcccacaea/boto3-1.42.37.tar.gz", hash = "sha256:d8b6c52c86f3bf04f71a5a53e7fb4d1527592afebffa5170cf3ef7d70966e610", size = 112830, upload-time = "2026-01-28T20:38:43.339Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/a4/cd334f74498acc6ad42a69c48e8c495f6f721d8abe13f8ef0d4b862fb1c0/boto3-1.42.37-py3-none-any.whl", hash = "sha256:e1e38fd178ffc66cfbe9cb6838b8c460000c3eb741e5f40f57eb730780ef0ed4", size = 140604, upload-time = "2026-01-28T20:38:42.135Z" },
+]
+
+[[package]]
+name = "botocore"
+version = "1.42.37"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jmespath" },
+ { name = "python-dateutil" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d5/4d/94292e7686e64d2ede8dae7102bbb11a1474e407c830de4192f2518e6cff/botocore-1.42.37.tar.gz", hash = "sha256:3ec58eb98b0857f67a2ae6aa3ded51597e7335f7640be654e0e86da4f173b5b2", size = 14914621, upload-time = "2026-01-28T20:38:34.586Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/72/30/54042dd3ad8161964f8f47aa418785079bd8d2f17053c40d65bafb9f6eed/botocore-1.42.37-py3-none-any.whl", hash = "sha256:f13bb8b560a10714d96fb7b0c7f17828dfa6e6606a1ead8c01c6ebb8765acbd8", size = 14589390, upload-time = "2026-01-28T20:38:31.306Z" },
+]
+
+[[package]]
+name = "cachetools"
+version = "6.2.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/39/91/d9ae9a66b01102a18cd16db0cf4cd54187ffe10f0865cc80071a4104fbb3/cachetools-6.2.6.tar.gz", hash = "sha256:16c33e1f276b9a9c0b49ab5782d901e3ad3de0dd6da9bf9bcd29ac5672f2f9e6", size = 32363, upload-time = "2026-01-27T20:32:59.956Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" },
+]
+
+[[package]]
+name = "cattrs"
+version = "25.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6e/00/2432bb2d445b39b5407f0a90e01b9a271475eea7caf913d7a86bcb956385/cattrs-25.3.0.tar.gz", hash = "sha256:1ac88d9e5eda10436c4517e390a4142d88638fe682c436c93db7ce4a277b884a", size = 509321, upload-time = "2025-10-07T12:26:08.737Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d8/2b/a40e1488fdfa02d3f9a653a61a5935ea08b3c2225ee818db6a76c7ba9695/cattrs-25.3.0-py3-none-any.whl", hash = "sha256:9896e84e0a5bf723bc7b4b68f4481785367ce07a8a02e7e9ee6eb2819bc306ff", size = 70738, upload-time = "2025-10-07T12:26:06.603Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
+]
+
+[[package]]
+name = "cffi"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
+ { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
+ { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
+ { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
+ { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
+ { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
+ { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
+ { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" },
+ { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
+ { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" },
+ { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" },
+ { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" },
+ { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" },
+ { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" },
+ { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" },
+ { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
+ { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
+ { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
+ { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
+ { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
+ { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "cryptography"
+version = "46.0.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" },
+ { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" },
+ { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" },
+ { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" },
+ { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" },
+ { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" },
+ { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" },
+ { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" },
+ { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" },
+ { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" },
+ { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" },
+ { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" },
+ { url = "https://files.pythonhosted.org/packages/59/e0/f9c6c53e1f2a1c2507f00f2faba00f01d2f334b35b0fbfe5286715da2184/cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b", size = 3476316, upload-time = "2026-01-28T00:24:24.144Z" },
+ { url = "https://files.pythonhosted.org/packages/27/7a/f8d2d13227a9a1a9fe9c7442b057efecffa41f1e3c51d8622f26b9edbe8f/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da", size = 4216693, upload-time = "2026-01-28T00:24:25.758Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/de/3787054e8f7972658370198753835d9d680f6cd4a39df9f877b57f0dd69c/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80", size = 4382765, upload-time = "2026-01-28T00:24:27.577Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/5f/60e0afb019973ba6a0b322e86b3d61edf487a4f5597618a430a2a15f2d22/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822", size = 4216066, upload-time = "2026-01-28T00:24:29.056Z" },
+ { url = "https://files.pythonhosted.org/packages/81/8e/bf4a0de294f147fee66f879d9bae6f8e8d61515558e3d12785dd90eca0be/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947", size = 4382025, upload-time = "2026-01-28T00:24:30.681Z" },
+ { url = "https://files.pythonhosted.org/packages/79/f4/9ceb90cfd6a3847069b0b0b353fd3075dc69b49defc70182d8af0c4ca390/cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3", size = 3406043, upload-time = "2026-01-28T00:24:32.236Z" },
+]
+
+[[package]]
+name = "dill"
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
+]
+
+[[package]]
+name = "diskcache"
+version = "5.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" },
+]
+
+[[package]]
+name = "distro"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
+]
+
+[[package]]
+name = "docstring-parser"
+version = "0.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
+]
+
+[[package]]
+name = "eval-type-backport"
+version = "0.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/30/ea/8b0ac4469d4c347c6a385ff09dc3c048c2d021696664e26c7ee6791631b5/eval_type_backport-0.2.2.tar.gz", hash = "sha256:f0576b4cf01ebb5bd358d02314d31846af5e07678387486e2c798af0e7d849c1", size = 9079, upload-time = "2024-12-21T20:09:46.005Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ce/31/55cd413eaccd39125368be33c46de24a1f639f2e12349b0361b4678f3915/eval_type_backport-0.2.2-py3-none-any.whl", hash = "sha256:cb6ad7c393517f476f96d456d0412ea80f0a8cf96f6892834cd9340149111b0a", size = 5830, upload-time = "2024-12-21T20:09:44.175Z" },
+]
+
+[[package]]
+name = "executing"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
+]
+
+[[package]]
+name = "filelock"
+version = "3.20.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" },
+]
+
+[[package]]
+name = "frozenlist"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" },
+ { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" },
+ { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" },
+ { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" },
+ { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" },
+ { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" },
+ { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" },
+ { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },
+ { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" },
+ { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" },
+ { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" },
+ { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
+]
+
+[[package]]
+name = "google-auth"
+version = "2.48.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+ { name = "pyasn1-modules" },
+ { name = "rsa" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" },
+]
+
+[package.optional-dependencies]
+requests = [
+ { name = "requests" },
+]
+
+[[package]]
+name = "google-genai"
+version = "1.60.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "distro" },
+ { name = "google-auth", extra = ["requests"] },
+ { name = "httpx" },
+ { name = "pydantic" },
+ { name = "requests" },
+ { name = "sniffio" },
+ { name = "tenacity" },
+ { name = "typing-extensions" },
+ { name = "websockets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0a/3f/a753be0dcee352b7d63bc6d1ba14a72591d63b6391dac0cdff7ac168c530/google_genai-1.60.0.tar.gz", hash = "sha256:9768061775fddfaecfefb0d6d7a6cabefb3952ebd246cd5f65247151c07d33d1", size = 487721, upload-time = "2026-01-21T22:17:30.398Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/31/e5/384b1f383917b5f0ae92e28f47bc27b16e3d26cd9bacb25e9f8ecab3c8fe/google_genai-1.60.0-py3-none-any.whl", hash = "sha256:967338378ffecebec19a8ed90cf8797b26818bacbefd7846a9280beb1099f7f3", size = 719431, upload-time = "2026-01-21T22:17:28.086Z" },
+]
+
+[[package]]
+name = "groq"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "distro" },
+ { name = "httpx" },
+ { name = "pydantic" },
+ { name = "sniffio" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3f/12/f4099a141677fcd2ed79dcc1fcec431e60c52e0e90c9c5d935f0ffaf8c0e/groq-1.0.0.tar.gz", hash = "sha256:66cb7bb729e6eb644daac7ce8efe945e99e4eb33657f733ee6f13059ef0c25a9", size = 146068, upload-time = "2025-12-17T23:34:23.115Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4a/88/3175759d2ef30406ea721f4d837bfa1ba4339fde3b81ba8c5640a96ed231/groq-1.0.0-py3-none-any.whl", hash = "sha256:6e22bf92ffad988f01d2d4df7729add66b8fd5dbfb2154b5bbf3af245b72c731", size = 138292, upload-time = "2025-12-17T23:34:21.957Z" },
+]
+
+[[package]]
+name = "grpclib"
+version = "0.4.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "h2" },
+ { name = "multidict" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5b/28/5a2c299ec82a876a252c5919aa895a6f1d1d35c96417c5ce4a4660dc3a80/grpclib-0.4.9.tar.gz", hash = "sha256:cc589c330fa81004c6400a52a566407574498cb5b055fa927013361e21466c46", size = 84798, upload-time = "2025-12-14T22:23:14.349Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/90/b0cbbd9efcc82816c58f31a34963071aa19fb792a212a5d9caf8e0fc3097/grpclib-0.4.9-py3-none-any.whl", hash = "sha256:7762ec1c8ed94dfad597475152dd35cbd11aecaaca2f243e29702435ca24cf0e", size = 77063, upload-time = "2025-12-14T22:23:13.224Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "h2"
+version = "4.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "hpack" },
+ { name = "hyperframe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
+]
+
+[[package]]
+name = "hpack"
+version = "4.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "hyperframe"
+version = "6.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
+]
+
+[[package]]
+name = "imbue-core"
+version = "0.0.9"
+source = { editable = "." }
+dependencies = [
+ { name = "anthropic" },
+ { name = "anyio" },
+ { name = "attrs" },
+ { name = "boto3" },
+ { name = "cachetools" },
+ { name = "cattrs" },
+ { name = "diskcache" },
+ { name = "google-genai" },
+ { name = "groq" },
+ { name = "grpclib" },
+ { name = "httpx" },
+ { name = "inline-snapshot" },
+ { name = "loguru" },
+ { name = "openai" },
+ { name = "pathspec" },
+ { name = "posthog" },
+ { name = "prometheus-client" },
+ { name = "pydantic" },
+ { name = "pydantic-settings" },
+ { name = "pygit2" },
+ { name = "pygments" },
+ { name = "pyhumps" },
+ { name = "pylint" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "pytest-mock" },
+ { name = "python-gitlab" },
+ { name = "sentry-sdk" },
+ { name = "syrupy" },
+ { name = "tblib" },
+ { name = "tenacity" },
+ { name = "tiktoken" },
+ { name = "together" },
+ { name = "toml" },
+ { name = "traceback-with-variables" },
+ { name = "typeid-python" },
+ { name = "yasoo" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "moto" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "anthropic", specifier = "~=0.54" },
+ { name = "anyio" },
+ { name = "attrs" },
+ { name = "boto3", specifier = ">=1.38.27" },
+ { name = "cachetools" },
+ { name = "cattrs" },
+ { name = "diskcache", specifier = ">=5.6.3" },
+ { name = "google-genai", specifier = ">=1.26.0" },
+ { name = "groq", specifier = ">=0.18.0" },
+ { name = "grpclib", specifier = ">=0.4.7" },
+ { name = "httpx" },
+ { name = "inline-snapshot" },
+ { name = "loguru" },
+ { name = "openai", specifier = ">=1.79.0" },
+ { name = "pathspec" },
+ { name = "posthog", specifier = "==5.4.0" },
+ { name = "prometheus-client", specifier = ">=0.20.0" },
+ { name = "pydantic", specifier = ">=2.11.4" },
+ { name = "pydantic-settings" },
+ { name = "pygit2", specifier = ">=1.18.0" },
+ { name = "pygments", specifier = ">=2.0.0" },
+ { name = "pyhumps" },
+ { name = "pylint", specifier = "==3.2.6" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "pytest-mock" },
+ { name = "python-gitlab", specifier = ">=4.5.0" },
+ { name = "sentry-sdk" },
+ { name = "syrupy" },
+ { name = "tblib", specifier = "==2.0.0" },
+ { name = "tenacity", specifier = ">=8.2.2" },
+ { name = "tiktoken" },
+ { name = "together" },
+ { name = "toml" },
+ { name = "traceback-with-variables", specifier = ">=2.2.0" },
+ { name = "typeid-python" },
+ { name = "yasoo" },
+]
+
+[package.metadata.requires-dev]
+dev = [{ name = "moto", specifier = ">=4.1.12" }]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "inline-snapshot"
+version = "0.31.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asttokens" },
+ { name = "executing" },
+ { name = "pytest" },
+ { name = "rich" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1c/b1/52b5ee59f73ed31d5fe21b10881bf2d121d07d54b23c0b6b74186792e620/inline_snapshot-0.31.1.tar.gz", hash = "sha256:4ea5ed70aa1d652713bbfd750606b94bd8a42483f7d3680433b3e92994495f64", size = 2606338, upload-time = "2025-11-07T07:36:18.932Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/52/945db420380efbda8c69a7a4a16c53df9d7ac50d8217286b9d41e5d825ff/inline_snapshot-0.31.1-py3-none-any.whl", hash = "sha256:7875a73c986a03388c7e758fb5cb8a43d2c3a20328aa1d851bfb4ed536c4496f", size = 71965, upload-time = "2025-11-07T07:36:16.836Z" },
+]
+
+[[package]]
+name = "isort"
+version = "5.13.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/87/f9/c1eb8635a24e87ade2efce21e3ce8cd6b8630bb685ddc9cdaca1349b2eb5/isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", size = 175303, upload-time = "2023-12-13T20:37:26.124Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/b3/8def84f539e7d2289a02f0524b944b15d7c75dab7628bedf1c4f0992029c/isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6", size = 92310, upload-time = "2023-12-13T20:37:23.244Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "jiter"
+version = "0.12.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/45/9d/e0660989c1370e25848bb4c52d061c71837239738ad937e83edca174c273/jiter-0.12.0.tar.gz", hash = "sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b", size = 168294, upload-time = "2025-11-09T20:49:23.302Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/f9/eaca4633486b527ebe7e681c431f529b63fe2709e7c5242fc0f43f77ce63/jiter-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d8f8a7e317190b2c2d60eb2e8aa835270b008139562d70fe732e1c0020ec53c9", size = 316435, upload-time = "2025-11-09T20:47:02.087Z" },
+ { url = "https://files.pythonhosted.org/packages/10/c1/40c9f7c22f5e6ff715f28113ebaba27ab85f9af2660ad6e1dd6425d14c19/jiter-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2218228a077e784c6c8f1a8e5d6b8cb1dea62ce25811c356364848554b2056cd", size = 320548, upload-time = "2025-11-09T20:47:03.409Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/1b/efbb68fe87e7711b00d2cfd1f26bb4bfc25a10539aefeaa7727329ffb9cb/jiter-0.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9354ccaa2982bf2188fd5f57f79f800ef622ec67beb8329903abf6b10da7d423", size = 351915, upload-time = "2025-11-09T20:47:05.171Z" },
+ { url = "https://files.pythonhosted.org/packages/15/2d/c06e659888c128ad1e838123d0638f0efad90cc30860cb5f74dd3f2fc0b3/jiter-0.12.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2607185ea89b4af9a604d4c7ec40e45d3ad03ee66998b031134bc510232bb7", size = 368966, upload-time = "2025-11-09T20:47:06.508Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/20/058db4ae5fb07cf6a4ab2e9b9294416f606d8e467fb74c2184b2a1eeacba/jiter-0.12.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a585a5e42d25f2e71db5f10b171f5e5ea641d3aa44f7df745aa965606111cc2", size = 482047, upload-time = "2025-11-09T20:47:08.382Z" },
+ { url = "https://files.pythonhosted.org/packages/49/bb/dc2b1c122275e1de2eb12905015d61e8316b2f888bdaac34221c301495d6/jiter-0.12.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd9e21d34edff5a663c631f850edcb786719c960ce887a5661e9c828a53a95d9", size = 380835, upload-time = "2025-11-09T20:47:09.81Z" },
+ { url = "https://files.pythonhosted.org/packages/23/7d/38f9cd337575349de16da575ee57ddb2d5a64d425c9367f5ef9e4612e32e/jiter-0.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a612534770470686cd5431478dc5a1b660eceb410abade6b1b74e320ca98de6", size = 364587, upload-time = "2025-11-09T20:47:11.529Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/a3/b13e8e61e70f0bb06085099c4e2462647f53cc2ca97614f7fedcaa2bb9f3/jiter-0.12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3985aea37d40a908f887b34d05111e0aae822943796ebf8338877fee2ab67725", size = 390492, upload-time = "2025-11-09T20:47:12.993Z" },
+ { url = "https://files.pythonhosted.org/packages/07/71/e0d11422ed027e21422f7bc1883c61deba2d9752b720538430c1deadfbca/jiter-0.12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b1207af186495f48f72529f8d86671903c8c10127cac6381b11dddc4aaa52df6", size = 522046, upload-time = "2025-11-09T20:47:14.6Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/59/b968a9aa7102a8375dbbdfbd2aeebe563c7e5dddf0f47c9ef1588a97e224/jiter-0.12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef2fb241de583934c9915a33120ecc06d94aa3381a134570f59eed784e87001e", size = 513392, upload-time = "2025-11-09T20:47:16.011Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/e4/7df62002499080dbd61b505c5cb351aa09e9959d176cac2aa8da6f93b13b/jiter-0.12.0-cp311-cp311-win32.whl", hash = "sha256:453b6035672fecce8007465896a25b28a6b59cfe8fbc974b2563a92f5a92a67c", size = 206096, upload-time = "2025-11-09T20:47:17.344Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/60/1032b30ae0572196b0de0e87dce3b6c26a1eff71aad5fe43dee3082d32e0/jiter-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:ca264b9603973c2ad9435c71a8ec8b49f8f715ab5ba421c85a51cde9887e421f", size = 204899, upload-time = "2025-11-09T20:47:19.365Z" },
+ { url = "https://files.pythonhosted.org/packages/49/d5/c145e526fccdb834063fb45c071df78b0cc426bbaf6de38b0781f45d956f/jiter-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:cb00ef392e7d684f2754598c02c409f376ddcef857aae796d559e6cacc2d78a5", size = 188070, upload-time = "2025-11-09T20:47:20.75Z" },
+ { url = "https://files.pythonhosted.org/packages/92/c9/5b9f7b4983f1b542c64e84165075335e8a236fa9e2ea03a0c79780062be8/jiter-0.12.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:305e061fa82f4680607a775b2e8e0bcb071cd2205ac38e6ef48c8dd5ebe1cf37", size = 314449, upload-time = "2025-11-09T20:47:22.999Z" },
+ { url = "https://files.pythonhosted.org/packages/98/6e/e8efa0e78de00db0aee82c0cf9e8b3f2027efd7f8a71f859d8f4be8e98ef/jiter-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c1860627048e302a528333c9307c818c547f214d8659b0705d2195e1a94b274", size = 319855, upload-time = "2025-11-09T20:47:24.779Z" },
+ { url = "https://files.pythonhosted.org/packages/20/26/894cd88e60b5d58af53bec5c6759d1292bd0b37a8b5f60f07abf7a63ae5f/jiter-0.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df37577a4f8408f7e0ec3205d2a8f87672af8f17008358063a4d6425b6081ce3", size = 350171, upload-time = "2025-11-09T20:47:26.469Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/27/a7b818b9979ac31b3763d25f3653ec3a954044d5e9f5d87f2f247d679fd1/jiter-0.12.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fdd787356c1c13a4f40b43c2156276ef7a71eb487d98472476476d803fb2cf", size = 365590, upload-time = "2025-11-09T20:47:27.918Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/7e/e46195801a97673a83746170b17984aa8ac4a455746354516d02ca5541b4/jiter-0.12.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1eb5db8d9c65b112aacf14fcd0faae9913d07a8afea5ed06ccdd12b724e966a1", size = 479462, upload-time = "2025-11-09T20:47:29.654Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/75/f833bfb009ab4bd11b1c9406d333e3b4357709ed0570bb48c7c06d78c7dd/jiter-0.12.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73c568cc27c473f82480abc15d1301adf333a7ea4f2e813d6a2c7d8b6ba8d0df", size = 378983, upload-time = "2025-11-09T20:47:31.026Z" },
+ { url = "https://files.pythonhosted.org/packages/71/b3/7a69d77943cc837d30165643db753471aff5df39692d598da880a6e51c24/jiter-0.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4321e8a3d868919bcb1abb1db550d41f2b5b326f72df29e53b2df8b006eb9403", size = 361328, upload-time = "2025-11-09T20:47:33.286Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/ac/a78f90caf48d65ba70d8c6efc6f23150bc39dc3389d65bbec2a95c7bc628/jiter-0.12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0a51bad79f8cc9cac2b4b705039f814049142e0050f30d91695a2d9a6611f126", size = 386740, upload-time = "2025-11-09T20:47:34.703Z" },
+ { url = "https://files.pythonhosted.org/packages/39/b6/5d31c2cc8e1b6a6bcf3c5721e4ca0a3633d1ab4754b09bc7084f6c4f5327/jiter-0.12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2a67b678f6a5f1dd6c36d642d7db83e456bc8b104788262aaefc11a22339f5a9", size = 520875, upload-time = "2025-11-09T20:47:36.058Z" },
+ { url = "https://files.pythonhosted.org/packages/30/b5/4df540fae4e9f68c54b8dab004bd8c943a752f0b00efd6e7d64aa3850339/jiter-0.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efe1a211fe1fd14762adea941e3cfd6c611a136e28da6c39272dbb7a1bbe6a86", size = 511457, upload-time = "2025-11-09T20:47:37.932Z" },
+ { url = "https://files.pythonhosted.org/packages/07/65/86b74010e450a1a77b2c1aabb91d4a91dd3cd5afce99f34d75fd1ac64b19/jiter-0.12.0-cp312-cp312-win32.whl", hash = "sha256:d779d97c834b4278276ec703dc3fc1735fca50af63eb7262f05bdb4e62203d44", size = 204546, upload-time = "2025-11-09T20:47:40.47Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/c7/6659f537f9562d963488e3e55573498a442503ced01f7e169e96a6110383/jiter-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:e8269062060212b373316fe69236096aaf4c49022d267c6736eebd66bbbc60bb", size = 205196, upload-time = "2025-11-09T20:47:41.794Z" },
+ { url = "https://files.pythonhosted.org/packages/21/f4/935304f5169edadfec7f9c01eacbce4c90bb9a82035ac1de1f3bd2d40be6/jiter-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:06cb970936c65de926d648af0ed3d21857f026b1cf5525cb2947aa5e01e05789", size = 186100, upload-time = "2025-11-09T20:47:43.007Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/54/5339ef1ecaa881c6948669956567a64d2670941925f245c434f494ffb0e5/jiter-0.12.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:4739a4657179ebf08f85914ce50332495811004cc1747852e8b2041ed2aab9b8", size = 311144, upload-time = "2025-11-09T20:49:10.503Z" },
+ { url = "https://files.pythonhosted.org/packages/27/74/3446c652bffbd5e81ab354e388b1b5fc1d20daac34ee0ed11ff096b1b01a/jiter-0.12.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:41da8def934bf7bec16cb24bd33c0ca62126d2d45d81d17b864bd5ad721393c3", size = 305877, upload-time = "2025-11-09T20:49:12.269Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/f4/ed76ef9043450f57aac2d4fbeb27175aa0eb9c38f833be6ef6379b3b9a86/jiter-0.12.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c44ee814f499c082e69872d426b624987dbc5943ab06e9bbaa4f81989fdb79e", size = 340419, upload-time = "2025-11-09T20:49:13.803Z" },
+ { url = "https://files.pythonhosted.org/packages/21/01/857d4608f5edb0664aa791a3d45702e1a5bcfff9934da74035e7b9803846/jiter-0.12.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd2097de91cf03eaa27b3cbdb969addf83f0179c6afc41bbc4513705e013c65d", size = 347212, upload-time = "2025-11-09T20:49:15.643Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/f5/12efb8ada5f5c9edc1d4555fe383c1fb2eac05ac5859258a72d61981d999/jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:e8547883d7b96ef2e5fe22b88f8a4c8725a56e7f4abafff20fd5272d634c7ecb", size = 309974, upload-time = "2025-11-09T20:49:17.187Z" },
+ { url = "https://files.pythonhosted.org/packages/85/15/d6eb3b770f6a0d332675141ab3962fd4a7c270ede3515d9f3583e1d28276/jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:89163163c0934854a668ed783a2546a0617f71706a2551a4a0666d91ab365d6b", size = 304233, upload-time = "2025-11-09T20:49:18.734Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/3e/e7e06743294eea2cf02ced6aa0ff2ad237367394e37a0e2b4a1108c67a36/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d96b264ab7d34bbb2312dedc47ce07cd53f06835eacbc16dde3761f47c3a9e7f", size = 338537, upload-time = "2025-11-09T20:49:20.317Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/9c/6753e6522b8d0ef07d3a3d239426669e984fb0eba15a315cdbc1253904e4/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24e864cb30ab82311c6425655b0cdab0a98c5d973b065c66a3f020740c2324c", size = 346110, upload-time = "2025-11-09T20:49:21.817Z" },
+]
+
+[[package]]
+name = "jmespath"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
+]
+
+[[package]]
+name = "loguru"
+version = "0.7.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "win32-setctime", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdurl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
+ { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
+ { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
+ { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
+ { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
+ { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
+ { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
+ { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
+]
+
+[[package]]
+name = "mccabe"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+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 = "more-itertools"
+version = "10.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" },
+]
+
+[[package]]
+name = "moto"
+version = "5.1.20"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "boto3" },
+ { name = "botocore" },
+ { name = "cryptography" },
+ { name = "jinja2" },
+ { name = "python-dateutil" },
+ { name = "requests" },
+ { name = "responses" },
+ { name = "werkzeug" },
+ { name = "xmltodict" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b4/93/6b696aab5174721696a17716a488086e21f7b2547b4c9517f799a9b25e9e/moto-5.1.20.tar.gz", hash = "sha256:6d12d781e26a550d80e4b7e01d5538178e3adec6efbdec870e06e84750f13ec0", size = 8318716, upload-time = "2026-01-17T21:49:00.101Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/2f/f50892fdb28097917b87d358a5fcefd30976289884ff142893edcb0243ba/moto-5.1.20-py3-none-any.whl", hash = "sha256:58c82c8e6b2ef659ef3a562fa415dce14da84bc7a797943245d9a338496ea0ea", size = 6392751, upload-time = "2026-01-17T21:48:57.099Z" },
+]
+
+[[package]]
+name = "multidict"
+version = "6.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" },
+ { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" },
+ { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" },
+ { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" },
+ { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" },
+ { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" },
+ { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" },
+ { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" },
+ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a5/34/2b1bc18424f3ad9af577f6ce23600319968a70575bd7db31ce66731bbef9/numpy-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0cce2a669e3c8ba02ee563c7835f92c153cf02edff1ae05e1823f1dde21b16a5", size = 16944563, upload-time = "2026-01-10T06:42:14.615Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/57/26e5f97d075aef3794045a6ca9eada6a4ed70eb9a40e7a4a93f9ac80d704/numpy-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:899d2c18024984814ac7e83f8f49d8e8180e2fbe1b2e252f2e7f1d06bea92425", size = 12645658, upload-time = "2026-01-10T06:42:17.298Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/ba/80fc0b1e3cb2fd5c6143f00f42eb67762aa043eaa05ca924ecc3222a7849/numpy-2.4.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:09aa8a87e45b55a1c2c205d42e2808849ece5c484b2aab11fecabec3841cafba", size = 5474132, upload-time = "2026-01-10T06:42:19.637Z" },
+ { url = "https://files.pythonhosted.org/packages/40/ae/0a5b9a397f0e865ec171187c78d9b57e5588afc439a04ba9cab1ebb2c945/numpy-2.4.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:edee228f76ee2dab4579fad6f51f6a305de09d444280109e0f75df247ff21501", size = 6804159, upload-time = "2026-01-10T06:42:21.44Z" },
+ { url = "https://files.pythonhosted.org/packages/86/9c/841c15e691c7085caa6fd162f063eff494099c8327aeccd509d1ab1e36ab/numpy-2.4.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a92f227dbcdc9e4c3e193add1a189a9909947d4f8504c576f4a732fd0b54240a", size = 14708058, upload-time = "2026-01-10T06:42:23.546Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/9d/7862db06743f489e6a502a3b93136d73aea27d97b2cf91504f70a27501d6/numpy-2.4.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:538bf4ec353709c765ff75ae616c34d3c3dca1a68312727e8f2676ea644f8509", size = 16651501, upload-time = "2026-01-10T06:42:25.909Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/9c/6fc34ebcbd4015c6e5f0c0ce38264010ce8a546cb6beacb457b84a75dfc8/numpy-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ac08c63cb7779b85e9d5318e6c3518b424bc1f364ac4cb2c6136f12e5ff2dccc", size = 16492627, upload-time = "2026-01-10T06:42:28.938Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/63/2494a8597502dacda439f61b3c0db4da59928150e62be0e99395c3ad23c5/numpy-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f9c360ecef085e5841c539a9a12b883dff005fbd7ce46722f5e9cef52634d82", size = 18585052, upload-time = "2026-01-10T06:42:31.312Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/93/098e1162ae7522fc9b618d6272b77404c4656c72432ecee3abc029aa3de0/numpy-2.4.1-cp311-cp311-win32.whl", hash = "sha256:0f118ce6b972080ba0758c6087c3617b5ba243d806268623dc34216d69099ba0", size = 6236575, upload-time = "2026-01-10T06:42:33.872Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/de/f5e79650d23d9e12f38a7bc6b03ea0835b9575494f8ec94c11c6e773b1b1/numpy-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:18e14c4d09d55eef39a6ab5b08406e84bc6869c1e34eef45564804f90b7e0574", size = 12604479, upload-time = "2026-01-10T06:42:35.778Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/65/e1097a7047cff12ce3369bd003811516b20ba1078dbdec135e1cd7c16c56/numpy-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:6461de5113088b399d655d45c3897fa188766415d0f568f175ab071c8873bd73", size = 10578325, upload-time = "2026-01-10T06:42:38.518Z" },
+ { url = "https://files.pythonhosted.org/packages/78/7f/ec53e32bf10c813604edf07a3682616bd931d026fcde7b6d13195dfb684a/numpy-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2", size = 16656888, upload-time = "2026-01-10T06:42:40.913Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/e0/1f9585d7dae8f14864e948fd7fa86c6cb72dee2676ca2748e63b1c5acfe0/numpy-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8", size = 12373956, upload-time = "2026-01-10T06:42:43.091Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/43/9762e88909ff2326f5e7536fa8cb3c49fb03a7d92705f23e6e7f553d9cb3/numpy-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a", size = 5202567, upload-time = "2026-01-10T06:42:45.107Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/ee/34b7930eb61e79feb4478800a4b95b46566969d837546aa7c034c742ef98/numpy-2.4.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0", size = 6549459, upload-time = "2026-01-10T06:42:48.152Z" },
+ { url = "https://files.pythonhosted.org/packages/79/e3/5f115fae982565771be994867c89bcd8d7208dbfe9469185497d70de5ddf/numpy-2.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c", size = 14404859, upload-time = "2026-01-10T06:42:49.947Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/7d/9c8a781c88933725445a859cac5d01b5871588a15969ee6aeb618ba99eee/numpy-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02", size = 16371419, upload-time = "2026-01-10T06:42:52.409Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/d2/8aa084818554543f17cf4162c42f162acbd3bb42688aefdba6628a859f77/numpy-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162", size = 16182131, upload-time = "2026-01-10T06:42:54.694Z" },
+ { url = "https://files.pythonhosted.org/packages/60/db/0425216684297c58a8df35f3284ef56ec4a043e6d283f8a59c53562caf1b/numpy-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9", size = 18295342, upload-time = "2026-01-10T06:42:56.991Z" },
+ { url = "https://files.pythonhosted.org/packages/31/4c/14cb9d86240bd8c386c881bafbe43f001284b7cce3bc01623ac9475da163/numpy-2.4.1-cp312-cp312-win32.whl", hash = "sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f", size = 5959015, upload-time = "2026-01-10T06:42:59.631Z" },
+ { url = "https://files.pythonhosted.org/packages/51/cf/52a703dbeb0c65807540d29699fef5fda073434ff61846a564d5c296420f/numpy-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87", size = 12310730, upload-time = "2026-01-10T06:43:01.627Z" },
+ { url = "https://files.pythonhosted.org/packages/69/80/a828b2d0ade5e74a9fe0f4e0a17c30fdc26232ad2bc8c9f8b3197cf7cf18/numpy-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8", size = 10312166, upload-time = "2026-01-10T06:43:03.673Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/48/d86f97919e79314a1cdee4c832178763e6e98e623e123d0bada19e92c15a/numpy-2.4.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8ad35f20be147a204e28b6a0575fbf3540c5e5f802634d4258d55b1ff5facce1", size = 16822202, upload-time = "2026-01-10T06:44:43.738Z" },
+ { url = "https://files.pythonhosted.org/packages/51/e9/1e62a7f77e0f37dcfb0ad6a9744e65df00242b6ea37dfafb55debcbf5b55/numpy-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8097529164c0f3e32bb89412a0905d9100bf434d9692d9fc275e18dcf53c9344", size = 12569985, upload-time = "2026-01-10T06:44:45.945Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/7e/914d54f0c801342306fdcdce3e994a56476f1b818c46c47fc21ae968088c/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:ea66d2b41ca4a1630aae5507ee0a71647d3124d1741980138aa8f28f44dac36e", size = 5398484, upload-time = "2026-01-10T06:44:48.012Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/d8/9570b68584e293a33474e7b5a77ca404f1dcc655e40050a600dee81d27fb/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:d3f8f0df9f4b8be57b3bf74a1d087fec68f927a2fab68231fdb442bf2c12e426", size = 6713216, upload-time = "2026-01-10T06:44:49.725Z" },
+ { url = "https://files.pythonhosted.org/packages/33/9b/9dd6e2db8d49eb24f86acaaa5258e5f4c8ed38209a4ee9de2d1a0ca25045/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2023ef86243690c2791fd6353e5b4848eedaa88ca8a2d129f462049f6d484696", size = 14538937, upload-time = "2026-01-10T06:44:51.498Z" },
+ { url = "https://files.pythonhosted.org/packages/53/87/d5bd995b0f798a37105b876350d346eea5838bd8f77ea3d7a48392f3812b/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8361ea4220d763e54cff2fbe7d8c93526b744f7cd9ddab47afeff7e14e8503be", size = 16479830, upload-time = "2026-01-10T06:44:53.931Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/c7/b801bf98514b6ae6475e941ac05c58e6411dd863ea92916bfd6d510b08c1/numpy-2.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4f1b68ff47680c2925f8063402a693ede215f0257f02596b1318ecdfb1d79e33", size = 12492579, upload-time = "2026-01-10T06:44:57.094Z" },
+]
+
+[[package]]
+name = "openai"
+version = "2.16.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "distro" },
+ { name = "httpx" },
+ { name = "jiter" },
+ { name = "pydantic" },
+ { name = "sniffio" },
+ { name = "tqdm" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/6c/e4c964fcf1d527fdf4739e7cc940c60075a4114d50d03871d5d5b1e13a88/openai-2.16.0.tar.gz", hash = "sha256:42eaa22ca0d8ded4367a77374104d7a2feafee5bd60a107c3c11b5243a11cd12", size = 629649, upload-time = "2026-01-27T23:28:02.579Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/83/0315bf2cfd75a2ce8a7e54188e9456c60cec6c0cf66728ed07bd9859ff26/openai-2.16.0-py3-none-any.whl", hash = "sha256:5f46643a8f42899a84e80c38838135d7038e7718333ce61396994f887b09a59b", size = 1068612, upload-time = "2026-01-27T23:28:00.356Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
+]
+
+[[package]]
+name = "pathspec"
+version = "1.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
+]
+
+[[package]]
+name = "pillow"
+version = "11.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" },
+ { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" },
+ { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" },
+ { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" },
+ { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" },
+ { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" },
+ { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" },
+ { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" },
+ { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" },
+ { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" },
+ { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" },
+ { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "posthog"
+version = "5.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "backoff" },
+ { name = "distro" },
+ { name = "python-dateutil" },
+ { name = "requests" },
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/48/20/60ae67bb9d82f00427946218d49e2e7e80fb41c15dc5019482289ec9ce8d/posthog-5.4.0.tar.gz", hash = "sha256:701669261b8d07cdde0276e5bc096b87f9e200e3b9589c5ebff14df658c5893c", size = 88076, upload-time = "2025-06-20T23:19:23.485Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4f/98/e480cab9a08d1c09b1c59a93dade92c1bb7544826684ff2acbfd10fcfbd4/posthog-5.4.0-py3-none-any.whl", hash = "sha256:284dfa302f64353484420b52d4ad81ff5c2c2d1d607c4e2db602ac72761831bd", size = 105364, upload-time = "2025-06-20T23:19:22.001Z" },
+]
+
+[[package]]
+name = "prometheus-client"
+version = "0.24.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" },
+]
+
+[[package]]
+name = "propcache"
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" },
+ { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" },
+ { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" },
+ { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" },
+ { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" },
+ { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" },
+ { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" },
+ { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" },
+ { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" },
+ { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" },
+ { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" },
+ { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" },
+ { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" },
+ { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" },
+ { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" },
+ { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" },
+ { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
+]
+
+[[package]]
+name = "pyasn1"
+version = "0.6.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
+]
+
+[[package]]
+name = "pyasn1-modules"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyasn1" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.12.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.41.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
+ { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
+ { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
+ { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
+ { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
+ { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
+ { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
+ { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
+ { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
+ { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
+ { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
+ { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
+ { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
+ { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
+ { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
+ { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
+ { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
+ { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
+ { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
+ { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
+ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
+]
+
+[[package]]
+name = "pydantic-settings"
+version = "2.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "python-dotenv" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
+]
+
+[[package]]
+name = "pygit2"
+version = "1.19.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/17/49/cf8350817de19f4cafe4ae47881e38f56d9bbebaa9e5ef31a5458af4bcf8/pygit2-1.19.1.tar.gz", hash = "sha256:3165f784aae56a309a27d8eeae7923d53da2e8f6094308c7f5b428deec925cf9", size = 800869, upload-time = "2025-12-29T11:47:48.618Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f8/4f/c8c29c4af2de6b8b7e086cad24e200ec7f165587caa77b7d2d495366204e/pygit2-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2b54f3a94648ac8e287f5e4333710d9fe05f9e09de3da232d50df753bb01b643", size = 5702353, upload-time = "2025-12-29T11:46:28.548Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/04/814b305804f067fd8d1cd7166dc3704900704a8fa71280703212abbacf9f/pygit2-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e46618a912fc984b8a9f4d8322704620f1315264359c7fa61c899128e23e226", size = 5691612, upload-time = "2025-12-29T11:46:30.754Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/04/61c84d1ab2585f50a2551199e4228f3a800635c482e451e93f2cd0c0ae3d/pygit2-1.19.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2eb386b3e98f7056d76bc7e805e8fce3cd0a773cbbb30b0f7e144c0ac37270f2", size = 6021372, upload-time = "2025-12-29T11:46:32.439Z" },
+ { url = "https://files.pythonhosted.org/packages/be/7a/daca8780c72b0d5a56165e0bff3b76d2fa8e0a8f7269f40aa17f10ed0356/pygit2-1.19.1-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f41a9b866676922ac9b0ec60f0dc9735a5d1ba6bb34146a6212dc0012d7959f", size = 4623817, upload-time = "2025-12-29T11:46:33.964Z" },
+ { url = "https://files.pythonhosted.org/packages/92/f6/d065bb189c9fd86c5e540eb264567b4fe3eb06447da1408c03a35e15096b/pygit2-1.19.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2cdc81ecffd990d8c6dce44a16b1dc4494b5dd5381d6e1f508e459c4bca09e0", size = 5781284, upload-time = "2025-12-29T11:46:35.703Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/8a/2b9195619a9a0dc6e25525e784f7474174614ebc064a91b2a2087952a583/pygit2-1.19.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a1c8645287556aa9b670886dbc0d5daa1d49040511940822fd43dbda13cfe4e8", size = 6027281, upload-time = "2025-12-29T11:46:37.331Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/b7/20837029e8f5177d4ac48396a4448d02dfe455e988bb722d43dc42f6b0af/pygit2-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e388d1eb0c44d92d8ff01b25eb9a969fc28748966843c2e26e9e084e42567f7d", size = 5750642, upload-time = "2025-12-29T11:46:38.626Z" },
+ { url = "https://files.pythonhosted.org/packages/41/42/18cc94976a35451a5653abf047356f94b5f503b1c0b02223a6d9e72979d3/pygit2-1.19.1-cp311-cp311-win32.whl", hash = "sha256:815c0b12845253929f2275759d623b3b4093e67e6536d2463177e6ff1d9ff0df", size = 942173, upload-time = "2025-12-29T11:46:40.087Z" },
+ { url = "https://files.pythonhosted.org/packages/61/19/590708fc3182d47b40f0274f80671ccdf9c1a8fa5a838b554e6fe15a2bb3/pygit2-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:93f4986b35984aaaa5e7613ceb1ba4c184d890589df60b0d8d74e7dccec1d8cb", size = 1159463, upload-time = "2025-12-29T11:46:41.338Z" },
+ { url = "https://files.pythonhosted.org/packages/90/a8/a2c1eb6f8c5f30cb5633a3c21e60ee6be2e4a3148b302f578e4b48e769ef/pygit2-1.19.1-cp311-cp311-win_arm64.whl", hash = "sha256:fef27b206955e66e3a63664e2ec93821e00ce2d917f8b4eae87c738163c00e14", size = 966795, upload-time = "2025-12-29T11:46:42.842Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/36/0784870218794d6069bf8ebae55679964edc44b8e59279f4526aa1220569/pygit2-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8e6a4f4a711750c286a13cea0007b40f7466c4d741c3d9b223ffbc3bbfbafe7", size = 5700218, upload-time = "2025-12-29T11:46:44.537Z" },
+ { url = "https://files.pythonhosted.org/packages/56/65/47206823900ddca606022025369ba3e136de9d2310585acac10d8cef81fd/pygit2-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3f2340a668eb3e2d8927dcbeb1a043d3a65d2dd39a913995b34fc437da5e73af", size = 5692231, upload-time = "2025-12-29T11:46:45.821Z" },
+ { url = "https://files.pythonhosted.org/packages/19/27/c6b52f53ee16b9d7eaacc575f08add3c336f53b5561cf94fe41ceeab1589/pygit2-1.19.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe41f09b1e334c43def6636b1133d2f4c91a20d9a6691bb4e7558e42a31bcb4e", size = 6022217, upload-time = "2025-12-29T11:46:47.086Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/ac/41d7a1ed69e117e9cd99b2f40f63898f9725ac6c4245b2b531ae0b7e59da/pygit2-1.19.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527e57133d30ff6ea96634da6bf428f7d551958207fa73f9e9a18582b885e192", size = 4622846, upload-time = "2025-12-29T11:46:48.679Z" },
+ { url = "https://files.pythonhosted.org/packages/09/22/f8fc7860b7b7ba15f7bf802ae3bce52b3e765b48846db115cb1c8372f971/pygit2-1.19.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a9340cb85b7be40080186a9d4dbf712a6be8a842556acbbfb305baebfb854f3", size = 5785236, upload-time = "2025-12-29T11:46:50.24Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/62/ee9275c48ecc119a7f5c48209aaa06d5f71d8476703c7700182c49c8a7a8/pygit2-1.19.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:66ecfa69f2287f50ec95dfc04821219c2f664c4cd292c7b33c10ed9afe975132", size = 6028266, upload-time = "2025-12-29T11:46:51.5Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/98/311112a50e6e319921f06c20ff237360c10bb2e8a1f959361567e48835f3/pygit2-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:14c76ec968ae20a6689c7b6fa833ef546c7bc176127d71e7b67cb2345a9813fb", size = 5755041, upload-time = "2025-12-29T11:46:53.337Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/45/f6a24326fb94e56ddae9906e21d4e4a006a36131a3a73819be1177e30e93/pygit2-1.19.1-cp312-cp312-win32.whl", hash = "sha256:ffe94118d39f6969fda594224b2b6df1ae79306adaf090ede65bcaf1a41b3a81", size = 942948, upload-time = "2025-12-29T11:46:54.465Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/1a/912ee3a33ba665f82cf8ed0087e7446f1f8e117aba1627e0c4ccc9b2a8c5/pygit2-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:c2ee3f2e91b0a5674ab7cb373234c23cf5f1cf6d84e56e6d12ff3db21414cf47", size = 1159880, upload-time = "2025-12-29T11:46:55.523Z" },
+ { url = "https://files.pythonhosted.org/packages/24/fc/784eeceab43c2b4978aa46f03c267409f2502331fa18d0a8e58116d143d0/pygit2-1.19.1-cp312-cp312-win_arm64.whl", hash = "sha256:c8747d968d8d6b9d390263907f014d38a0f67bd26d8243e5bc3384cb252ec3d3", size = 966904, upload-time = "2025-12-29T11:46:56.888Z" },
+ { url = "https://files.pythonhosted.org/packages/45/01/607b8a400ffe46340df083d67cb05296f90e0d302d09addac5dc1afee47f/pygit2-1.19.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3c56ef9ac89e020ca005a39db4e045792b1ce98c2450a53f79815e9d831c006a", size = 5646594, upload-time = "2025-12-29T11:47:41.437Z" },
+ { url = "https://files.pythonhosted.org/packages/18/59/45e517b86692120fd927b8949916203c50ffce0cd7a7124131d90d816fde/pygit2-1.19.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a6d89079f3af32f25abb8680eabea31143a4f02f3d1da6644c296ba89b6a2fc", size = 5644506, upload-time = "2025-12-29T11:47:42.779Z" },
+ { url = "https://files.pythonhosted.org/packages/db/25/41c0c37c0f8b23677364d9f82ddbb1377d2342666045d39b508acc3d6f97/pygit2-1.19.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bfd44dc6f1d5b1165cc2097c39000c4a5cc05443d27a3a5f2791ad338f52b07", size = 5559864, upload-time = "2025-12-29T11:47:44.399Z" },
+ { url = "https://files.pythonhosted.org/packages/76/c0/16ff6c4d732d8644ab84a5d48141b55f6b353e08da5ffcbee03a5c58c3a5/pygit2-1.19.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0aca00ff7e3420f9c06d9386b0bfc76c18fd8a2c5234412db0e200a6cc47ed03", size = 5312681, upload-time = "2025-12-29T11:47:46.022Z" },
+ { url = "https://files.pythonhosted.org/packages/08/cc/f762a2378d148ae40766fcac3f1ae1b5d925ae80128422366788eea9f5e6/pygit2-1.19.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f89f047667a218b71ebc96c398aca1e5109f149045a8d59ca9fd4a557d1e932e", size = 1130023, upload-time = "2025-12-29T11:47:47.55Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.19.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+]
+
+[[package]]
+name = "pyhumps"
+version = "3.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c4/83/fa6f8fb7accb21f39e8f2b6a18f76f6d90626bdb0a5e5448e5cc9b8ab014/pyhumps-3.8.0.tar.gz", hash = "sha256:498026258f7ee1a8e447c2e28526c0bea9407f9a59c03260aee4bd6c04d681a3", size = 9018, upload-time = "2022-10-21T10:38:59.496Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/11/a1938340ecb32d71e47ad4914843775011e6e9da59ba1229f181fef3119e/pyhumps-3.8.0-py3-none-any.whl", hash = "sha256:060e1954d9069f428232a1adda165db0b9d8dfdce1d265d36df7fbff540acfd6", size = 6095, upload-time = "2022-10-21T10:38:58.231Z" },
+]
+
+[[package]]
+name = "pylint"
+version = "3.2.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "astroid" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "dill" },
+ { name = "isort" },
+ { name = "mccabe" },
+ { name = "platformdirs" },
+ { name = "tomlkit" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/30/10/abee071c1d52b2bca48be40fe9f64ca878a77e0beef6504597e8c9c1ed84/pylint-3.2.6.tar.gz", hash = "sha256:a5d01678349454806cff6d886fb072294f56a58c4761278c97fb557d708e1eb3", size = 1510167, upload-time = "2024-07-21T19:48:38.032Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/09/88/1a406dd0b17a4796f025d8c937d8d56f97869cffa55c21d9edb07f5a3912/pylint-3.2.6-py3-none-any.whl", hash = "sha256:03c8e3baa1d9fb995b12c1dbe00aa6c4bcef210c2a2634374aedeb22fb4a8f8f", size = 519798, upload-time = "2024-07-21T19:48:34.788Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
+]
+
+[[package]]
+name = "pytest-asyncio"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
+]
+
+[[package]]
+name = "pytest-mock"
+version = "3.15.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
+]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
+]
+
+[[package]]
+name = "python-gitlab"
+version = "8.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "requests" },
+ { name = "requests-toolbelt" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c4/68/02645bc9d71554e7a263b118e4e55dafe4c4735c1ba74f9712232ed84054/python_gitlab-8.0.0.tar.gz", hash = "sha256:03eae5a9d105448796e6c0e192d402c266057e75790cf4f42c143dddf91313ce", size = 401334, upload-time = "2026-01-28T01:22:27.005Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/52/60/ba68e51e90a99b14af639463e5d617239029ec25927a0990ff28bd851916/python_gitlab-8.0.0-py3-none-any.whl", hash = "sha256:c635e6722c5710d35ddadfcf95c362b0aa8de11ab3972bc4f230ebd58a6c49ee", size = 144483, upload-time = "2026-01-28T01:22:25.772Z" },
+]
+
+[[package]]
+name = "pytokens"
+version = "0.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e5/16/4b9cfd90d55e66ffdb277d7ebe3bc25250c2311336ec3fc73b2673c794d5/pytokens-0.4.0.tar.gz", hash = "sha256:6b0b03e6ea7c9f9d47c5c61164b69ad30f4f0d70a5d9fe7eac4d19f24f77af2d", size = 15039, upload-time = "2026-01-19T07:59:50.623Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b4/05/3196399a353dd4cd99138a88f662810979ee2f1a1cdb0b417cb2f4507836/pytokens-0.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:92eb3ef88f27c22dc9dbab966ace4d61f6826e02ba04dac8e2d65ea31df56c8e", size = 160075, upload-time = "2026-01-19T07:59:00.316Z" },
+ { url = "https://files.pythonhosted.org/packages/28/1d/c8fc4ed0a1c4f660391b201cda00b1d5bbcc00e2998e8bcd48b15eefd708/pytokens-0.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4b77858a680635ee9904306f54b0ee4781effb89e211ba0a773d76539537165", size = 247318, upload-time = "2026-01-19T07:59:01.636Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/0e/53e55ba01f3e858d229cd84b02481542f42ba59050483a78bf2447ee1af7/pytokens-0.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25cacc20c2ad90acb56f3739d87905473c54ca1fa5967ffcd675463fe965865e", size = 259752, upload-time = "2026-01-19T07:59:04.229Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/56/2d930d7f899e3f21868ca6e8ec739ac31e8fc532f66e09cbe45d3df0a84f/pytokens-0.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:628fab535ebc9079e4db35cd63cb401901c7ce8720a9834f9ad44b9eb4e0f1d4", size = 262842, upload-time = "2026-01-19T07:59:06.14Z" },
+ { url = "https://files.pythonhosted.org/packages/42/dd/4e7e6920d23deffaf66e6f40d45f7610dcbc132ca5d90ab4faccef22f624/pytokens-0.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:4d0f568d7e82b7e96be56d03b5081de40e43c904eb6492bf09aaca47cd55f35b", size = 102620, upload-time = "2026-01-19T07:59:07.839Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/65/65460ebbfefd0bc1b160457904370d44f269e6e4582e0a9b6cba7c267b04/pytokens-0.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cd8da894e5a29ba6b6da8be06a4f7589d7220c099b5e363cb0643234b9b38c2a", size = 159864, upload-time = "2026-01-19T07:59:08.908Z" },
+ { url = "https://files.pythonhosted.org/packages/25/70/a46669ec55876c392036b4da9808b5c3b1c5870bbca3d4cc923bf68bdbc1/pytokens-0.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:237ba7cfb677dbd3b01b09860810aceb448871150566b93cd24501d5734a04b1", size = 254448, upload-time = "2026-01-19T07:59:10.594Z" },
+ { url = "https://files.pythonhosted.org/packages/62/0b/c486fc61299c2fc3b7f88ee4e115d4c8b6ffd1a7f88dc94b398b5b1bc4b8/pytokens-0.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01d1a61e36812e4e971cfe2c0e4c1f2d66d8311031dac8bf168af8a249fa04dd", size = 268863, upload-time = "2026-01-19T07:59:12.31Z" },
+ { url = "https://files.pythonhosted.org/packages/79/92/b036af846707d25feaff7cafbd5280f1bd6a1034c16bb06a7c910209c1ab/pytokens-0.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e47e2ef3ec6ee86909e520d79f965f9b23389fda47460303cf715d510a6fe544", size = 267181, upload-time = "2026-01-19T07:59:13.856Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/c0/6d011fc00fefa74ce34816c84a923d2dd7c46b8dbc6ee52d13419786834c/pytokens-0.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:3d36954aba4557fd5a418a03cf595ecbb1cdcce119f91a49b19ef09d691a22ae", size = 102814, upload-time = "2026-01-19T07:59:15.288Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/3c/6941a82f4f130af6e1c68c076b6789069ef10c04559bd4733650f902fd3b/pytokens-0.4.0-py3-none-any.whl", hash = "sha256:0508d11b4de157ee12063901603be87fb0253e8f4cb9305eb168b1202ab92068", size = 13224, upload-time = "2026-01-19T07:59:49.822Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
+ { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
+ { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
+ { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
+ { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+]
+
+[[package]]
+name = "regex"
+version = "2026.1.15"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d0/c9/0c80c96eab96948363d270143138d671d5731c3a692b417629bf3492a9d6/regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a", size = 488168, upload-time = "2026-01-14T23:14:16.129Z" },
+ { url = "https://files.pythonhosted.org/packages/17/f0/271c92f5389a552494c429e5cc38d76d1322eb142fb5db3c8ccc47751468/regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f", size = 290636, upload-time = "2026-01-14T23:14:17.715Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/f9/5f1fd077d106ca5655a0f9ff8f25a1ab55b92128b5713a91ed7134ff688e/regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1", size = 288496, upload-time = "2026-01-14T23:14:19.326Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/e1/8f43b03a4968c748858ec77f746c286d81f896c2e437ccf050ebc5d3128c/regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b", size = 793503, upload-time = "2026-01-14T23:14:20.922Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/4e/a39a5e8edc5377a46a7c875c2f9a626ed3338cb3bb06931be461c3e1a34a/regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8", size = 860535, upload-time = "2026-01-14T23:14:22.405Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/1c/9dce667a32a9477f7a2869c1c767dc00727284a9fa3ff5c09a5c6c03575e/regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413", size = 907225, upload-time = "2026-01-14T23:14:23.897Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/3c/87ca0a02736d16b6262921425e84b48984e77d8e4e572c9072ce96e66c30/regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026", size = 800526, upload-time = "2026-01-14T23:14:26.039Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/ff/647d5715aeea7c87bdcbd2f578f47b415f55c24e361e639fe8c0cc88878f/regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785", size = 773446, upload-time = "2026-01-14T23:14:28.109Z" },
+ { url = "https://files.pythonhosted.org/packages/af/89/bf22cac25cb4ba0fe6bff52ebedbb65b77a179052a9d6037136ae93f42f4/regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e", size = 783051, upload-time = "2026-01-14T23:14:29.929Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/f4/6ed03e71dca6348a5188363a34f5e26ffd5db1404780288ff0d79513bce4/regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763", size = 854485, upload-time = "2026-01-14T23:14:31.366Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/9a/8e8560bd78caded8eb137e3e47612430a05b9a772caf60876435192d670a/regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb", size = 762195, upload-time = "2026-01-14T23:14:32.802Z" },
+ { url = "https://files.pythonhosted.org/packages/38/6b/61fc710f9aa8dfcd764fe27d37edfaa023b1a23305a0d84fccd5adb346ea/regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2", size = 845986, upload-time = "2026-01-14T23:14:34.898Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/2e/fbee4cb93f9d686901a7ca8d94285b80405e8c34fe4107f63ffcbfb56379/regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1", size = 788992, upload-time = "2026-01-14T23:14:37.116Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/14/3076348f3f586de64b1ab75a3fbabdaab7684af7f308ad43be7ef1849e55/regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569", size = 265893, upload-time = "2026-01-14T23:14:38.426Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/19/772cf8b5fc803f5c89ba85d8b1870a1ca580dc482aa030383a9289c82e44/regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7", size = 277840, upload-time = "2026-01-14T23:14:39.785Z" },
+ { url = "https://files.pythonhosted.org/packages/78/84/d05f61142709474da3c0853222d91086d3e1372bcdab516c6fd8d80f3297/regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec", size = 270374, upload-time = "2026-01-14T23:14:41.592Z" },
+ { url = "https://files.pythonhosted.org/packages/92/81/10d8cf43c807d0326efe874c1b79f22bfb0fb226027b0b19ebc26d301408/regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1", size = 489398, upload-time = "2026-01-14T23:14:43.741Z" },
+ { url = "https://files.pythonhosted.org/packages/90/b0/7c2a74e74ef2a7c32de724658a69a862880e3e4155cba992ba04d1c70400/regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681", size = 291339, upload-time = "2026-01-14T23:14:45.183Z" },
+ { url = "https://files.pythonhosted.org/packages/19/4d/16d0773d0c818417f4cc20aa0da90064b966d22cd62a8c46765b5bd2d643/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f", size = 289003, upload-time = "2026-01-14T23:14:47.25Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/e4/1fc4599450c9f0863d9406e944592d968b8d6dfd0d552a7d569e43bceada/regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa", size = 798656, upload-time = "2026-01-14T23:14:48.77Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/e6/59650d73a73fa8a60b3a590545bfcf1172b4384a7df2e7fe7b9aab4e2da9/regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804", size = 864252, upload-time = "2026-01-14T23:14:50.528Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/ab/1d0f4d50a1638849a97d731364c9a80fa304fec46325e48330c170ee8e80/regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c", size = 912268, upload-time = "2026-01-14T23:14:52.952Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/df/0d722c030c82faa1d331d1921ee268a4e8fb55ca8b9042c9341c352f17fa/regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5", size = 803589, upload-time = "2026-01-14T23:14:55.182Z" },
+ { url = "https://files.pythonhosted.org/packages/66/23/33289beba7ccb8b805c6610a8913d0131f834928afc555b241caabd422a9/regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3", size = 775700, upload-time = "2026-01-14T23:14:56.707Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/65/bf3a42fa6897a0d3afa81acb25c42f4b71c274f698ceabd75523259f6688/regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb", size = 787928, upload-time = "2026-01-14T23:14:58.312Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f5/13bf65864fc314f68cdd6d8ca94adcab064d4d39dbd0b10fef29a9da48fc/regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410", size = 858607, upload-time = "2026-01-14T23:15:00.657Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/31/040e589834d7a439ee43fb0e1e902bc81bd58a5ba81acffe586bb3321d35/regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4", size = 763729, upload-time = "2026-01-14T23:15:02.248Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/84/6921e8129687a427edf25a34a5594b588b6d88f491320b9de5b6339a4fcb/regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d", size = 850697, upload-time = "2026-01-14T23:15:03.878Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/87/3d06143d4b128f4229158f2de5de6c8f2485170c7221e61bf381313314b2/regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22", size = 789849, upload-time = "2026-01-14T23:15:06.102Z" },
+ { url = "https://files.pythonhosted.org/packages/77/69/c50a63842b6bd48850ebc7ab22d46e7a2a32d824ad6c605b218441814639/regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913", size = 266279, upload-time = "2026-01-14T23:15:07.678Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/36/39d0b29d087e2b11fd8191e15e81cce1b635fcc845297c67f11d0d19274d/regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a", size = 277166, upload-time = "2026-01-14T23:15:09.257Z" },
+ { url = "https://files.pythonhosted.org/packages/28/32/5b8e476a12262748851fa8ab1b0be540360692325975b094e594dfebbb52/regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056", size = 270415, upload-time = "2026-01-14T23:15:10.743Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.32.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
+]
+
+[[package]]
+name = "requests-toolbelt"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
+]
+
+[[package]]
+name = "responses"
+version = "0.25.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyyaml" },
+ { name = "requests" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0e/95/89c054ad70bfef6da605338b009b2e283485835351a9935c7bfbfaca7ffc/responses-0.25.8.tar.gz", hash = "sha256:9374d047a575c8f781b94454db5cab590b6029505f488d12899ddb10a4af1cf4", size = 79320, upload-time = "2025-08-08T19:01:46.709Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1c/4c/cc276ce57e572c102d9542d383b2cfd551276581dc60004cb94fe8774c11/responses-0.25.8-py3-none-any.whl", hash = "sha256:0c710af92def29c8352ceadff0c3fe340ace27cf5af1bbe46fb71275bcd2831c", size = 34769, upload-time = "2025-08-08T19:01:45.018Z" },
+]
+
+[[package]]
+name = "rich"
+version = "14.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a1/84/4831f881aa6ff3c976f6d6809b58cdfa350593ffc0dc3c58f5f6586780fb/rich-14.3.1.tar.gz", hash = "sha256:b8c5f568a3a749f9290ec6bddedf835cec33696bfc1e48bcfecb276c7386e4b8", size = 230125, upload-time = "2026-01-24T21:40:44.847Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/87/2a/a1810c8627b9ec8c57ec5ec325d306701ae7be50235e8fd81266e002a3cc/rich-14.3.1-py3-none-any.whl", hash = "sha256:da750b1aebbff0b372557426fb3f35ba56de8ef954b3190315eb64076d6fb54e", size = 309952, upload-time = "2026-01-24T21:40:42.969Z" },
+]
+
+[[package]]
+name = "rsa"
+version = "4.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyasn1" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
+]
+
+[[package]]
+name = "s3transfer"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "botocore" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" },
+]
+
+[[package]]
+name = "sentry-sdk"
+version = "2.51.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6f/9f/094bbb6be5cf218ab6712c6528310687f3d3fe8818249fcfe1d74192f7c5/sentry_sdk-2.51.0.tar.gz", hash = "sha256:b89d64577075fd8c13088bc3609a2ce77a154e5beb8cba7cc16560b0539df4f7", size = 407447, upload-time = "2026-01-28T10:29:50.962Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/da/df379404d484ca9dede4ad8abead5de828cdcff35623cd44f0351cf6869c/sentry_sdk-2.51.0-py2.py3-none-any.whl", hash = "sha256:e21016d318a097c2b617bb980afd9fc737e1efc55f9b4f0cdc819982c9717d5f", size = 431426, upload-time = "2026-01-28T10:29:48.868Z" },
+]
+
+[[package]]
+name = "shellingham"
+version = "1.5.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
+]
+
+[[package]]
+name = "syrupy"
+version = "5.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2e/b0/24bca682d6a6337854be37f242d116cceeda9942571d5804c44bc1bdd427/syrupy-5.1.0.tar.gz", hash = "sha256:df543c7aa50d3cf1246e83d58fe490afe5f7dab7b41e74ecc0d8d23ae19bd4b8", size = 50495, upload-time = "2026-01-25T14:53:06.2Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/70/cf880c3b95a6034ef673e74b369941b42315c01f1554a5637a4f8b911009/syrupy-5.1.0-py3-none-any.whl", hash = "sha256:95162d2b05e61ed3e13f117b88dfab7c58bd6f90e66ebbf918e8a77114ad51c5", size = 51658, upload-time = "2026-01-25T14:53:05.105Z" },
+]
+
+[[package]]
+name = "tabulate"
+version = "0.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" },
+]
+
+[[package]]
+name = "tblib"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/64/e3/d9aebe40d15d2c4c73a0ff8555326ef42a62ce3e5320ceb1aa762e4fbb54/tblib-2.0.0.tar.gz", hash = "sha256:a6df30f272c08bf8be66e0775fad862005d950a6b8449b94f7c788731d70ecd7", size = 28695, upload-time = "2023-06-22T08:24:16.494Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/bd/ccb241b97e39dd8ec143418f89b3fd5752f872c862877a0f1b2d9fb9e815/tblib-2.0.0-py3-none-any.whl", hash = "sha256:9100bfa016b047d5b980d66e7efed952fbd20bd85b56110aaf473cb97d18709a", size = 11455, upload-time = "2023-06-22T08:24:14.248Z" },
+]
+
+[[package]]
+name = "tenacity"
+version = "9.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" },
+]
+
+[[package]]
+name = "tiktoken"
+version = "0.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "regex" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" },
+ { url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/d0/3d9275198e067f8b65076a68894bb52fd253875f3644f0a321a720277b8a/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded", size = 1152444, upload-time = "2025-10-06T20:21:48.139Z" },
+ { url = "https://files.pythonhosted.org/packages/78/db/a58e09687c1698a7c592e1038e01c206569b86a0377828d51635561f8ebf/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd", size = 1195080, upload-time = "2025-10-06T20:21:49.246Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/1b/a9e4d2bf91d515c0f74afc526fd773a812232dd6cda33ebea7f531202325/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967", size = 1255240, upload-time = "2025-10-06T20:21:50.274Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/15/963819345f1b1fb0809070a79e9dd96938d4ca41297367d471733e79c76c/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def", size = 879422, upload-time = "2025-10-06T20:21:51.734Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" },
+ { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" },
+ { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" },
+ { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" },
+]
+
+[[package]]
+name = "together"
+version = "1.5.35"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohttp" },
+ { name = "black" },
+ { name = "click" },
+ { name = "eval-type-backport" },
+ { name = "filelock" },
+ { name = "numpy" },
+ { name = "pillow" },
+ { name = "pydantic" },
+ { name = "requests" },
+ { name = "rich" },
+ { name = "tabulate" },
+ { name = "tqdm" },
+ { name = "typer" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5a/1d/6c50e0e32af097d966723e63b8e5ee02cfb002a40b6095c8ac65d6c08fe8/together-1.5.35.tar.gz", hash = "sha256:db3fc7dbc04dca044f437cd28224432e17567e6650dc1afd09780b48c0187cff", size = 91037, upload-time = "2026-01-21T23:15:15.909Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/68/e6/cd079d92f9eab83cd48c932e9b1e5e7fe25d90576f913dde66373135c392/together-1.5.35-py3-none-any.whl", hash = "sha256:74b6192e26492dbce2570fb801f884e74739bae1045b20c5b070a71639d7d5fc", size = 120461, upload-time = "2026-01-21T23:15:14.054Z" },
+]
+
+[[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 = "tomlkit"
+version = "0.14.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" },
+]
+
+[[package]]
+name = "tqdm"
+version = "4.67.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
+]
+
+[[package]]
+name = "traceback-with-variables"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/34/b1/25ee53be3125145cef9385f159a44547b4a01e1b2d2828055ca69b7e18aa/traceback_with_variables-2.2.1.tar.gz", hash = "sha256:ea7c695f9b401762f68f75df0439d661112b8dbd58bcd6910e402cff925ad7e0", size = 26145, upload-time = "2025-10-24T13:39:35.141Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4a/06/fda9970d55fbbb7cd5cc856da2a9c693107e20f84386596da1f25b90a8cf/traceback_with_variables-2.2.1-py3-none-any.whl", hash = "sha256:ab6d75c72d26d61217962d11db44c98c62dccd2fedb2d4fb0ae4f9faf9db23c2", size = 22388, upload-time = "2025-10-24T13:29:32.712Z" },
+]
+
+[[package]]
+name = "typeid-python"
+version = "0.3.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "uuid-utils" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/83/d1b140e4941a05ab7f4ccc2a5466b37fa559f48a9d3684d8107a7511508f/typeid_python-0.3.9.tar.gz", hash = "sha256:7cf7ede21e6ba8f272981dbae504d1256261d03edab42fb05d36787dbfd589bd", size = 25201, upload-time = "2026-01-28T18:23:38.917Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5b/30/523b728eab3157d818818ac022579b37b2d5f7994eb6e2bb9636a08a712a/typeid_python-0.3.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c37992e3aeee2ca2d1c5412a8a1bbbff71454c7db30b343a40ac2ab7ffe0d892", size = 241454, upload-time = "2026-01-28T18:23:14.452Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/da/13d577f76f5fceb77dbdc94b8435d86fbbbe472d5fa86245a2ece22ece20/typeid_python-0.3.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5b72f9e75eb8ae229e56c00a25330fbb9881f76f9b3a67fd3e35470328db1078", size = 238088, upload-time = "2026-01-28T18:23:16.429Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/36/a81e397a341d51315288835a809c5038835f925da00d11dce3e10115693f/typeid_python-0.3.9-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b035ef63c243776783551963983d12146b134c52475edfc42a33ce0a9baea337", size = 271957, upload-time = "2026-01-28T18:23:18.236Z" },
+ { url = "https://files.pythonhosted.org/packages/03/06/dd85db0de10c6337e3e9fc457fda009f4cdac83fa873418c7e19c6041ecb/typeid_python-0.3.9-cp311-cp311-win_amd64.whl", hash = "sha256:751b0aaeeb5c8c0bd87d15c77ffe52df86c372d66572e6b2ec3fb2df056d790e", size = 132071, upload-time = "2026-01-28T18:23:19.583Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/7b/bb76c6016f862b9cb5a4e7d6cb3d1378cc342793644ba6a2022f05790dc6/typeid_python-0.3.9-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:fc193d8a17529c65915273a9734e7d724b8b279450d01b2559a77b8bd53f52da", size = 240430, upload-time = "2026-01-28T18:23:21.506Z" },
+ { url = "https://files.pythonhosted.org/packages/20/0c/f8834a0d465afc4ea194d43fe10dd11e23874f5c102cec260172108a5cdc/typeid_python-0.3.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7dedb9894dea2a39653822224af6292710a54af0c9f48ba896bea6c22b6bff06", size = 236578, upload-time = "2026-01-28T18:23:22.874Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/29/c9842c47610213821cddbb274d093e36dc5d4346195eb5f036856f7d15f3/typeid_python-0.3.9-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:20ce022fe7d6be11a0d332d794e75a056b5aa225969f4b8ef9dbe4a94e651f2c", size = 270059, upload-time = "2026-01-28T18:23:24.396Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/87/9c3ae9936491f29ff09dcdbfdc4476368cc75b09be515ab31c5a2519ea7d/typeid_python-0.3.9-cp312-cp312-win_amd64.whl", hash = "sha256:bba27b8d1708e9654b85ffbcc4fc89a7c1d6cea65fbdab7fb9069d8cd71c2acd", size = 131062, upload-time = "2026-01-28T18:23:25.832Z" },
+]
+
+[[package]]
+name = "typer"
+version = "0.19.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "rich" },
+ { name = "shellingham" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/21/ca/950278884e2ca20547ff3eb109478c6baf6b8cf219318e6bc4f666fad8e8/typer-0.19.2.tar.gz", hash = "sha256:9ad824308ded0ad06cc716434705f691d4ee0bfd0fb081839d2e426860e7fdca", size = 104755, upload-time = "2025-09-23T09:47:48.256Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/00/22/35617eee79080a5d071d0f14ad698d325ee6b3bf824fc0467c03b30e7fa8/typer-0.19.2-py3-none-any.whl", hash = "sha256:755e7e19670ffad8283db353267cb81ef252f595aa6834a0d1ca9312d9326cb9", size = 46748, upload-time = "2025-09-23T09:47:46.777Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
+]
+
+[[package]]
+name = "uuid-utils"
+version = "0.14.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/57/7c/3a926e847516e67bc6838634f2e54e24381105b4e80f9338dc35cca0086b/uuid_utils-0.14.0.tar.gz", hash = "sha256:fc5bac21e9933ea6c590433c11aa54aaca599f690c08069e364eb13a12f670b4", size = 22072, upload-time = "2026-01-20T20:37:15.729Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/42/42d003f4a99ddc901eef2fd41acb3694163835e037fb6dde79ad68a72342/uuid_utils-0.14.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:f6695c0bed8b18a904321e115afe73b34444bc8451d0ce3244a1ec3b84deb0e5", size = 601786, upload-time = "2026-01-20T20:37:09.843Z" },
+ { url = "https://files.pythonhosted.org/packages/96/e6/775dfb91f74b18f7207e3201eb31ee666d286579990dc69dd50db2d92813/uuid_utils-0.14.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4f0a730bbf2d8bb2c11b93e1005e91769f2f533fa1125ed1f00fd15b6fcc732b", size = 303943, upload-time = "2026-01-20T20:37:18.767Z" },
+ { url = "https://files.pythonhosted.org/packages/17/82/ea5f5e85560b08a1f30cdc65f75e76494dc7aba9773f679e7eaa27370229/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40ce3fd1a4fdedae618fc3edc8faf91897012469169d600133470f49fd699ed3", size = 340467, upload-time = "2026-01-20T20:37:11.794Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/33/54b06415767f4569882e99b6470c6c8eeb97422686a6d432464f9967fd91/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:09ae4a98416a440e78f7d9543d11b11cae4bab538b7ed94ec5da5221481748f2", size = 346333, upload-time = "2026-01-20T20:37:12.818Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/10/a6bce636b8f95e65dc84bf4a58ce8205b8e0a2a300a38cdbc83a3f763d27/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:971e8c26b90d8ae727e7f2ac3ee23e265971d448b3672882f2eb44828b2b8c3e", size = 470859, upload-time = "2026-01-20T20:37:01.512Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/27/84121c51ea72f013f0e03d0886bcdfa96b31c9b83c98300a7bd5cc4fa191/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5cde1fa82804a8f9d2907b7aec2009d440062c63f04abbdb825fce717a5e860", size = 341988, upload-time = "2026-01-20T20:37:22.881Z" },
+ { url = "https://files.pythonhosted.org/packages/90/a4/01c1c7af5e6a44f20b40183e8dac37d6ed83e7dc9e8df85370a15959b804/uuid_utils-0.14.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7343862a2359e0bd48a7f3dfb5105877a1728677818bb694d9f40703264a2db", size = 365784, upload-time = "2026-01-20T20:37:10.808Z" },
+ { url = "https://files.pythonhosted.org/packages/04/f0/65ee43ec617b8b6b1bf2a5aecd56a069a08cca3d9340c1de86024331bde3/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c51e4818fdb08ccec12dc7083a01f49507b4608770a0ab22368001685d59381b", size = 523750, upload-time = "2026-01-20T20:37:06.152Z" },
+ { url = "https://files.pythonhosted.org/packages/95/d3/6bf503e3f135a5dfe705a65e6f89f19bccd55ac3fb16cb5d3ec5ba5388b8/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:181bbcccb6f93d80a8504b5bd47b311a1c31395139596edbc47b154b0685b533", size = 615818, upload-time = "2026-01-20T20:37:21.816Z" },
+ { url = "https://files.pythonhosted.org/packages/df/6c/99937dd78d07f73bba831c8dc9469dfe4696539eba2fc269ae1b92752f9e/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:5c8ae96101c3524ba8dbf762b6f05e9e9d896544786c503a727c5bf5cb9af1a7", size = 580831, upload-time = "2026-01-20T20:37:19.691Z" },
+ { url = "https://files.pythonhosted.org/packages/44/fa/bbc9e2c25abd09a293b9b097a0d8fc16acd6a92854f0ec080f1ea7ad8bb3/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00ac3c6edfdaff7e1eed041f4800ae09a3361287be780d7610a90fdcde9befdc", size = 546333, upload-time = "2026-01-20T20:37:03.117Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/9b/e5e99b324b1b5f0c62882230455786df0bc66f67eff3b452447e703f45d2/uuid_utils-0.14.0-cp39-abi3-win32.whl", hash = "sha256:ec2fd80adf8e0e6589d40699e6f6df94c93edcc16dd999be0438dd007c77b151", size = 177319, upload-time = "2026-01-20T20:37:04.208Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/28/2c7d417ea483b6ff7820c948678fdf2ac98899dc7e43bb15852faa95acaf/uuid_utils-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:efe881eb43a5504fad922644cb93d725fd8a6a6d949bd5a4b4b7d1a1587c7fd1", size = 182566, upload-time = "2026-01-20T20:37:16.868Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/86/49e4bdda28e962fbd7266684171ee29b3d92019116971d58783e51770745/uuid_utils-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:32b372b8fd4ebd44d3a219e093fe981af4afdeda2994ee7db208ab065cfcd080", size = 182809, upload-time = "2026-01-20T20:37:05.139Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/03/1f1146e32e94d1f260dfabc81e1649102083303fb4ad549775c943425d9a/uuid_utils-0.14.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:762e8d67992ac4d2454e24a141a1c82142b5bde10409818c62adbe9924ebc86d", size = 587430, upload-time = "2026-01-20T20:37:24.998Z" },
+ { url = "https://files.pythonhosted.org/packages/87/ba/d5a7469362594d885fd9219fe9e851efbe65101d3ef1ef25ea321d7ce841/uuid_utils-0.14.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:40be5bf0b13aa849d9062abc86c198be6a25ff35316ce0b89fc25f3bac6d525e", size = 298106, upload-time = "2026-01-20T20:37:23.896Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/11/3dafb2a5502586f59fd49e93f5802cd5face82921b3a0f3abb5f357cb879/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:191a90a6f3940d1b7322b6e6cceff4dd533c943659e0a15f788674407856a515", size = 333423, upload-time = "2026-01-20T20:37:17.828Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f2/c8987663f0cdcf4d717a36d85b5db2a5589df0a4e129aa10f16f4380ef48/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4aa4525f4ad82f9d9c842f9a3703f1539c1808affbaec07bb1b842f6b8b96aa5", size = 338659, upload-time = "2026-01-20T20:37:14.286Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/c8/929d81665d83f0b2ffaecb8e66c3091a50f62c7cb5b65e678bd75a96684e/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdbd82ff20147461caefc375551595ecf77ebb384e46267f128aca45a0f2cdfc", size = 467029, upload-time = "2026-01-20T20:37:08.277Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/a0/27d7daa1bfed7163f4ccaf52d7d2f4ad7bb1002a85b45077938b91ee584f/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eff57e8a5d540006ce73cf0841a643d445afe78ba12e75ac53a95ca2924a56be", size = 333298, upload-time = "2026-01-20T20:37:07.271Z" },
+ { url = "https://files.pythonhosted.org/packages/63/d4/acad86ce012b42ce18a12f31ee2aa3cbeeb98664f865f05f68c882945913/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3fd9112ca96978361201e669729784f26c71fecc9c13a7f8a07162c31bd4d1e2", size = 359217, upload-time = "2026-01-20T20:36:59.687Z" },
+]
+
+[[package]]
+name = "websockets"
+version = "15.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
+ { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
+ { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
+ { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
+ { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
+ { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
+ { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
+ { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
+ { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
+ { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
+ { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
+]
+
+[[package]]
+name = "werkzeug"
+version = "3.1.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" },
+]
+
+[[package]]
+name = "win32-setctime"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
+]
+
+[[package]]
+name = "xmltodict"
+version = "1.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6a/aa/917ceeed4dbb80d2f04dbd0c784b7ee7bba8ae5a54837ef0e5e062cd3cfb/xmltodict-1.0.2.tar.gz", hash = "sha256:54306780b7c2175a3967cad1db92f218207e5bc1aba697d887807c0fb68b7649", size = 25725, upload-time = "2025-09-17T21:59:26.459Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c0/20/69a0e6058bc5ea74892d089d64dfc3a62ba78917ec5e2cfa70f7c92ba3a5/xmltodict-1.0.2-py3-none-any.whl", hash = "sha256:62d0fddb0dcbc9f642745d8bbf4d81fd17d6dfaec5a15b5c1876300aad92af0d", size = 13893, upload-time = "2025-09-17T21:59:24.859Z" },
+]
+
+[[package]]
+name = "yarl"
+version = "1.22.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "multidict" },
+ { name = "propcache" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" },
+ { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" },
+ { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" },
+ { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" },
+ { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" },
+ { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" },
+ { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" },
+ { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" },
+ { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" },
+ { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" },
+ { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" },
+ { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" },
+ { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" },
+ { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" },
+ { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" },
+ { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" },
+ { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" },
+ { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" },
+]
+
+[[package]]
+name = "yasoo"
+version = "0.12.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "more-itertools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cf/00/a1ed9035b00254227c684161c3d4037f767ed53a1993b69c00c9d4d94f25/yasoo-0.12.6.tar.gz", hash = "sha256:aec81e790045198e8f51f92353f11923580f1c94f49eed2b543f286e4cc1c5cc", size = 13705, upload-time = "2022-10-22T10:05:39.619Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/68/21/cfd73fd9cc69855bffdda55cbeefa45ffd415f97704f67ceda1eb57141ad/yasoo-0.12.6-py3-none-any.whl", hash = "sha256:7400ff055c2153d670c04c82621fd4aeafc3f1fc7c0f99240831752357b67e1f", size = 14850, upload-time = "2022-10-22T10:05:38.4Z" },
+]
diff --git a/imbue_tools/README.md b/imbue_tools/README.md
@@ -0,0 +1,9 @@
+# Purpose
+Shared functionality for imbue-cli tools like imbue-verify, imbue-retrieve, etc.
+
+# Contents
+- formatting git repos as LLM input
+
+# Excluded files list
+We currently maintain a hard-coded list of filename patterns for which we'll only ever include the file name, rather than the files' contents, in the LLM input.
+This list is maintained in the EXCLUSIONS_PATHSPEC constant in imbue_tools/imbue_tools/repo_utils/context_prefix.py .
diff --git a/imbue_tools/imbue_tools/__init__.py b/imbue_tools/imbue_tools/__init__.py
diff --git a/imbue_tools/imbue_tools/capabilities_data_logging/common.py b/imbue_tools/imbue_tools/capabilities_data_logging/common.py
@@ -0,0 +1,83 @@
+import abc
+import datetime
+import subprocess
+from enum import StrEnum
+from pathlib import Path
+from typing import Any
+from typing import Iterable
+
+from loguru import logger
+from psycopg.sql import SQL
+
+from imbue_core.pydantic_serialization import SerializableModel
+
+IMBUE_AUTOMATIC_TESTING_ORGANIZATION_ID = "imbue-automatic-testing"
+NEON_PROJECT_ID = (
+ "holy-butterfly-05886102" # This is the ID of the crafty project in neon.tech. TODO: reuse this for now
+)
+
+
+# TODO: this should be shared with `sculptor` but there's not a great shared module right now.
+# Make sure if this is updated the value in `sculptor` also gets updated
+# Path where imbue verify will log data and logged data will be expected to be found
+CAPABILITIES_DATA_LOGGING_PATH = Path("/tmp/sculptor/capabilities_logging")
+
+
+class ProductDataBaseEventRecord(SerializableModel, abc.ABC):
+ """
+ This is a base class for all product data events.
+ It contains the fields that are common to all product data events.
+ """
+
+ id: str
+ user_id: str
+ organization_id: str
+ created_at: datetime.datetime
+
+
+class SculptorDataTables(StrEnum):
+ PRODUCT_TOOL_DATA = "PRODUCT_TOOL_DATA"
+
+
+def build_product_feature_data_query_args(
+ user_id: str | None = None,
+ creation_bounds: tuple[datetime.datetime | None, datetime.datetime | None] = (
+ None,
+ None,
+ ),
+ ids: Iterable[str] | None = None,
+ filter_test_users: bool = False,
+) -> tuple[Any, tuple[Any, ...]]:
+ where_clause: Any = SQL("1 = 1")
+ where_args: tuple[Any, ...] = ()
+ start, end = creation_bounds
+ if start is not None:
+ where_clause = SQL("{} AND {}").format(where_clause, SQL("created_at >= %s"))
+ where_args += (start,)
+ if end is not None:
+ where_clause = SQL("{} AND {}").format(where_clause, SQL("created_at <= %s"))
+ where_args += (end,)
+ if user_id is not None:
+ where_clause = SQL("{} AND {}").format(where_clause, SQL("user_id = %s"))
+ where_args += (user_id,)
+ if ids is not None:
+ where_clause = SQL("{} AND {}").format(where_clause, SQL("id = ANY(%s)"))
+ where_args += (ids,)
+ if filter_test_users:
+ where_clause = SQL("{} AND NOT {}").format(where_clause, SQL("organization_id = %s"))
+ where_args += (IMBUE_AUTOMATIC_TESTING_ORGANIZATION_ID,)
+ return where_clause, where_args
+
+
+UNKNOWN_USER_NAME = "unknown"
+
+
+def get_current_user_name() -> str:
+ try:
+ possible_username = subprocess.check_output(["git", "config", "user.name"], universal_newlines=True).strip()
+ assert possible_username != ""
+ return possible_username
+ except subprocess.CalledProcessError:
+ pass
+ logger.info("Using UNKNOWN_USER_NAME")
+ return UNKNOWN_USER_NAME
diff --git a/imbue_tools/imbue_tools/capabilities_data_logging/data_types.py b/imbue_tools/imbue_tools/capabilities_data_logging/data_types.py
@@ -0,0 +1,250 @@
+"""
+Any fields in this file that are optional are not strictly required to be present in the database.
+Any fields that are non-optional are required and should not be changed without talking to capabilities eval group.
+Much of this code is taken from an unmerged branch `pranali/backwards_compatibility`.
+The write_code event is also supported in the minimal format in that branch.
+"""
+
+import uuid
+from datetime import datetime
+from datetime import timezone
+from enum import StrEnum
+from typing import Annotated
+from typing import Any
+from typing import Callable
+from typing import Self
+from typing import TypeVar
+
+from pydantic import ConfigDict
+from pydantic import Field
+from pydantic import PlainValidator
+from pydantic import ValidationError
+from pydantic import field_validator
+from pydantic import model_validator
+
+from imbue_core.agents.configs import LanguageModelGenerationConfig
+from imbue_core.common import generate_id
+from imbue_core.data_types import IdentifiedVerifyIssue
+from imbue_core.data_types import LLMResponse
+from imbue_core.frozen_utils import FrozenDict
+from imbue_core.frozen_utils import empty_mapping
+from imbue_core.nested_evolver import assign
+from imbue_core.nested_evolver import chill
+from imbue_core.nested_evolver import evolver
+from imbue_core.pydantic_serialization import SerializableModel
+from imbue_core.repo_state import RepoState
+from imbue_core.sculptor.state.messages import ConversationMessageUnion
+from imbue_tools.repo_utils.context_prefix import SubrepoContext
+from imbue_tools.types.imbue_verify_config import ImbueVerifyConfig
+
+TypeInput = TypeVar("TypeInput")
+TypeOutput = TypeVar("TypeOutput")
+
+
+class UnknownFeatureType(Exception):
+ """Exception raised when an unknown feature type is encountered."""
+
+
+class LoggedFeatureType(StrEnum):
+ VERIFY_EXCEPTION = "VERIFY_EXCEPTION"
+ COMMAND_RUN = "COMMAND_RUN"
+ ISSUE_FEEDBACK = "ISSUE_FEEDBACK"
+ UNKNOWN = "UNKNOWN"
+
+
+class CommandType(StrEnum):
+ IMBUE_VERIFY = "IMBUE_VERIFY"
+
+
+# TODO this is WEIRD
+EVENT_BY_LOGGED_FEATURE_TYPE: dict[LoggedFeatureType, Callable[[], type["CapabilitiesLoggedEvent"]]] = {
+ LoggedFeatureType.VERIFY_EXCEPTION: lambda: ImbueVerifyEvent,
+ LoggedFeatureType.COMMAND_RUN: lambda: ImbueVerifyEvent,
+ LoggedFeatureType.ISSUE_FEEDBACK: lambda: IssueFeedbackReport,
+}
+
+# TODO: had to pull in code from crafty in here to make this work, revisit what needs to stay, what should become shared, what we don't need
+
+
+def make_string_safe_for_formatting(s: str) -> str:
+ """
+ Make a string safe for things like str.format()
+ """
+ # Replace each '{' with '{{' and each '}' with '}}'
+ return s.replace("{", "{{").replace("}", "}}")
+
+
+class IssueKey(SerializableModel):
+ # TODO: this should likely be shared with the product code in v1
+ issue_type: CommandType
+ # this should NOT contain line numbers, as we want it to be stable across changes as much as possible
+ # NOTE: we do some initial formatting to avoid issues around message containing code
+ message: Annotated[str, PlainValidator(make_string_safe_for_formatting)]
+
+ # NOTE: this is the error code for pyre, ruff, and imbue_verify, and the test name for pytest
+ error_type: str | None = None
+
+ def commit_message(self) -> str:
+ return f"Fix {self.issue_type} issue: {self.message[:20]}"
+
+
+class CapabilitiesLoggedEvent(SerializableModel):
+ # TODO: Maybe convert empty optional strings and tuples and other datatypes to be Nones so there is only one notion of emptiness
+ # Though in some cases there actually is a difference: eg current_issues not existing or there being 0
+ # Null values are generally going to correspond to (possibly intentionally) 'missing' data
+ """
+ Most types in this class are optional since they may not be present for every type of event.
+ In the future additional fields may be added to this class to support new events, but they should be optional.
+ """
+
+ model_config = ConfigDict(frozen=True)
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+ id: str = Field(default_factory=lambda: uuid.uuid4().hex)
+ user_id: str
+ organization_id: str
+ git_url: str | None = None
+ git_hash: str | None = None
+ subrepo_context: SubrepoContext | None = None
+ instruction_context: SubrepoContext | None = None
+ conversation_history: tuple[ConversationMessageUnion, ...] | None = None
+
+ server_version: FrozenDict[str, Any] = Field(default_factory=empty_mapping)
+
+ feature_name: LoggedFeatureType | None = None
+ repo_state: RepoState | None = None
+
+ # TODO: Add configuration settings about how sculptor was started
+
+ # Command run specific fields
+ diff: str | None = None
+ command_type: CommandType | None = None
+ task_description: str | None = None
+ # For feedback
+ issue_key: IssueKey | None = None
+
+ # Output fields
+ has_output: bool = False
+ output_completion_time: datetime | None = None
+ llm_response: str | None = Field(default=None, deprecated=True)
+ llm_responses: tuple[LLMResponse, ...] | None = None
+ # Command run output fields
+ issues: tuple[IdentifiedVerifyIssue, ...] | None = None
+
+ # Feedback fields
+ feedback_rating: str | None = None
+ feedback_text: str | None = None
+
+ # Imbue verify specific fields
+ generation_config: LanguageModelGenerationConfig | None = None
+ imbue_verify_config: ImbueVerifyConfig | None = None
+
+ # pyre-ignore[56]: pyre's stubs don't match pydantic v2 decorator signatures
+ @field_validator("server_version", mode="before")
+ @classmethod
+ def validate_server_version(cls, v: Any) -> Any:
+ if isinstance(v, dict):
+ return FrozenDict(v)
+ return v
+
+ @classmethod
+ def build_from_json(cls, json_data: str) -> "CapabilitiesLoggedEvent":
+ # mypy is unhappy if this is -> Self because of event = event_type.build_from_json(json_data) below
+ event = cls.model_validate_json(json_data)
+ if event.feature_name:
+ feature_name = event.feature_name
+ if feature_name in EVENT_BY_LOGGED_FEATURE_TYPE:
+ event_type = EVENT_BY_LOGGED_FEATURE_TYPE[feature_name]()
+ try:
+ event = event_type.build_from_json(json_data)
+ except ValidationError as e:
+ print(e)
+ return event
+
+ def build_new_event_with_outputs(self, outputs: Any) -> Self:
+ raise NotImplementedError("Should be implemented by subclasses")
+
+
+class ImbueVerifyUsage(SerializableModel):
+ model_config = ConfigDict(frozen=True)
+
+ num_llm_calls: int
+ total_cost: float
+ total_input_tokens: int
+ total_output_tokens: int
+ total_cache_creation_tokens: int
+ total_cache_read_tokens: int
+
+
+class CostedImbueVerifyEvent(SerializableModel):
+ model_config = ConfigDict(frozen=True)
+ event: "ImbueVerifyEvent"
+ usage: ImbueVerifyUsage
+
+
+class ImbueVerifyEvent(CapabilitiesLoggedEvent):
+ """
+ Events for `imbue_verify`.
+ """
+
+ diff: str
+ command_type: CommandType
+ feature_name: LoggedFeatureType = LoggedFeatureType.COMMAND_RUN
+
+ task_description: str
+ generation_config: LanguageModelGenerationConfig
+ imbue_verify_config: ImbueVerifyConfig = Field(default_factory=ImbueVerifyConfig)
+ server_version: FrozenDict[str, Any] = Field(default_factory=empty_mapping)
+ # TODO: Get the direct llm response for command run events as well
+
+ exception_name: str | None = None
+
+ # pyre-ignore[56]: pyre's stubs don't match pydantic v2 decorator signatures
+ @model_validator(mode="after")
+ def ensure_reasonable_output(self) -> Self:
+ if self.has_output:
+ if self.issues is None:
+ raise ValueError("If has_output is True, there must be some issues")
+ return self
+
+ # TODO the only sites at which this is constructed have this info available.
+ # should simplify by providing it at construction time rather than using evolver
+ def build_new_event_with_outputs(
+ self,
+ outputs: tuple[tuple[IdentifiedVerifyIssue, ...], tuple[LLMResponse, ...], str | None],
+ ) -> Self:
+ issues, llm_responses, git_url = outputs
+ event_evolver = evolver(self)
+ assign(event_evolver.issues, lambda: issues)
+ assign(event_evolver.llm_responses, lambda: llm_responses)
+ assign(event_evolver.has_output, lambda: True)
+ assign(event_evolver.id, lambda: generate_id())
+ assign(event_evolver.output_completion_time, lambda: datetime.now(timezone.utc))
+ assign(event_evolver.git_url, lambda: git_url)
+ event_with_outputs = chill(event_evolver)
+ return event_with_outputs
+
+ @classmethod
+ def build_from_json(cls, json_data: str) -> Self:
+ return cls.model_validate_json(json_data)
+
+
+class IssueFeedbackReport(CapabilitiesLoggedEvent):
+ # TODO: this is copied and not updated from crafty
+ issue_key: IssueKey
+ device_id: str | None = None
+ session_id: str | None = None
+ browser_id: str | None = None
+ tab_id: str | None = None
+ feature_name: LoggedFeatureType = LoggedFeatureType.ISSUE_FEEDBACK
+
+ # pyre-ignore[56]: pyre's stubs don't match pydantic v2 decorator signatures
+ @model_validator(mode="after")
+ def has_some_feedback(self) -> Self:
+ if self.feedback_rating is None and self.feedback_text is None:
+ raise ValueError("At least one of feedback_rating or feedback_text must be set")
+ return self
+
+ @classmethod
+ def build_from_json(cls, json_data: str) -> "IssueFeedbackReport":
+ event = IssueFeedbackReport.model_validate_json(json_data)
+ return event
diff --git a/imbue_tools/imbue_tools/conftest.py b/imbue_tools/imbue_tools/conftest.py
@@ -0,0 +1,44 @@
+from pathlib import Path
+from typing import Callable
+from typing import Generator
+
+import pytest
+from pytest_asyncio import fixture as async_fixture
+from syrupy.assertion import SnapshotAssertion
+
+from imbue_core.agents.configs import LanguageModelGenerationConfig
+from imbue_core.agents.llm_apis.anthropic_api import AnthropicModelName
+from imbue_core.async_monkey_patches_test import explode_on_error # noqa: F401
+from imbue_core.test_repo_utils import make_simple_test_git_repo
+from imbue_core.test_utils import make_llm_cache_with_snapshot
+
+llm_cache_path = async_fixture(make_llm_cache_with_snapshot)
+
+
+# this is copied from sculptor/conftest.py
+# (it must be copied rather than imported because of the autouse)
+@pytest.fixture(autouse=True)
+def always_explode_on_error(
+ explode_on_error: Callable[[], Generator[None, None, None]],
+) -> Generator[None, None, None]:
+ """
+ Ensures that we do not log errors or exceptions during testing.
+
+ If your test is checking error handling behavior (and you expect to see a log_exception call),
+ use the `expect_exact_logged_errors` decorator to suppress the logging of those errors.
+ """
+ yield
+
+
+def llm_config_for_test(
+ llm_cache_path: Path, snapshot: SnapshotAssertion, is_caching_inputs: bool = False
+) -> LanguageModelGenerationConfig:
+ return LanguageModelGenerationConfig(
+ model_name=AnthropicModelName.CLAUDE_4_SONNET_2025_05_14,
+ cache_path=llm_cache_path,
+ is_running_offline=not snapshot.session.update_snapshots,
+ is_caching_inputs=is_caching_inputs,
+ )
+
+
+simple_test_git_repo = pytest.fixture(make_simple_test_git_repo)
diff --git a/imbue_tools/imbue_tools/get_conversation_history/get_conversation_history.py b/imbue_tools/imbue_tools/get_conversation_history/get_conversation_history.py
@@ -0,0 +1,82 @@
+import json
+from pathlib import Path
+from typing import assert_never
+
+from loguru import logger
+from pydantic import TypeAdapter
+from pydantic import ValidationError
+
+from imbue_core.sculptor.state.chat_state import ContentBlockTypes
+from imbue_core.sculptor.state.messages import ChatInputUserMessage
+from imbue_core.sculptor.state.messages import ConversationMessageUnion
+from imbue_core.sculptor.state.messages import ResponseBlockAgentMessage
+
+CONVERSATION_FILE_ENV_VAR = "CONVERSATION_FILE"
+TASK_SOURCE_BRANCH_ENV_VAR = "TASK_SOURCE_BRANCH"
+
+
+class ConversationLoadingError(Exception):
+ pass
+
+
+# === formatting for prompt ===
+
+
+def delete_unnecessary_content_block_fields(block: ContentBlockTypes) -> str:
+ """Returns the content as a json-serialized string without the fields that we don't want to include in the prompt"""
+ fields_to_remove = {"id"}
+ return block.model_dump_json(exclude=fields_to_remove)
+
+
+def delete_unnecessary_conversation_message_fields(
+ message: ConversationMessageUnion,
+) -> str:
+ """Returns the message as a json-serialized string without the fields that we don't want to include in the prompt"""
+ general_fields_to_remove = {"message_id", "source", "approximate_creation_time"}
+ match message:
+ case ChatInputUserMessage():
+ # remove the 'files' field if it's empty
+ fields_to_remove = general_fields_to_remove | {"model_name"} | {"files"} if not message.files else set()
+ return message.model_dump_json(exclude=fields_to_remove)
+ case ResponseBlockAgentMessage():
+ fields_to_remove = general_fields_to_remove | {"assistant_message_id"}
+ return json.dumps(
+ message.model_dump(mode="json", exclude=fields_to_remove)
+ | {"content": [delete_unnecessary_content_block_fields(block) for block in message.content]}
+ )
+ case _ as unreachable:
+ assert_never(unreachable)
+
+
+def format_conversation_history_for_prompt(
+ conversation_history: tuple[ConversationMessageUnion, ...],
+) -> str:
+ formatted_messages = [delete_unnecessary_conversation_message_fields(message) for message in conversation_history]
+ return "\n".join(message for message in formatted_messages if message is not None)
+
+
+# === loading from file ===
+
+
+def load_conversation_history(
+ conversation_file_path: Path,
+) -> tuple[ConversationMessageUnion, ...]:
+ """Load a jsonl file into a list of conversation messages"""
+ file_contents = conversation_file_path.read_text()
+ return parse_conversation_history(file_contents)
+
+
+def parse_conversation_history(
+ conversation_str: str,
+) -> tuple[ConversationMessageUnion, ...]:
+ """Load a jsonl string into a list of conversation messages"""
+ messages = []
+ for line in conversation_str.strip().splitlines():
+ try:
+ # deserialize the message with pydantic
+ message: ConversationMessageUnion = TypeAdapter(ConversationMessageUnion).validate_json(line)
+ except ValidationError:
+ logger.info("Skipping malformed history line {}", line)
+ continue
+ messages.append(message)
+ return tuple(messages)
diff --git a/imbue_tools/imbue_tools/get_conversation_history/input_data_types.py b/imbue_tools/imbue_tools/get_conversation_history/input_data_types.py
@@ -0,0 +1,72 @@
+from typing import Self
+from typing import TypeVar
+
+from pydantic import model_validator
+
+from imbue_core.pydantic_serialization import SerializableModel
+from imbue_core.sculptor.state.messages import ConversationMessageUnion
+
+
+class IdentifierInputsMissingError(Exception):
+ pass
+
+
+class IdentifierInputs(SerializableModel):
+ # goal (for now, commit message) and diff to check
+ maybe_goal: str | None = None
+ maybe_diff: str | None = None
+
+ # whole files to check
+ maybe_files: tuple[str, ...] | None = None
+
+ # conversation history to check
+ maybe_conversation_history: tuple[ConversationMessageUnion, ...] | None = None
+
+
+class CommitInputs(IdentifierInputs):
+ # pyre-ignore[56]: pyre's stubs don't match pydantic v2 decorator signatures
+ @model_validator(mode="after")
+ def validate_goal_not_none(self) -> Self:
+ if self.maybe_goal is None:
+ raise IdentifierInputsMissingError("goal cannot be None for CommitInputs")
+ return self
+
+ # pyre-ignore[56]: pyre's stubs don't match pydantic v2 decorator signatures
+ @model_validator(mode="after")
+ def validate_diff_not_none(self) -> Self:
+ if self.maybe_diff is None:
+ raise IdentifierInputsMissingError("goal cannot be None for CommitInputs")
+ return self
+
+ @property
+ def goal(self) -> str:
+ assert self.maybe_goal is not None
+ return self.maybe_goal
+
+ @property
+ def diff(self) -> str:
+ assert self.maybe_diff is not None
+ return self.maybe_diff
+
+
+class ConversationInputs(IdentifierInputs):
+ # pyre-ignore[56]: pyre's stubs don't match pydantic v2 decorator signatures
+ @model_validator(mode="after")
+ def validate_conversation_history_not_none(self) -> Self:
+ if self.maybe_conversation_history is None:
+ raise IdentifierInputsMissingError("conversation_history is required for conversation inputs")
+ return self
+
+ @property
+ def conversation_history(self) -> tuple[ConversationMessageUnion, ...]:
+ assert self.maybe_conversation_history is not None
+ return self.maybe_conversation_history
+
+
+SpecificIdentifierInputsType = TypeVar("SpecificIdentifierInputsType", bound=IdentifierInputs)
+
+
+def to_specific_inputs_type(
+ identifier_inputs: IdentifierInputs, to_type: type[SpecificIdentifierInputsType]
+) -> SpecificIdentifierInputsType:
+ return to_type(**identifier_inputs.model_dump())
diff --git a/imbue_tools/imbue_tools/llm_output_parsing/parse_model_json_response.py b/imbue_tools/imbue_tools/llm_output_parsing/parse_model_json_response.py
@@ -0,0 +1,51 @@
+import json
+import re
+from typing import TypeVar
+
+from pydantic import ValidationError
+
+from imbue_core.async_monkey_patches import log_exception
+from imbue_core.pydantic_serialization import SerializableModel
+
+
+def parse_json_block_from_response_text(response_text: str) -> str:
+ """Clean markdown formatting and extra content from LLM response."""
+ response_text = response_text.strip()
+ # Parse content between first ```json and ``` block
+ json_start_line = re.search(r"^.*?```json\s*", response_text, flags=re.MULTILINE)
+ if json_start_line:
+ response_text = response_text[json_start_line.end() :]
+ json_end_line = re.search(r"```\s*$", response_text, flags=re.MULTILINE)
+ if json_end_line:
+ response_text = response_text[: json_end_line.start()]
+ return response_text.strip()
+
+
+ResponseSchema = TypeVar("ResponseSchema", bound=SerializableModel)
+
+
+class ResponseParsingError(Exception):
+ pass
+
+
+def parse_model_json_response(response_text: str, result_type: type[ResponseSchema]) -> ResponseSchema:
+ """Parse a JSON response from the LLM into a Pydantic model."""
+ cleaned_response = parse_json_block_from_response_text(response_text)
+ try:
+ return result_type.model_validate_json(cleaned_response)
+ except json.JSONDecodeError as e:
+ log_exception(
+ e,
+ "Response is not valid JSON.\nraw_response: {response_text}\ncleaned_response: {cleaned_response}",
+ response_text=response_text,
+ cleaned_response=cleaned_response,
+ )
+ raise ResponseParsingError(str(e)) from e
+ except ValidationError as e:
+ log_exception(
+ e,
+ "Response does not match the expected schema.\nraw_response: {response_text}\ncleaned_response: {cleaned_response}",
+ response_text=response_text,
+ cleaned_response=cleaned_response,
+ )
+ raise ResponseParsingError(str(e)) from e
diff --git a/imbue_tools/imbue_tools/py.typed b/imbue_tools/imbue_tools/py.typed
diff --git a/imbue_tools/imbue_tools/repo_utils/__init__.py b/imbue_tools/imbue_tools/repo_utils/__init__.py
diff --git a/imbue_tools/imbue_tools/repo_utils/context_prefix.py b/imbue_tools/imbue_tools/repo_utils/context_prefix.py
@@ -0,0 +1,613 @@
+import functools
+from enum import StrEnum
+from pathlib import Path
+from typing import Any
+from typing import Iterable
+from typing import Mapping
+from typing import assert_never
+
+from loguru import logger
+from pydantic import BaseModel
+from pydantic import ConfigDict
+
+from imbue_core.agents.configs import LanguageModelGenerationConfig
+from imbue_core.async_monkey_patches import log_exception
+from imbue_core.pydantic_serialization import SerializableModel
+from imbue_tools.repo_utils.context_utils import escape_prompt_markers
+from imbue_tools.repo_utils.context_utils import maybe_get_file_path_from_qualified_name
+from imbue_tools.repo_utils.data_types import FileContextUnion
+from imbue_tools.repo_utils.errors import ContextLengthExceededError
+from imbue_tools.repo_utils.file_system import InMemoryFileSystem
+from imbue_tools.repo_utils.python_imports import QualifiedName
+from imbue_tools.repo_utils.python_imports import STANDARD_LIBRARIES
+from imbue_tools.repo_utils.python_imports import get_global_imports
+from imbue_tools.repo_utils.subrepo_formatting import BaseFilenamePattern
+from imbue_tools.repo_utils.subrepo_formatting import ContextFormatStyle
+from imbue_tools.repo_utils.subrepo_formatting import ExactFilenamePattern
+from imbue_tools.repo_utils.subrepo_formatting import FilenamePattern
+from imbue_tools.repo_utils.subrepo_formatting import IntersectionFilenamePattern
+from imbue_tools.repo_utils.subrepo_formatting import NegatedFilenamePattern
+from imbue_tools.repo_utils.subrepo_formatting import REPO_CONTEXT_TEMPLATE
+from imbue_tools.repo_utils.subrepo_formatting import SubrepoContextMatchers
+from imbue_tools.repo_utils.subrepo_formatting import UnionFilenamePattern
+from imbue_tools.repo_utils.subrepo_formatting import compute_file_context_format_styles
+from imbue_tools.repo_utils.subrepo_formatting import format_subrepo_context
+from imbue_tools.repo_utils.subrepo_formatting import (
+ parse_subrepo_context_matchers_from_toml,
+)
+
+
+class SubrepoContext(SerializableModel):
+ repo_context_files: tuple[FileContextUnion, ...]
+ subrepo_context_strategy_label: str
+
+
+class SubrepoContextWithFormattedContext(SubrepoContext):
+ formatted_repo_context: str
+
+
+def is_qualified_name_from_stdlib(qualified_name: QualifiedName) -> bool:
+ return qualified_name.top_level_name.value in STANDARD_LIBRARIES
+
+
+def get_immediate_first_party_import_paths_for_python_file(
+ current_file_path: str, full_repo_contents_map: InMemoryFileSystem
+) -> set[str] | None:
+ file_contents = full_repo_contents_map.get_text(current_file_path)
+ if not file_contents or not current_file_path.endswith(".py"):
+ return None
+
+ try:
+ global_imports = get_global_imports(file_contents)
+ except SyntaxError as e:
+ log_exception(
+ e,
+ "Failed to parse imports for {current_file_path}",
+ current_file_path=current_file_path,
+ )
+ return None
+
+ parent_names: set[QualifiedName] = set()
+ for import_ in global_imports:
+ parent_name = import_.qualified_name.parent_name
+ parent_names.add(parent_name)
+
+ imported_file_paths = set()
+ all_file_paths = [Path(x) for x in full_repo_contents_map.text_files.keys()]
+ for parent_name in parent_names:
+ other_file_path = maybe_get_file_path_from_qualified_name(parent_name, all_file_paths)
+ # if this doesn't exist it's likely not a first party import so we can ignore it
+ if not other_file_path or is_qualified_name_from_stdlib(parent_name):
+ continue
+ imported_file_paths.add(str(other_file_path))
+
+ return imported_file_paths
+
+
+FULL_REPO_PATHSPEC = BaseFilenamePattern.from_lines(["/**"])
+DOC_FILE_EXTENSIONS = [".md", ".txt"]
+DOC_PATHSPEC = BaseFilenamePattern.from_lines([f"**/*{ext}" for ext in DOC_FILE_EXTENSIONS])
+
+# Common files that we want to exclude since they can be large and are of low signal for issue identification.
+EXCLUSIONS_PATHSPEC = BaseFilenamePattern.from_lines(["uv.lock", "**/__snapshots__/**"])
+
+
+def escape_gitignore_pattern(path: str) -> str:
+ """
+ Escape a path into a GitIgnore pattern that matches exactly the path.
+
+ GitWildMatchPattern assigns special meaning to the following characters, which need to be escaped:
+ `*`, `?`, `[`, `]` and `\\`.
+ At the beginning of a line, we additionally need to escape leading `#` and `!` characters,
+ and at the end of a line we need to escape trailing ` ` (space) characters.
+ For simplicity, we simply escape these characters everywhere, which should still work correctly.
+ """
+ return (
+ path.replace("\\", "\\\\")
+ .replace("*", "\\*")
+ .replace("?", "\\?")
+ .replace("[", "\\[")
+ .replace("]", "\\]")
+ .replace("#", "\\#")
+ .replace("!", "\\!")
+ .replace(" ", "\\ ")
+ )
+
+
+def first_level_files_along_paths(file_paths: Iterable[str]) -> FilenamePattern:
+ """
+ Create a pathspec that matches all files along the given paths, but doesn't match adjacent directories.
+ """
+ # for each level in the path, we create an IntersectionFilenamePattern
+ # which has one branch that matches everything starting with that path,
+ # and another branch which matches everything except subdirectories starting with that path
+ # then we OR these all together as a UnionFilenamePattern
+ sorted_file_paths = sorted(file_paths)
+ file_patterns = []
+ for file_path in sorted_file_paths:
+ for parent in Path(file_path).parents:
+ escaped_parent = Path(escape_gitignore_pattern(str(parent)))
+ match_all = BaseFilenamePattern.from_lines([str("/" / escaped_parent / "*")])
+ match_except_subdirectories = NegatedFilenamePattern.build_from_positive_pattern(
+ BaseFilenamePattern.from_lines([str("/" / escaped_parent / "*/*")])
+ )
+ file_patterns.append(IntersectionFilenamePattern(specs=(match_all, match_except_subdirectories)))
+ return UnionFilenamePattern(specs=tuple(file_patterns))
+
+
+# cache this since it's reused across strategies
+@functools.lru_cache(maxsize=5)
+def make_docs_pathspec_along_paths(file_paths: frozenset[str]) -> FilenamePattern:
+ """
+ Create a pathspec that matches documentation files (.md, .txt) along each parent folder of the given file paths.
+ """
+ return IntersectionFilenamePattern(specs=(DOC_PATHSPEC, first_level_files_along_paths(file_paths=file_paths)))
+
+
+INSTRUCTIONS_PATHSPEC = BaseFilenamePattern.from_lines(["**/.claude.md", "**/CLAUDE.md", "**/AGENTS.md"])
+
+
+@functools.lru_cache(maxsize=5)
+def make_relevant_files_pathspec(file_paths: frozenset[str]) -> FilenamePattern:
+ """
+ Create a pathspec that matches the given file paths.
+ """
+ return ExactFilenamePattern(filenames=tuple(sorted(file_paths)))
+
+
+# cache this since it's reused across strategies
+@functools.lru_cache(maxsize=5)
+def make_instructions_pathspec_along_paths(
+ file_paths: frozenset[str],
+) -> FilenamePattern:
+ """
+ Create a pathspec that matches instruction files (e.g. .claude.md, CLAUDE.md, AGENTS.md) along each parent folder of the given file paths.
+
+ Should match a strict subset of make_docs_pathspec_along_paths.
+ """
+ return IntersectionFilenamePattern(
+ specs=(
+ INSTRUCTIONS_PATHSPEC,
+ first_level_files_along_paths(file_paths=file_paths),
+ )
+ )
+
+
+# cache this since it's reused across strategies
+@functools.lru_cache(maxsize=5)
+def make_imports_pathspec_for_paths(
+ file_paths: frozenset[str], full_repo_contents: InMemoryFileSystem
+) -> FilenamePattern:
+ """
+ Create a pathspec that matches Python files that are imported by the given file paths.
+ """
+ full_repo_python_file_contents_map = InMemoryFileSystem.build(
+ {k: v for k, v in full_repo_contents.files.items() if k.endswith(".py")}
+ )
+
+ imported_file_paths = set()
+ for file_path in file_paths:
+ if file_path.endswith(".py"):
+ # Include first party imports
+ imported_paths = get_immediate_first_party_import_paths_for_python_file(
+ file_path, full_repo_python_file_contents_map
+ )
+ if imported_paths:
+ imported_file_paths.update(imported_paths)
+
+ return ExactFilenamePattern(filenames=tuple(sorted(imported_file_paths)))
+
+
+class SubrepoContextStrategy(BaseModel):
+ model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
+ label: str
+ matchers: SubrepoContextMatchers
+
+
+class SubrepoContextStrategyType(StrEnum):
+ # defaults if we have relevant files
+ FULL_REPO_CONTENTS = "full repo contents"
+ RELEVANT_WHOLE_FILES_IMPORTS_DOCS_AND_ELSEWHERE_FILENAME = (
+ "relevant files + immediate imports + docs along relevant paths + filenames elsewhere"
+ )
+ RELEVANT_WHOLE_FILES_IMPORTS_DOCS = "relevant files + immediate imports + docs along relevant paths"
+ RELEVANT_WHOLE_FILES_AND_RELEVANT_STUBBIFIED_IMPORTS_DOCS = (
+ "relevant files + stubbified imports + docs along relevant paths"
+ )
+ RELEVANT_WHOLE_FILES_DOCS = "relevant files + docs along relevant paths"
+ RELEVANT_WHOLE_FILES_INSTRUCTIONS = "relevant files + agent instructions along relevant paths"
+ RELEVANT_WHOLE_FILES = "relevant files"
+ RELEVANT_STUBBIFIED_FILES = "relevant stubbified files"
+ NOTHING = "nothing"
+
+ # defaults if we don't have relevant files (missing FULL_REPO_CONTENTS and NOTHING because they're already listed for if we do have relevant files)
+ WHOLE_DOCS_AND_OTHERWISE_FILENAMES = "docs + filenames elsewhere"
+ WHOLE_INSTRUCTIONS_AND_OTHERWISE_FILENAMES = "agent instructions + filenames elsewhere"
+ WHOLE_INSTRUCTIONS = "agent instructions"
+
+ # defaults for providing instruction files if we have relevant files
+ WHOLE_DOCS = "docs"
+ WHOLE_INSTRUCTIONS_AND_RELEVANT_DOCS = "agent instructions + relevant docs"
+ RELEVANT_DOCS = "relevant docs"
+ RELEVANT_INSTRUCTIONS = "relevant agent instructions"
+
+ # custom
+ CUSTOM = "custom"
+
+
+class StrategyMode(StrEnum):
+ REGULAR = "regular"
+ DOCS = "docs"
+
+
+class AvailableInfoMode(StrEnum):
+ YES_FILES = "yes_files"
+ NO_FILES = "no_files"
+
+
+DEFAULT_STRATEGY_TYPES: dict[tuple[StrategyMode, AvailableInfoMode], tuple[SubrepoContextStrategyType, ...]] = {
+ (StrategyMode.REGULAR, AvailableInfoMode.YES_FILES): (
+ SubrepoContextStrategyType.FULL_REPO_CONTENTS,
+ SubrepoContextStrategyType.RELEVANT_WHOLE_FILES_IMPORTS_DOCS_AND_ELSEWHERE_FILENAME,
+ SubrepoContextStrategyType.RELEVANT_WHOLE_FILES_IMPORTS_DOCS,
+ SubrepoContextStrategyType.RELEVANT_WHOLE_FILES_AND_RELEVANT_STUBBIFIED_IMPORTS_DOCS,
+ SubrepoContextStrategyType.RELEVANT_WHOLE_FILES_DOCS,
+ SubrepoContextStrategyType.RELEVANT_WHOLE_FILES_INSTRUCTIONS,
+ SubrepoContextStrategyType.RELEVANT_WHOLE_FILES,
+ SubrepoContextStrategyType.RELEVANT_STUBBIFIED_FILES,
+ SubrepoContextStrategyType.NOTHING,
+ ),
+ (StrategyMode.REGULAR, AvailableInfoMode.NO_FILES): (
+ SubrepoContextStrategyType.FULL_REPO_CONTENTS,
+ SubrepoContextStrategyType.WHOLE_DOCS_AND_OTHERWISE_FILENAMES,
+ SubrepoContextStrategyType.WHOLE_INSTRUCTIONS_AND_OTHERWISE_FILENAMES,
+ SubrepoContextStrategyType.WHOLE_INSTRUCTIONS,
+ SubrepoContextStrategyType.NOTHING,
+ ),
+ (StrategyMode.DOCS, AvailableInfoMode.YES_FILES): (
+ SubrepoContextStrategyType.WHOLE_DOCS,
+ SubrepoContextStrategyType.WHOLE_INSTRUCTIONS_AND_RELEVANT_DOCS,
+ SubrepoContextStrategyType.RELEVANT_DOCS,
+ SubrepoContextStrategyType.RELEVANT_INSTRUCTIONS,
+ # these don't have `nothing` strategies because having some user instructions is crucial for
+ # the issue identifier which uses this, whereas the others can do ok with just a diff
+ ),
+ (StrategyMode.DOCS, AvailableInfoMode.NO_FILES): (
+ SubrepoContextStrategyType.WHOLE_DOCS,
+ SubrepoContextStrategyType.WHOLE_INSTRUCTIONS,
+ ),
+}
+
+
+def build_strategy(
+ strategy_type: SubrepoContextStrategyType,
+ full_repo_contents: InMemoryFileSystem,
+ relevant_file_paths: frozenset[str] | None,
+) -> SubrepoContextStrategy:
+ match strategy_type:
+ case SubrepoContextStrategyType.FULL_REPO_CONTENTS as s:
+ return SubrepoContextStrategy(
+ label=s,
+ matchers=((ContextFormatStyle.FULL_FILE, FULL_REPO_PATHSPEC),),
+ )
+ case SubrepoContextStrategyType.RELEVANT_WHOLE_FILES_IMPORTS_DOCS_AND_ELSEWHERE_FILENAME as s:
+ return SubrepoContextStrategy(
+ label=s,
+ matchers=(
+ (
+ ContextFormatStyle.FULL_FILE,
+ make_relevant_files_pathspec(relevant_file_paths),
+ ),
+ (
+ ContextFormatStyle.FULL_FILE,
+ make_imports_pathspec_for_paths(relevant_file_paths, full_repo_contents),
+ ),
+ (
+ ContextFormatStyle.FULL_FILE,
+ make_docs_pathspec_along_paths(relevant_file_paths),
+ ),
+ (ContextFormatStyle.FILENAME_ONLY, FULL_REPO_PATHSPEC),
+ ),
+ )
+ case SubrepoContextStrategyType.RELEVANT_WHOLE_FILES_IMPORTS_DOCS as s:
+ return SubrepoContextStrategy(
+ label=s,
+ matchers=(
+ (
+ ContextFormatStyle.FULL_FILE,
+ make_relevant_files_pathspec(relevant_file_paths),
+ ),
+ (
+ ContextFormatStyle.FULL_FILE,
+ make_imports_pathspec_for_paths(relevant_file_paths, full_repo_contents),
+ ),
+ (
+ ContextFormatStyle.FULL_FILE,
+ make_docs_pathspec_along_paths(relevant_file_paths),
+ ),
+ (ContextFormatStyle.HIDDEN, FULL_REPO_PATHSPEC),
+ ),
+ )
+ case SubrepoContextStrategyType.RELEVANT_WHOLE_FILES_AND_RELEVANT_STUBBIFIED_IMPORTS_DOCS as s:
+ return SubrepoContextStrategy(
+ label=s,
+ matchers=(
+ (
+ ContextFormatStyle.FULL_FILE,
+ make_relevant_files_pathspec(relevant_file_paths),
+ ),
+ (
+ ContextFormatStyle.STUB,
+ make_imports_pathspec_for_paths(relevant_file_paths, full_repo_contents),
+ ),
+ (
+ ContextFormatStyle.FULL_FILE,
+ make_docs_pathspec_along_paths(relevant_file_paths),
+ ),
+ (ContextFormatStyle.HIDDEN, FULL_REPO_PATHSPEC),
+ ),
+ )
+ case SubrepoContextStrategyType.RELEVANT_WHOLE_FILES_DOCS as s:
+ return SubrepoContextStrategy(
+ label=s,
+ matchers=(
+ (
+ ContextFormatStyle.FULL_FILE,
+ make_relevant_files_pathspec(relevant_file_paths),
+ ),
+ (
+ ContextFormatStyle.FULL_FILE,
+ make_docs_pathspec_along_paths(relevant_file_paths),
+ ),
+ (ContextFormatStyle.HIDDEN, FULL_REPO_PATHSPEC),
+ ),
+ )
+ case SubrepoContextStrategyType.RELEVANT_WHOLE_FILES_INSTRUCTIONS as s:
+ return SubrepoContextStrategy(
+ label=s,
+ matchers=(
+ (
+ ContextFormatStyle.FULL_FILE,
+ make_relevant_files_pathspec(relevant_file_paths),
+ ),
+ (
+ ContextFormatStyle.FULL_FILE,
+ make_instructions_pathspec_along_paths(relevant_file_paths),
+ ),
+ (ContextFormatStyle.HIDDEN, FULL_REPO_PATHSPEC),
+ ),
+ )
+ case SubrepoContextStrategyType.RELEVANT_WHOLE_FILES as s:
+ return SubrepoContextStrategy(
+ label=s,
+ matchers=(
+ (
+ ContextFormatStyle.FULL_FILE,
+ make_relevant_files_pathspec(relevant_file_paths),
+ ),
+ (ContextFormatStyle.HIDDEN, FULL_REPO_PATHSPEC),
+ ),
+ )
+ case SubrepoContextStrategyType.RELEVANT_STUBBIFIED_FILES as s:
+ return SubrepoContextStrategy(
+ label=s,
+ matchers=(
+ (
+ ContextFormatStyle.STUB,
+ make_relevant_files_pathspec(relevant_file_paths),
+ ),
+ (ContextFormatStyle.HIDDEN, FULL_REPO_PATHSPEC),
+ ),
+ )
+ case SubrepoContextStrategyType.NOTHING as s:
+ return SubrepoContextStrategy(
+ label=s,
+ matchers=((ContextFormatStyle.HIDDEN, FULL_REPO_PATHSPEC),),
+ )
+
+ case SubrepoContextStrategyType.WHOLE_DOCS_AND_OTHERWISE_FILENAMES as s:
+ return SubrepoContextStrategy(
+ label=s,
+ matchers=(
+ (ContextFormatStyle.FULL_FILE, DOC_PATHSPEC),
+ (ContextFormatStyle.FILENAME_ONLY, FULL_REPO_PATHSPEC),
+ ),
+ )
+ case SubrepoContextStrategyType.WHOLE_INSTRUCTIONS_AND_OTHERWISE_FILENAMES as s:
+ return SubrepoContextStrategy(
+ label=s,
+ matchers=(
+ (ContextFormatStyle.FULL_FILE, INSTRUCTIONS_PATHSPEC),
+ (ContextFormatStyle.FILENAME_ONLY, FULL_REPO_PATHSPEC),
+ ),
+ )
+ case SubrepoContextStrategyType.WHOLE_INSTRUCTIONS as s:
+ return SubrepoContextStrategy(
+ label=s,
+ matchers=(
+ (ContextFormatStyle.FULL_FILE, INSTRUCTIONS_PATHSPEC),
+ (ContextFormatStyle.HIDDEN, FULL_REPO_PATHSPEC),
+ ),
+ )
+
+ case SubrepoContextStrategyType.WHOLE_DOCS as s:
+ return SubrepoContextStrategy(
+ label=s,
+ matchers=(
+ (ContextFormatStyle.FULL_FILE, DOC_PATHSPEC),
+ (ContextFormatStyle.HIDDEN, FULL_REPO_PATHSPEC),
+ ),
+ )
+ case SubrepoContextStrategyType.WHOLE_INSTRUCTIONS_AND_RELEVANT_DOCS as s:
+ return SubrepoContextStrategy(
+ label=s,
+ matchers=(
+ (ContextFormatStyle.FULL_FILE, INSTRUCTIONS_PATHSPEC),
+ (
+ ContextFormatStyle.FULL_FILE,
+ make_docs_pathspec_along_paths(relevant_file_paths),
+ ),
+ (ContextFormatStyle.HIDDEN, FULL_REPO_PATHSPEC),
+ ),
+ )
+ case SubrepoContextStrategyType.RELEVANT_DOCS as s:
+ return SubrepoContextStrategy(
+ label=s,
+ matchers=(
+ (
+ ContextFormatStyle.FULL_FILE,
+ make_docs_pathspec_along_paths(relevant_file_paths),
+ ),
+ (ContextFormatStyle.HIDDEN, FULL_REPO_PATHSPEC),
+ ),
+ )
+ case SubrepoContextStrategyType.RELEVANT_INSTRUCTIONS as s:
+ return SubrepoContextStrategy(
+ label=s,
+ matchers=(
+ (
+ ContextFormatStyle.FULL_FILE,
+ make_instructions_pathspec_along_paths(relevant_file_paths),
+ ),
+ (ContextFormatStyle.HIDDEN, FULL_REPO_PATHSPEC),
+ ),
+ )
+ case _ as unreachable:
+ assert_never(unreachable) # pyre-ignore[6]: pyre doesn't understand enums
+
+
+def generate_subrepo_strategies(
+ mode: StrategyMode,
+ full_repo_contents: InMemoryFileSystem,
+ relevant_file_paths: frozenset[str] | None = None,
+) -> list[SubrepoContextStrategy]:
+ available_info = AvailableInfoMode.YES_FILES if relevant_file_paths else AvailableInfoMode.NO_FILES
+ return [
+ build_strategy(strategy_type, full_repo_contents, relevant_file_paths)
+ for strategy_type in DEFAULT_STRATEGY_TYPES[(mode, available_info)]
+ ]
+
+
+def select_desired_subrepo_strategies(
+ full_repo_contents: InMemoryFileSystem,
+ relevant_file_paths: frozenset[str] | None = None,
+ subrepo_context_config: str | None = None,
+ strategy_types_to_try: tuple[SubrepoContextStrategyType] | None = None,
+ strategy_mode: StrategyMode | None = None, # if no config option is set, defaults to StrategyMode.REGULAR
+) -> list[SubrepoContextStrategy]:
+ num_ways_config_was_set = sum(
+ 1 for v in [subrepo_context_config, strategy_types_to_try, strategy_mode] if v is not None
+ )
+ if num_ways_config_was_set > 1:
+ assert False, "Can only specify one of subrepo_context_config, strategy_types_to_try, and strategy_mode"
+
+ if subrepo_context_config is not None:
+ # An explicit subrepo context config was provided. Use it exclusively.
+ subrepo_context_matchers = parse_subrepo_context_matchers_from_toml(subrepo_context_config)
+ return [
+ SubrepoContextStrategy(
+ label=SubrepoContextStrategyType.CUSTOM,
+ matchers=subrepo_context_matchers,
+ )
+ ]
+ elif strategy_types_to_try is not None:
+ return [
+ build_strategy(strategy_type, full_repo_contents, relevant_file_paths)
+ for strategy_type in strategy_types_to_try
+ ]
+ else:
+ strategy_mode_to_use = strategy_mode if strategy_mode is not None else StrategyMode.REGULAR
+ return generate_subrepo_strategies(
+ strategy_mode_to_use,
+ full_repo_contents=full_repo_contents,
+ relevant_file_paths=relevant_file_paths,
+ )
+
+
+# Caching results because this function is quite expensive. We compose multiple repo_context prefixes, and
+# also have to tokenize them to check their respective lengths. Both of these operations are expensive,
+@functools.lru_cache(maxsize=10)
+def get_repo_context(
+ model_config: LanguageModelGenerationConfig,
+ full_repo_contents: InMemoryFileSystem,
+ # how many tokens to reserve for additional prompt messages and output
+ tokens_to_reserve: int,
+ relevant_file_paths: frozenset[str] | None = None,
+ subrepo_context_config: str | None = None,
+ strategy_types_to_try: tuple[SubrepoContextStrategyType] | None = None,
+ strategy_mode: StrategyMode | None = None, # if no config option is set, defaults to StrategyMode.REGULAR
+ template: str = REPO_CONTEXT_TEMPLATE,
+) -> SubrepoContextWithFormattedContext:
+ """
+ Make sure to try pass the same `full_repo_contents` when making multiple similar calls.
+ Ordering of the dict is relevant for caching.
+ """
+ subrepo_context_strategies_to_try = select_desired_subrepo_strategies(
+ full_repo_contents,
+ relevant_file_paths,
+ subrepo_context_config,
+ strategy_types_to_try,
+ strategy_mode,
+ )
+
+ last_context_length_exceeded_error: ContextLengthExceededError | None = None
+ for subrepo_context_strategy in subrepo_context_strategies_to_try:
+ try:
+ path_to_format_style = compute_file_context_format_styles(
+ file_paths=full_repo_contents.text_files.keys(),
+ subrepo_context_matchers=subrepo_context_strategy.matchers,
+ exclusions=EXCLUSIONS_PATHSPEC,
+ )
+ repo_context_str, repo_context_files = format_subrepo_context(
+ full_repo_contents=full_repo_contents.text_files,
+ model_config=model_config,
+ path_to_format_style=path_to_format_style,
+ tokens_to_reserve=tokens_to_reserve,
+ template=template,
+ )
+ logger.info("Selected subrepo context strategy: {}", subrepo_context_strategy.label)
+
+ if subrepo_context_strategy.label == SubrepoContextStrategyType.NOTHING:
+ # log an error if we have to use the NOTHING strategy, but still proceed with the call
+ logger.error("Selected NOTHING subrepo context strategy; hopefully this doesn't happen too often!")
+
+ return SubrepoContextWithFormattedContext(
+ formatted_repo_context=repo_context_str,
+ repo_context_files=repo_context_files,
+ subrepo_context_strategy_label=subrepo_context_strategy.label,
+ )
+ except ContextLengthExceededError as e:
+ last_context_length_exceeded_error = e
+
+ # We have exhausted all subrepo context strategies, and none of them worked.
+ assert last_context_length_exceeded_error is not None
+ raise last_context_length_exceeded_error from last_context_length_exceeded_error
+
+
+# TODO: why not just render this here?
+def create_context_prompt_prefix(repo_context: str) -> tuple[str, Mapping[str, Any]]:
+ """Create a message that provides context about the repo contents."""
+ cached_prefix_template = """[ROLE=SYSTEM_CACHED]
+You are a detail-oriented, expert software developer.
+
+Your goal is to help the user develop a particular commit to make a change to their program.
+
+{{repo_context}}
+
+{% if recent_git_history -%}
+
+As additional context, here are some of the most recent changes made to the codebase (the output of `git log` and diffs for each of those commits):
+
+```
+{{recent_git_history}}
+```
+{% endif -%}
+"""
+
+ return (
+ cached_prefix_template,
+ dict(
+ repo_context=escape_prompt_markers(repo_context),
+ recent_git_history=None,
+ ),
+ )
diff --git a/imbue_tools/imbue_tools/repo_utils/context_retrieval.py b/imbue_tools/imbue_tools/repo_utils/context_retrieval.py
@@ -0,0 +1,136 @@
+import asyncio
+import threading
+import time
+from contextlib import asynccontextmanager
+from pathlib import Path
+from typing import AsyncGenerator
+from typing import Generator
+
+import pygit2
+from loguru import logger
+from pygit2.enums import ObjectType
+from pygit2.repository import Repository
+
+from imbue_core.async_utils import make_async
+from imbue_core.git import LocalGitRepo
+from imbue_tools.repo_utils.diff_utils import apply_diffs_to_files
+from imbue_tools.repo_utils.file_system import FileContents
+from imbue_tools.repo_utils.file_system import InMemoryFileSystem
+from imbue_tools.repo_utils.file_system import SymlinkContents
+
+
+class RepoContextManagerError(Exception):
+ pass
+
+
+class RepoContextManager:
+ """A manager for handling retrieval of files, etc from the repo."""
+
+ def __init__(self, repo_path: Path, project_name: str) -> None:
+ self.project_name = project_name
+ self.repo_path = repo_path
+ self._repo = Repository(path=str(repo_path))
+
+ # We need the sync lock due to pygit2 being synchronous.
+ # It is mostly used for the blob data cache, but also for the repo contents by git hash cache.
+ self._lock = threading.Lock()
+ # We need the async lock for tests TODO: we can probably remove this
+ self._local_repo_async_lock: asyncio.Lock = asyncio.Lock()
+
+ @classmethod
+ def build(cls, repo_path: Path) -> "RepoContextManager":
+ try:
+ # make sure we are in a git repo
+ Repository(path=str(repo_path))
+ except pygit2.GitError as e:
+ raise RepoContextManagerError(f"Failed to initialize git repo at {repo_path}") from e
+
+ repo_context_manager = cls(repo_path=repo_path, project_name=repo_path.name)
+ return repo_context_manager
+
+ async def get_full_repo_contents_at_repo_state(self, git_hash: str, diff: str) -> InMemoryFileSystem:
+ final_contents = await self.get_full_repo_contents_at_commit(git_hash)
+ final_contents = await apply_diffs_to_files(final_contents, (diff,))
+ return final_contents
+
+ def get_full_repo_contents_at_commit_sync(self, git_hash: str) -> InMemoryFileSystem:
+ # NOTE: most of the time we want to get the contents at a repo state, not a git hash.
+ # Call get_full_repo_contents_at_repo_state instead in that case.
+ with self._lock:
+ start_time = time.perf_counter()
+
+ # Assert against use of HEAD specifically because there could be some existing code
+ # that uses it, and we want to catch that. It would fail below as well with a KeyError,
+ # but this assert makes the exception message more explicit.
+ assert git_hash != "HEAD", "Only proper commit hashes are supported, not HEAD"
+ commit = self._repo[git_hash]
+ assert isinstance(commit, pygit2.Commit), f"Expected a pygit2.Commit, got {type(commit)}"
+
+ full_repo_contents = self._read_blobs_from_commit(commit)
+
+ end_time = time.perf_counter()
+ logger.debug(
+ "Loaded full repo contents for git hash {git_hash} in {duration:.2f} seconds",
+ git_hash=git_hash,
+ duration=end_time - start_time,
+ )
+ return full_repo_contents
+
+ @make_async
+ def get_full_repo_contents_at_commit(self, git_hash: str) -> InMemoryFileSystem:
+ return self.get_full_repo_contents_at_commit_sync(git_hash)
+
+ def _read_blobs_from_commit(self, commit: pygit2.Commit) -> InMemoryFileSystem:
+ """Read all blobs in a given commit."""
+ file_system_dict: dict[str, FileContents] = {}
+
+ for path, blob in self._list_blobs_from_tree(commit.tree, skip_binary=False, skip_symlinks=False):
+ if blob.filemode == 0o120000:
+ # Blob is a symbolic link. Its contents in git represent the target path.
+ file_system_dict[path] = SymlinkContents(target_path=blob.data.decode("utf-8"))
+ else:
+ file_system_dict[path] = blob.data
+ return InMemoryFileSystem.build(file_system_dict)
+
+ def _list_blobs_from_tree(
+ self, tree: pygit2.Tree, skip_binary: bool, skip_symlinks: bool
+ ) -> Generator[tuple[str, pygit2.Blob], None, None]:
+ """Recursively list all blobs in a tree, including its subtrees."""
+ assert self._lock.locked()
+ for entry in tree:
+ if entry.type == ObjectType.BLOB:
+ assert isinstance(entry, pygit2.Blob)
+ if skip_binary and entry.is_binary:
+ continue
+ if skip_symlinks and entry.filemode == 0o120000:
+ continue
+
+ blob_path = entry.name
+ assert blob_path is not None
+ yield blob_path, entry
+
+ elif entry.type == ObjectType.TREE:
+ assert isinstance(entry, pygit2.Tree)
+ # Recurse into a subtree (folder)
+ sub_tree = self._repo[entry.id]
+ assert isinstance(sub_tree, pygit2.Tree)
+ for sub_path, sub_blob in self._list_blobs_from_tree(
+ sub_tree, skip_binary=skip_binary, skip_symlinks=skip_symlinks
+ ):
+ yield f"{entry.name}/{sub_path}", sub_blob
+
+ elif entry.type == ObjectType.COMMIT:
+ # A COMMIT object indicates a submodule, which we do not traverse for the time being.
+ logger.info("Skipping submodule in repo context: {}", entry.name)
+
+ else:
+ raise ValueError(f"Unexpected entry type in git tree: {entry.type}")
+
+ @asynccontextmanager
+ async def tmp_repo_context(self) -> AsyncGenerator[LocalGitRepo, None]:
+ """
+ This function is only used in tests
+ TODO: we can probably remove it
+ """
+ async with self._local_repo_async_lock:
+ yield LocalGitRepo(self.repo_path)
diff --git a/imbue_tools/imbue_tools/repo_utils/context_utils.py b/imbue_tools/imbue_tools/repo_utils/context_utils.py
@@ -0,0 +1,52 @@
+from pathlib import Path
+from typing import Iterable
+
+from imbue_tools.repo_utils.python_imports import QualifiedName
+
+
+def escape_prompt_markers(text: str) -> str:
+ markers = [
+ "[ROLE=ASSISTANT]",
+ "[ROLE=USER]",
+ "[ROLE=USER_CACHED]",
+ "[ROLE=SYSTEM]",
+ "[ROLE=SYSTEM_CACHED]",
+ "[ROLE=HUMAN]",
+ ]
+ for marker in markers:
+ text = text.replace(marker, f"[{marker}]")
+ return text
+
+
+def escape_all_jinja_variables(text: str) -> str:
+ return "{% raw %}" + text + "{% endraw %}"
+
+
+def does_relative_path_match_target_path_suffix(target_path: Path, relative_file_path: Path) -> bool:
+ """
+ Checks if the parts of a relative path match the suffix of a target path.
+ """
+ possible_parts = relative_file_path.parts
+ target_parts = target_path.parts
+
+ if len(possible_parts) > len(target_parts):
+ return False
+
+ for i in range(1, len(possible_parts) + 1):
+ if possible_parts[-i] != target_parts[-i]:
+ return False
+ return True
+
+
+def maybe_get_file_path_from_qualified_name(
+ qualified_name: QualifiedName, all_file_paths: Iterable[Path]
+) -> Path | None:
+ """
+ Tries to find the file path that corresponds to qualified name. This requires the qualified name to be a file in the repo.
+ """
+ possible_relative_file_path = qualified_name.to_path()
+ # NOTE: it's possible to make this faster by doing some upfront computation
+ for target_file_path in all_file_paths:
+ if does_relative_path_match_target_path_suffix(target_file_path, possible_relative_file_path):
+ return target_file_path
+ return None
diff --git a/imbue_tools/imbue_tools/repo_utils/data_types.py b/imbue_tools/imbue_tools/repo_utils/data_types.py
@@ -0,0 +1,51 @@
+from abc import ABC
+from abc import abstractmethod
+from typing import Annotated
+
+from pydantic import Tag
+
+from imbue_core.pydantic_serialization import SerializableModel
+from imbue_core.pydantic_serialization import build_discriminator
+
+
+class FileContext(ABC, SerializableModel):
+ object_type: str
+ path: str
+
+ @abstractmethod
+ def format_for_agent(self) -> str:
+ pass
+
+
+class FullFileContext(FileContext):
+ object_type: str = "FullFileContext"
+ path: str
+ contents: str = "RAW FILE CONTENTS"
+
+ def format_for_agent(self) -> str:
+ return f"<FILE>\n<PATH>\n{self.path}\n</PATH>\n<CONTENTS>\n{self.contents}\n</CONTENTS>\n</FILE>\n\n"
+
+
+class FilenameContext(FileContext):
+ object_type: str = "FilenameContext"
+ path: str
+
+ def format_for_agent(self) -> str:
+ return f"<FILE>\n<PATH>\n{self.path}\n</PATH>\n</FILE>\n\n"
+
+
+class StubFileContext(FileContext):
+ object_type: str = "StubFileContext"
+ path: str
+ stub: str
+
+ def format_for_agent(self) -> str:
+ return f"<FILE>\n<PATH>\n{self.path}\n</PATH>\n<STUBIFIED_CONTENTS>\n{self.stub}\n</STUBIFIED_CONTENTS>\n</FILE>\n\n"
+
+
+FileContextUnion = Annotated[
+ Annotated[FullFileContext, Tag("FullFileContext")]
+ | Annotated[FilenameContext, Tag("FilenameContext")]
+ | Annotated[StubFileContext, Tag("StubFileContext")],
+ build_discriminator(),
+]
diff --git a/imbue_tools/imbue_tools/repo_utils/diff_utils.py b/imbue_tools/imbue_tools/repo_utils/diff_utils.py
@@ -0,0 +1,156 @@
+import re
+import subprocess
+import tempfile
+from pathlib import Path
+
+import pygit2
+from async_lru import alru_cache # type: ignore[undefined-attribute]: pyre on modal has an issue with this
+from loguru import logger
+
+from imbue_tools.repo_utils.errors import DiffApplicationError
+from imbue_tools.repo_utils.errors import DiffCalculationError
+from imbue_tools.repo_utils.file_system import FileContents
+from imbue_tools.repo_utils.file_system import InMemoryFileSystem
+from imbue_tools.repo_utils.file_system import SymlinkContents
+from imbue_tools.repo_utils.file_system_utils import (
+ create_initial_placeholder_commit_for_dir,
+)
+from imbue_tools.repo_utils.file_system_utils import (
+ temporary_local_dir_from_in_memory_file_system,
+)
+from imbue_tools.repo_utils.file_system_utils import write_file_contents_to_dir
+
+
+class NonZeroReturncodeError(Exception):
+ pass
+
+
+async def get_diff_between_files(old_file_contents: InMemoryFileSystem, new_file_contents: InMemoryFileSystem) -> str:
+ with (
+ tempfile.TemporaryDirectory() as old_repo_dir,
+ tempfile.TemporaryDirectory() as new_repo_dir,
+ ):
+ # Get all changed file contents to prevent writing more than necessary
+ changed_old_file_contents_dict = {}
+ changed_new_file_contents_dict = {}
+ old_file_contents_dict = old_file_contents.files
+ new_file_contents_dict = new_file_contents.files
+ for file_path in old_file_contents_dict.keys() | new_file_contents_dict.keys():
+ if file_path not in old_file_contents_dict:
+ changed_new_file_contents_dict[file_path] = new_file_contents_dict[file_path]
+ elif file_path not in new_file_contents_dict:
+ changed_old_file_contents_dict[file_path] = old_file_contents_dict[file_path]
+ elif old_file_contents_dict[file_path] != new_file_contents_dict[file_path]:
+ changed_old_file_contents_dict[file_path] = old_file_contents_dict[file_path]
+ changed_new_file_contents_dict[file_path] = new_file_contents_dict[file_path]
+
+ changed_old_file_contents = InMemoryFileSystem.build(changed_old_file_contents_dict)
+ changed_new_file_contents = InMemoryFileSystem.build(changed_new_file_contents_dict)
+
+ await write_file_contents_to_dir(changed_old_file_contents, old_repo_dir)
+ await write_file_contents_to_dir(changed_new_file_contents, new_repo_dir)
+
+ try:
+ result = subprocess.run(
+ (
+ "git",
+ "diff",
+ "--no-index",
+ "--relative",
+ "--full-index",
+ "--binary",
+ old_repo_dir,
+ new_repo_dir,
+ ),
+ capture_output=True,
+ text=True,
+ timeout=10.0,
+ )
+ if result.returncode == 0 or result.returncode == 1:
+ diff = result.stdout
+ else:
+ raise NonZeroReturncodeError(f"git diff process returned with non-zero returncode {result.returncode}")
+ except Exception as e:
+ raise DiffCalculationError from e
+
+ diff = diff.replace(old_repo_dir, "")
+ diff = diff.replace(new_repo_dir, "")
+
+ return diff
+
+
+@alru_cache
+async def apply_diffs_to_files(file_contents: InMemoryFileSystem, diff_strings: tuple[str, ...]) -> InMemoryFileSystem:
+ # Have to do this wrapping and unwrapping into dicts to allow @alru_cache to work
+ files_with_diffs = file_contents
+ for diff_string in diff_strings:
+ files_with_diffs = await _apply_diff_to_files(file_contents=files_with_diffs, diff_string=diff_string)
+ return files_with_diffs
+
+
+async def _apply_diff_to_files(file_contents: InMemoryFileSystem, diff_string: str) -> InMemoryFileSystem:
+ if diff_string.strip() == "":
+ return file_contents
+
+ file_pattern = re.compile(r"^diff --git a/(.+?) b/(.+)$", re.MULTILINE)
+ matches = file_pattern.findall(diff_string)
+
+ relevant_file_contents_dict = {}
+ for match in matches:
+ assert len(match) == 2
+ for file_path in match:
+ contents = file_contents.get(file_path, None)
+ if contents is not None:
+ relevant_file_contents_dict[file_path] = contents
+
+ async with temporary_local_dir_from_in_memory_file_system(
+ InMemoryFileSystem.build(relevant_file_contents_dict)
+ ) as temp_repo_dir:
+ repo = pygit2.init_repository(temp_repo_dir, bare=False)
+ create_initial_placeholder_commit_for_dir(repo)
+
+ with tempfile.NamedTemporaryFile(delete=False) as temp_patch_file:
+ temp_patch_file.write(diff_string.encode("utf-8"))
+ temp_patch_file.flush()
+ patch_file_path = temp_patch_file.name
+
+ try:
+ result = subprocess.run(
+ ("git", "apply", "--verbose", patch_file_path),
+ cwd=temp_repo_dir,
+ capture_output=True,
+ text=True,
+ timeout=10.0,
+ check=True,
+ )
+ except Exception as e:
+ logger.trace("Unable to apply patch: {error}", error=e)
+ raise DiffApplicationError from e
+
+ try:
+ updated_file_contents = _read_file_contents_from_dir_without_git(temp_repo_dir)
+ except Exception as e:
+ raise DiffApplicationError from e
+
+ combined_file_contents_dict = dict(updated_file_contents.files)
+ for file_path, contents in file_contents.files.items():
+ if file_path not in relevant_file_contents_dict:
+ combined_file_contents_dict[file_path] = contents
+
+ return InMemoryFileSystem.build(combined_file_contents_dict)
+
+
+def _read_file_contents_from_dir_without_git(dir_path_str: str) -> InMemoryFileSystem:
+ file_system_dict: dict[str, FileContents] = {}
+ for file_path in Path(dir_path_str).rglob("*"):
+ if ".git" in file_path.parts:
+ continue
+ if file_path.is_symlink():
+ relative_path = str(file_path.relative_to(dir_path_str))
+ target_path = str(file_path.readlink())
+ file_system_dict[relative_path] = SymlinkContents(target_path=target_path)
+ elif file_path.is_file():
+ relative_path = str(file_path.relative_to(dir_path_str))
+ with open(file_path, "rb") as file:
+ file_system_dict[relative_path] = file.read()
+ return InMemoryFileSystem.build(file_system_dict)
diff --git a/imbue_tools/imbue_tools/repo_utils/errors.py b/imbue_tools/imbue_tools/repo_utils/errors.py
@@ -0,0 +1,25 @@
+from imbue_core.errors import ExpectedError
+
+
+class PromptAssemblyError(Exception):
+ """Raised when there is an error assembling the prompt."""
+
+
+class ContextLengthExceededError(PromptAssemblyError):
+ """Raised when the context length exceeds the maximum allowed length."""
+
+
+class InvalidVersionedConfigError(ExpectedError):
+ pass
+
+
+class MissingVersionedConfigError(ExpectedError):
+ pass
+
+
+class DiffApplicationError(Exception):
+ pass
+
+
+class DiffCalculationError(Exception):
+ pass
diff --git a/imbue_tools/imbue_tools/repo_utils/file_system.py b/imbue_tools/imbue_tools/repo_utils/file_system.py
@@ -0,0 +1,102 @@
+from typing import Generator
+from typing import Mapping
+
+from imbue_core.frozen_utils import FrozenDict
+from imbue_core.frozen_utils import deep_freeze_mapping
+from imbue_core.pydantic_serialization import SerializableModel
+from imbue_tools.repo_utils.subrepo_formatting import BaseFilenamePattern
+
+
+class SymlinkContents(SerializableModel):
+ """A special type to represent a symbolic link in a file system."""
+
+ target_path: str
+
+ # Need to make SymlinkContents non-Iterable, or else deep_freeze_mapping will convert this to a tuple in InMemoryFileSystem.build.
+ __iter__ = None # type: ignore
+
+
+DecodedTextFileContents = str
+FileContents = bytes | SymlinkContents
+
+
+class InMemoryFileSystem(SerializableModel):
+ """
+ A simple representation of in-memory file system. Can contain both text and binary files.
+ """
+
+ # Mapping from file path to contents
+ files: FrozenDict[str, FileContents]
+ # Only text files, decoded as UTF-8. Excludes symlinks and binary files.
+ text_files: FrozenDict[str, DecodedTextFileContents]
+
+ @classmethod
+ def build(cls, files: Mapping[str, FileContents]) -> "InMemoryFileSystem":
+ sorted_files = {k: v for k, v in sorted(files.items())}
+ sorted_decoded_files: dict[str, DecodedTextFileContents | None] = {
+ k: _try_decode_file_contents(c) for k, c in sorted_files.items()
+ }
+ sorted_text_files: dict[str, DecodedTextFileContents] = {
+ k: c for k, c in sorted_decoded_files.items() if c is not None
+ }
+ return cls(
+ files=deep_freeze_mapping(sorted_files),
+ text_files=deep_freeze_mapping(sorted_text_files),
+ )
+
+ def get(self, file_path: str, default: FileContents | None = None) -> FileContents | None:
+ if file_path in self.files:
+ return self.files[file_path]
+ return default
+
+ def get_text(
+ self, file_path: str, default: DecodedTextFileContents | None = None
+ ) -> DecodedTextFileContents | None:
+ """Get a the contents of a text file as a string. Returns `default` if the file does not exist, is a symlink, or is a binary file."""
+ if file_path in self.text_files:
+ return self.text_files[file_path]
+ return default
+
+ def __iter__(self) -> Generator[tuple[str, FileContents], None, None]:
+ return (file for file in self.files.items())
+
+
+def _try_decode_file_contents(contents: FileContents) -> DecodedTextFileContents | None:
+ if isinstance(contents, SymlinkContents):
+ return None
+ else:
+ assert isinstance(contents, bytes)
+ try:
+ return contents.decode("utf-8")
+ except UnicodeDecodeError:
+ return None
+
+
+def filter_files_patterns(
+ file_system: InMemoryFileSystem,
+ include_patterns: tuple[str, ...] | None = None,
+ exclude_patterns: tuple[str, ...] | None = None,
+) -> InMemoryFileSystem:
+ """Filter all files based on include/exclude patterns.
+ If an include pattern is provided, only files that match the include pattern will be included.
+ If an exclude pattern is provided, files that match the exclude pattern will be excluded. If no include or exclude patterns are provided, all files will be included.
+
+ Args:
+ file_system: The file system to filter
+ include_patterns: Glob patterns for files to include
+ exclude_patterns: Glob patterns for files to exclude
+
+ Returns:
+ Filtered InMemoryFileSystem
+ """
+ include_spec = BaseFilenamePattern.from_lines(include_patterns or ())
+ exclude_spec = BaseFilenamePattern.from_lines(exclude_patterns or ())
+ filtered_files = {
+ file_path: content
+ for file_path, content in file_system.files.items()
+ if (
+ (not include_patterns or include_spec.match_file(file_path))
+ and (not exclude_patterns or not exclude_spec.match_file(file_path))
+ )
+ }
+ return InMemoryFileSystem.build(filtered_files)
diff --git a/imbue_tools/imbue_tools/repo_utils/file_system_utils.py b/imbue_tools/imbue_tools/repo_utils/file_system_utils.py
@@ -0,0 +1,70 @@
+import asyncio
+import tempfile
+from contextlib import asynccontextmanager
+from pathlib import Path
+from typing import AsyncGenerator
+from typing import cast
+
+import anyio
+import pygit2
+from loguru import logger
+
+from imbue_tools.repo_utils.file_system import FileContents
+from imbue_tools.repo_utils.file_system import InMemoryFileSystem
+from imbue_tools.repo_utils.file_system import SymlinkContents
+
+
+async def write_file_contents_to_dir(file_contents: InMemoryFileSystem, dir_path_str: str) -> None:
+ dir_path = Path(dir_path_str)
+ tasks = [
+ asyncio.create_task(_write_single_file_to_dir(dir_path / file_path, content))
+ for file_path, content in file_contents.files.items()
+ ]
+ await asyncio.gather(*tasks)
+
+
+async def _write_single_file_to_dir(full_path: Path, content: FileContents) -> None:
+ await anyio.to_thread.run_sync(_write_file_sync, full_path, content)
+
+
+def _write_file_sync(full_path: Path, content: FileContents) -> None:
+ full_path.parent.mkdir(parents=True, exist_ok=True)
+ if isinstance(content, bytes):
+ full_path.write_bytes(content)
+ elif isinstance(content, SymlinkContents):
+ full_path.symlink_to(content.target_path)
+ else:
+ logger.error(
+ "Tried to write contents that were neither bytes nor SymlinkContents: {content}",
+ content=content,
+ )
+
+
+@asynccontextmanager
+async def temporary_local_dir_from_in_memory_file_system(
+ file_contents: InMemoryFileSystem,
+) -> AsyncGenerator[str, None]:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ await write_file_contents_to_dir(file_contents, temp_dir)
+ yield temp_dir
+
+
+def create_initial_placeholder_commit_for_dir(repo: pygit2.Repository) -> pygit2.Commit:
+ # pyre-ignore[16]: pyre doesn't understand the inheritance of Repository from BaseRepository
+ repo_index = repo.index
+ repo_index.add_all()
+ repo_index.write()
+ tree = repo_index.write_tree()
+ signature = pygit2.Signature("placeholder", "placeholder@example.com")
+
+ commit_oid = repo.create_commit(
+ "refs/heads/master",
+ signature,
+ signature,
+ "placeholder commit for diff utils",
+ tree,
+ [],
+ )
+ # pyre-ignore[16]: pyre doesn't understand the inheritance of Repository from BaseRepository
+ commit = repo.get(commit_oid)
+ return cast(pygit2.Commit, commit)
diff --git a/imbue_tools/imbue_tools/repo_utils/find_relative_to.py b/imbue_tools/imbue_tools/repo_utils/find_relative_to.py
@@ -0,0 +1,30 @@
+from pathlib import Path
+
+from imbue_core.simple_git import SyncLocalGitRepo
+
+
+def find_relative_to_commit_hash(relative_to: str, repo_path: Path) -> str:
+ """
+ Find the commit hash to use as the source to compare against.
+ - If relative_to is "HEAD", it will return the current commit hash.
+ - If relative_to is a branch name, it will find the last common ancestor between that branch and the current state.
+ - If relative_to is a commit hash or tag, it will return that commit hash.
+ """
+ repo = SyncLocalGitRepo(repo_path)
+ if relative_to.startswith("HEAD"):
+ # The current commit hash or relative to it (e.g. "HEAD~1")
+ base_commit = repo.run_git(["rev-parse", relative_to], check=True)
+ else:
+ # Check if relative_to is the name of a branch.
+ is_branch = repo.is_commit_a_branch(relative_to)
+ if is_branch:
+ # Yes, it's a branch.
+ # Since we're comparing to a branch, this command will find the last common ancestor
+ # between that branch and the current state. This is typically what we want for branches.
+ # (Think of this as getting the diff that would be applied if this branch was to be merged into relative_to.)
+ base_commit = repo.get_merge_base(relative_to, "HEAD")
+ else:
+ # Not a branch. relative_to might be a commit hash or tag.
+ base_commit = relative_to
+
+ return base_commit
diff --git a/imbue_tools/imbue_tools/repo_utils/project_context.py b/imbue_tools/imbue_tools/repo_utils/project_context.py
@@ -0,0 +1,234 @@
+from functools import cached_property
+from functools import lru_cache
+from pathlib import Path
+from typing import Annotated
+from typing import Self
+
+import jinja2
+from pydantic import Tag
+from pydantic import computed_field
+
+from imbue_core.agents.configs import LanguageModelGenerationConfig
+from imbue_core.agents.configs import OpenAICompatibleModelConfig
+from imbue_core.async_utils import sync
+from imbue_core.frozen_utils import FrozenDict
+from imbue_core.pydantic_serialization import SerializableModel
+from imbue_core.pydantic_serialization import build_discriminator
+from imbue_tools.repo_utils.context_prefix import StrategyMode
+from imbue_tools.repo_utils.context_prefix import SubrepoContext
+from imbue_tools.repo_utils.context_prefix import SubrepoContextWithFormattedContext
+from imbue_tools.repo_utils.context_prefix import create_context_prompt_prefix
+from imbue_tools.repo_utils.context_prefix import get_repo_context
+from imbue_tools.repo_utils.context_retrieval import RepoContextManager
+from imbue_tools.repo_utils.file_system import InMemoryFileSystem
+from imbue_tools.repo_utils.subrepo_formatting import (
+ REPO_CONTEXT_TEMPLATE_WITH_NO_MENTION_OF_DIFF,
+)
+
+
+@lru_cache
+def _get_repo_context_manager_for_repo_path(repo_path: Path) -> RepoContextManager:
+ """
+ Wrapper around RepoContextManager.build() to cache the resulting repo context manager.
+
+ Internally, the RepoContextManager object will itself cache the repo contents.
+ """
+ return RepoContextManager.build(repo_path)
+
+
+class BaseProjectContext(SerializableModel):
+ """
+ Holds the context of the checked project including all its files.
+
+ For LLM-based issue identifiers, no matter the scope, we want to use a fixed prompt prefix to leverage caching.
+ We use the stable cached_prompt_prefix for that purpose.
+
+ """
+
+ object_type: str = "BaseProjectContext"
+
+ file_contents_by_path: FrozenDict[str, str]
+ cached_prompt_prefix: str
+ # 0 - n most recent commits, with the most recent one being the first.
+ # The state of the project (file_contents_by_path) is the state after the most recent commit.
+
+ subrepo_context: SubrepoContext | None = None
+ instruction_context: SubrepoContext | None = None
+ repo_path: Path | None = None
+
+ def get_file_contents(self, file_path: str) -> str | None:
+ return self.file_contents_by_path.get(file_path)
+
+ def get_computed_contexts(
+ self,
+ ) -> tuple[SubrepoContext | None, SubrepoContext | None]:
+ """To match usage for LazyProjectContext; all fields are always computed because this isn't lazy"""
+ return self.subrepo_context, self.instruction_context
+
+
+class LazyProjectContext(SerializableModel):
+ object_type: str = "LazyProjectContext"
+
+ base_commit: str
+ diff: str
+ language_model_name: str
+ repo_path: Path
+ tokens_to_reserve: int
+
+ # Optional context window override. If not provided, the model's default context window
+ # will be looked up from the model registry (which fails for unknown models).
+ context_window: int | None = None
+
+ # If True, this is a custom/user-defined model (uses approximate token counting).
+ is_custom_model: bool = False
+
+ def get_file_contents(self, file_path: str) -> str | None:
+ return self.file_contents_by_path.get(file_path)
+
+ @classmethod
+ def build(
+ cls,
+ base_commit: str,
+ diff: str,
+ language_model_name: str,
+ repo_path: Path,
+ # How many tokens to keep for the imbue_verify specific prompt and any output tokens.
+ tokens_to_reserve: int,
+ context_window: int | None = None,
+ is_custom_model: bool = False,
+ ) -> Self:
+ return cls(
+ base_commit=base_commit,
+ diff=diff,
+ language_model_name=language_model_name,
+ repo_path=repo_path,
+ tokens_to_reserve=tokens_to_reserve,
+ context_window=context_window,
+ is_custom_model=is_custom_model,
+ )
+
+ # The fields are computed and cached because they are quite expensive to compute.
+ # We compose multiple repo_context prefixes, and also have to tokenize them to check their respective lengths.
+ # Both of these operations are expensive
+
+ @computed_field
+ @cached_property
+ def repo_context_manager(self) -> RepoContextManager:
+ return _get_repo_context_manager_for_repo_path(self.repo_path)
+
+ @computed_field
+ @cached_property
+ def original_content_by_path(self) -> InMemoryFileSystem:
+ original_content_by_path = sync(self.repo_context_manager.get_full_repo_contents_at_commit)(self.base_commit)
+ return original_content_by_path
+
+ @computed_field
+ @cached_property
+ def content_by_path(self) -> InMemoryFileSystem:
+ if self.diff:
+ return sync(self.repo_context_manager.get_full_repo_contents_at_repo_state)(self.base_commit, self.diff)
+ else:
+ return self.original_content_by_path
+
+ @computed_field
+ @property
+ def file_contents_by_path(self) -> FrozenDict[str, str]:
+ return self.content_by_path.text_files
+
+ @computed_field
+ @cached_property
+ def cached_prompt_prefix(self) -> str:
+ prompt_prefix_template, prompt_prefix_params = create_context_prompt_prefix(
+ repo_context=self.subrepo_context.formatted_repo_context,
+ )
+ env = jinja2.Environment(undefined=jinja2.StrictUndefined)
+ jinja_template = env.from_string(prompt_prefix_template)
+ cached_prompt_prefix = jinja_template.render(**prompt_prefix_params)
+ return cached_prompt_prefix
+
+ @computed_field
+ @cached_property
+ def modified_file_paths(self) -> frozenset[str]:
+ modified_file_paths = []
+ for file_path in self.content_by_path.files.keys():
+ if self.content_by_path.get(file_path) != self.original_content_by_path.get(file_path):
+ modified_file_paths.append(file_path)
+ return frozenset(modified_file_paths)
+
+ def _create_model_config(self) -> LanguageModelGenerationConfig:
+ """Create the appropriate model config for context building.
+
+ For custom models (is_custom_model=True), creates an OpenAICompatibleModelConfig
+ that uses approximate token counting and the specified context window.
+
+ For known models, creates a standard LanguageModelGenerationConfig that uses
+ the model registry for token counting and context window lookup.
+ """
+ if self.is_custom_model:
+ if self.context_window is None:
+ raise ValueError(
+ "context_window must be provided when is_custom_model=True "
+ + "(custom models don't have a known context window)"
+ )
+ return OpenAICompatibleModelConfig(
+ model_name=self.language_model_name,
+ custom_base_url="", # Not used for context building
+ custom_api_key_env="", # Not used for context building
+ custom_context_window=self.context_window,
+ custom_max_output_tokens=0, # Not used for context building
+ )
+ else:
+ return LanguageModelGenerationConfig(model_name=self.language_model_name)
+
+ @computed_field
+ @cached_property
+ def subrepo_context(self) -> SubrepoContextWithFormattedContext:
+ model_config = self._create_model_config()
+
+ subrepo_context = get_repo_context(
+ full_repo_contents=self.content_by_path,
+ model_config=model_config,
+ relevant_file_paths=self.modified_file_paths,
+ tokens_to_reserve=self.tokens_to_reserve,
+ )
+ return subrepo_context
+
+ def to_base_project_context(self) -> BaseProjectContext:
+ return BaseProjectContext(
+ file_contents_by_path=self.file_contents_by_path,
+ cached_prompt_prefix=self.cached_prompt_prefix,
+ subrepo_context=self.subrepo_context,
+ instruction_context=self.instruction_context,
+ repo_path=self.repo_path,
+ )
+
+ @computed_field
+ @cached_property
+ def instruction_context(self) -> SubrepoContextWithFormattedContext:
+ model_config = self._create_model_config()
+ return get_repo_context(
+ model_config=model_config,
+ full_repo_contents=self.content_by_path,
+ relevant_file_paths=None,
+ tokens_to_reserve=self.tokens_to_reserve,
+ strategy_mode=StrategyMode.DOCS,
+ template=REPO_CONTEXT_TEMPLATE_WITH_NO_MENTION_OF_DIFF,
+ )
+
+ def get_computed_contexts(
+ self,
+ ) -> tuple[
+ SubrepoContextWithFormattedContext | None,
+ SubrepoContextWithFormattedContext | None,
+ ]:
+ """Returns subrepo context and instruction context, but only if they have already been computed; those that haven't been computed are None"""
+ # checking for presence in __dict__ does not trigger computation
+ subrepo_context = self.subrepo_context if "subrepo_context" in self.__dict__ else None
+ instruction_context = self.instruction_context if "instruction_context" in self.__dict__ else None
+ return subrepo_context, instruction_context
+
+
+ProjectContext = Annotated[
+ Annotated[BaseProjectContext, Tag("BaseProjectContext")] | LazyProjectContext,
+ build_discriminator(),
+]
diff --git a/imbue_tools/imbue_tools/repo_utils/python_imports.py b/imbue_tools/imbue_tools/repo_utils/python_imports.py
@@ -0,0 +1,130 @@
+import ast
+import sys
+from pathlib import Path
+
+from imbue_core.pydantic_serialization import SerializableModel
+
+STANDARD_LIBRARIES: frozenset[str] = sys.stdlib_module_names | frozenset(sys.builtin_module_names)
+
+
+class QualifiedName(SerializableModel):
+ """A qualified name like 'foo.bar.baz'."""
+
+ value: str
+
+ @property
+ def top_level_name(self) -> "QualifiedName":
+ """Return the top-level module name (e.g., 'foo' from 'foo.bar.baz')."""
+ return QualifiedName(value=self.value.split(".", maxsplit=1)[0])
+
+ @property
+ def parent_name(self) -> "QualifiedName":
+ """Return the parent module name (e.g., 'foo.bar' from 'foo.bar.baz')."""
+ return QualifiedName(value=self.value.rsplit(".", maxsplit=1)[0])
+
+ def to_path(self) -> Path:
+ """Convert qualified name to a file path (e.g., 'foo.bar' -> 'foo/bar.py')."""
+ return Path(self.value.replace(".", "/") + ".py")
+
+
+class Import(SerializableModel):
+ """Represents a single import statement."""
+
+ source: str
+ alias: str | None
+ qualified_name: QualifiedName
+
+
+def _collect_global_imports(node: ast.AST, imports: list[Import]) -> None:
+ """
+ Recursively collect import statements at global scope.
+
+ Stops recursing into function and class definitions since imports
+ inside those are not at global scope.
+
+ Args:
+ node: The AST node to process
+ imports: List to accumulate found imports
+ """
+ if isinstance(node, ast.Import):
+ # Handle: import foo, bar
+ # Handle: import foo as bar
+ for alias in node.names:
+ if alias.asname:
+ source = f"import {alias.name} as {alias.asname}"
+ alias_name = alias.asname
+ else:
+ source = f"import {alias.name}"
+ alias_name = None
+ imports.append(
+ Import(
+ source=source,
+ alias=alias_name,
+ qualified_name=QualifiedName(value=alias.name),
+ )
+ )
+ elif isinstance(node, ast.ImportFrom):
+ # Handle: from foo import bar, baz
+ module = node.module or ""
+ if node.names[0].name == "*":
+ # from foo import *
+ source = f"from {module} import *"
+ imports.append(
+ Import(
+ source=source,
+ alias=None,
+ qualified_name=QualifiedName(value=f"{module}.*"),
+ )
+ )
+ else:
+ for alias in node.names:
+ if module:
+ full_name = f"{module}.{alias.name}"
+ else:
+ # relative import like: from . import foo
+ full_name = alias.name
+
+ if alias.asname:
+ source = f"from {module} import {alias.name} as {alias.asname}"
+ alias_name = alias.asname
+ else:
+ source = f"from {module} import {alias.name}"
+ alias_name = None
+
+ imports.append(
+ Import(
+ source=source,
+ alias=alias_name,
+ qualified_name=QualifiedName(value=full_name),
+ )
+ )
+
+ # Don't recurse into function or class definitions - imports there are not global
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
+ return
+
+ # Recurse into child nodes
+ for child in ast.iter_child_nodes(node):
+ _collect_global_imports(child, imports)
+
+
+def get_global_imports(source_code: str) -> tuple[Import, ...]:
+ """
+ Extract all global imports from Python source code.
+
+ This includes imports at module level, as well as imports inside conditionals
+ or other control flow structures at the top level (not inside functions or classes).
+
+ Args:
+ source_code: Python source code as a string
+
+ Returns:
+ Tuple of Import objects representing all imports in the file
+
+ Raises:
+ SyntaxError: If the source code cannot be parsed
+ """
+ tree = ast.parse(source_code)
+ imports: list[Import] = []
+ _collect_global_imports(tree, imports)
+ return tuple(imports)
diff --git a/imbue_tools/imbue_tools/repo_utils/stubify_file.py b/imbue_tools/imbue_tools/repo_utils/stubify_file.py
@@ -0,0 +1,167 @@
+"""From https://github.com/OpenAutoCoder/Agentless/blob/main/agentless/util/compress_file.py"""
+
+import re
+from typing import Any
+from typing import Callable
+
+import libcst as cst
+import libcst.matchers as m
+from loguru import logger
+
+
+def check_on_body(stmt: cst.CSTNode, check: Callable[[cst.CSTNode], bool]) -> bool:
+ if not m.matches(stmt, m.SimpleStatementLine()):
+ return False
+ # pyre-ignore[16]: m.SimpleStatementLine has a body attribute which is a Sequence
+ first_body_item = stmt.body[0]
+ return check(first_body_item)
+
+
+def is_assign(stmt: cst.CSTNode) -> bool:
+ if not m.matches(stmt, m.SimpleStatementLine()):
+ return False
+ # pyre-ignore[16]: m.SimpleStatementLine has a body attribute which is a Sequence
+ first_body_item = stmt.body[0]
+ if m.matches(first_body_item, m.Assign()):
+ return True
+ return False
+
+
+class CompressTransformer(cst.CSTTransformer):
+ DESCRIPTION = str = "Replaces function body with ..."
+ replacement_string = '"__FUNC_BODY_REPLACEMENT_STRING__"'
+
+ def __init__(self, keep_constant: bool = True, keep_indent: bool = False) -> None:
+ self.keep_constant = keep_constant
+ self.keep_indent = keep_indent
+
+ def leave_Module(self, original_node: cst.Module, updated_node: cst.Module) -> cst.Module:
+ new_body = [
+ stmt
+ for stmt in updated_node.body
+ if m.matches(stmt, m.ClassDef())
+ or m.matches(stmt, m.FunctionDef())
+ or (
+ self.keep_constant
+ and check_on_body(stmt, lambda first_body_item: m.matches(first_body_item, m.Assign()))
+ )
+ ]
+ return updated_node.with_changes(body=new_body)
+
+ def leave_ClassDef(self, original_node: cst.ClassDef, updated_node: cst.ClassDef) -> cst.ClassDef:
+ # Remove docstring in the class body
+ new_body = [
+ stmt
+ for stmt in updated_node.body.body
+ if not check_on_body(
+ stmt,
+ lambda first_body_item: m.matches(first_body_item, m.Expr())
+ or (hasattr(first_body_item, "value") and m.matches(first_body_item.value, m.SimpleString())),
+ )
+ ]
+ # pyre-fixme[6]: cst.IndentedBlock has a body attribute which is a Sequence[BaseStatement], not a Sequence[BaseSmallStatement] like new_body
+ return updated_node.with_changes(body=cst.IndentedBlock(body=new_body))
+
+ def leave_FunctionDef(self, original_node: cst.FunctionDef, updated_node: cst.FunctionDef) -> cst.BaseStatement:
+ if not self.keep_indent:
+ # replace with unindented statement
+ new_expr = cst.Expr(value=cst.SimpleString(value=self.replacement_string))
+ # pyre-fixme[6]: cst.Expr is a BaseSmallStatement, not a BaseStatement like new_expr
+ new_body = cst.IndentedBlock((new_expr,))
+ return updated_node.with_changes(body=new_body)
+ else:
+ # replace with indented statement
+ # new_expr = [cst.Pass()]
+ new_expr = [
+ cst.Expr(value=cst.SimpleString(value=self.replacement_string)),
+ ]
+ return updated_node.with_changes(body=cst.IndentedBlock(body=[cst.SimpleStatementLine(body=new_expr)]))
+
+
+class GlobalVariableVisitor(cst.CSTVisitor):
+ METADATA_DEPENDENCIES = (cst.metadata.PositionProvider,)
+
+ def __init__(self) -> None:
+ self.assigns: list[list[Any]] = []
+
+ def leave_Assign(self, original_node: cst.Assign) -> None:
+ stmt = original_node
+ start_pos = self.get_metadata(cst.metadata.PositionProvider, stmt).start
+ end_pos = self.get_metadata(cst.metadata.PositionProvider, stmt).end
+ self.assigns.append([stmt, start_pos, end_pos])
+
+
+def remove_lines(raw_code: str, remove_line_intervals: list[tuple[int, int]]) -> str:
+ # TODO: speed up this function
+ # remove_line_intervals.sort()
+
+ # Remove lines
+ new_code = ""
+ for i, line in enumerate(raw_code.splitlines()):
+ # intervals are one-based
+ if not any(start <= i + 1 <= end for start, end in remove_line_intervals):
+ new_code += line + "\n"
+ if any(start == i + 1 for start, _ in remove_line_intervals):
+ new_code += "...\n"
+ return new_code
+
+
+def compress_assign_stmts(raw_code: str, total_lines: int = 30, prefix_lines: int = 10, suffix_lines: int = 10) -> str:
+ try:
+ tree = cst.parse_module(raw_code)
+ except cst.ParserSyntaxError as e:
+ logger.debug(
+ "Failed to compress assign statements: {exception_class}, {exception}",
+ exception_class=e.__class__.__name__,
+ exception=e,
+ )
+ return raw_code
+
+ wrapper = cst.metadata.MetadataWrapper(tree)
+ visitor = GlobalVariableVisitor()
+ wrapper.visit(visitor)
+
+ remove_line_intervals = []
+ for stmt in visitor.assigns:
+ if stmt[2].line - stmt[1].line > total_lines:
+ remove_line_intervals.append((stmt[1].line + prefix_lines, stmt[2].line - suffix_lines))
+ return remove_lines(raw_code, remove_line_intervals)
+
+
+def stubify_code_file(
+ path: str | None,
+ raw_code: str,
+ keep_constant: bool = True,
+ keep_indent: bool = False,
+ compress_assign: bool = False,
+ total_lines: int = 30,
+ prefix_lines: int = 10,
+ suffix_lines: int = 10,
+) -> str:
+ try:
+ tree = cst.parse_module(raw_code)
+ except cst.ParserSyntaxError:
+ logger.debug("failed to stubify code file {}; will leave it as is", path)
+ return raw_code
+
+ transformer = CompressTransformer(keep_constant=keep_constant, keep_indent=True)
+ modified_tree = tree.visit(transformer)
+ code = modified_tree.code
+
+ if compress_assign:
+ code = compress_assign_stmts(
+ code,
+ total_lines=total_lines,
+ prefix_lines=prefix_lines,
+ suffix_lines=suffix_lines,
+ )
+
+ if keep_indent:
+ code = code.replace(CompressTransformer.replacement_string + "\n", "...\n")
+ code = code.replace(CompressTransformer.replacement_string, "...\n")
+ else:
+ pattern = f"\\n[ \\t]*{CompressTransformer.replacement_string}"
+ replacement = "\n..."
+ code = re.sub(pattern, replacement, code)
+
+ return code
diff --git a/imbue_tools/imbue_tools/repo_utils/subrepo_formatting.py b/imbue_tools/imbue_tools/repo_utils/subrepo_formatting.py
@@ -0,0 +1,368 @@
+import functools
+from collections import defaultdict
+from enum import Enum
+from typing import Annotated
+from typing import Iterable
+from typing import Mapping
+from typing import Self
+from typing import assert_never
+
+import jinja2
+from pathspec import GitIgnoreSpec
+from pydantic import Tag
+
+from imbue_core.agents.configs import LanguageModelGenerationConfig
+from imbue_core.pydantic_serialization import SerializableModel
+from imbue_core.pydantic_serialization import build_discriminator
+from imbue_tools.repo_utils.context_utils import escape_all_jinja_variables
+from imbue_tools.repo_utils.context_utils import escape_prompt_markers
+from imbue_tools.repo_utils.data_types import FileContext
+from imbue_tools.repo_utils.data_types import FileContextUnion
+from imbue_tools.repo_utils.data_types import FilenameContext
+from imbue_tools.repo_utils.data_types import FullFileContext
+from imbue_tools.repo_utils.data_types import StubFileContext
+from imbue_tools.repo_utils.errors import ContextLengthExceededError
+from imbue_tools.repo_utils.stubify_file import stubify_code_file
+
+
+class ContextFormatStyle(Enum):
+ FULL_FILE = "FULL_FILE"
+ STUB = "STUB"
+ FILENAME_ONLY = "FILENAME_ONLY"
+ HIDDEN = "HIDDEN"
+
+
+class BaseFilenamePattern(SerializableModel):
+ """
+ Extends the functionality of `GitIgnoreSpec` to be serializable.
+ """
+
+ object_type: str = "BaseFilenamePattern"
+ lines: tuple[str, ...]
+
+ @functools.cached_property
+ def git_ignore_spec(self) -> GitIgnoreSpec:
+ return GitIgnoreSpec.from_lines(self.lines)
+
+ @classmethod
+ def from_lines(cls, lines: Iterable[str]) -> Self:
+ return cls(lines=tuple(sorted(lines)))
+
+ def match_file(self, file: str) -> bool:
+ return self.git_ignore_spec.match_file(file)
+
+
+class NegatedFilenamePattern(BaseFilenamePattern):
+ """Matches everything except the files matched by the base pattern."""
+
+ object_type: str = "NegatedFilenamePattern"
+
+ def match_file(self, file: str) -> bool:
+ return not self.git_ignore_spec.match_file(file)
+
+ @classmethod
+ def build_from_positive_pattern(cls, positive_pattern: BaseFilenamePattern) -> Self:
+ return cls(lines=positive_pattern.lines)
+
+
+class IntersectionFilenamePattern(SerializableModel):
+ object_type: str = "IntersectionFilenamePattern"
+ specs: tuple["FilenamePattern", ...]
+
+ def match_file(self, file: str) -> bool:
+ return all(spec.match_file(file) for spec in self.specs)
+
+
+class UnionFilenamePattern(SerializableModel):
+ object_type: str = "UnionFilenamePattern"
+ specs: tuple["FilenamePattern", ...]
+
+ def match_file(self, file: str) -> bool:
+ return any(spec.match_file(file) for spec in self.specs)
+
+
+class ExactFilenamePattern(SerializableModel):
+ """
+ Similar to a BaseFilenamePattern, but more efficient thanks to the use of a hash set.
+ However, it only supports exact filename matches and no patterns.
+
+ O(1) matching over n filenames, instead of O(n) with BaseFilenamePattern.
+ """
+
+ object_type: str = "ExactFilenamePattern"
+ # Will match these exact filenames.
+ # We store this as a tuple instead of frozenset to have deterministic ordering. Helps with snapshot tests.
+ filenames: tuple[str, ...]
+
+ def match_file(self, file: str) -> bool:
+ return file in self.filenames_set
+
+ @functools.cached_property
+ def filenames_set(self) -> set[str]:
+ return set(self.filenames)
+
+
+FilenamePattern = Annotated[
+ Annotated[BaseFilenamePattern, Tag("BaseFilenamePattern")]
+ | Annotated[NegatedFilenamePattern, Tag("NegatedFilenamePattern")]
+ | Annotated[IntersectionFilenamePattern, Tag("IntersectionFilenamePattern")]
+ | Annotated[UnionFilenamePattern, Tag("UnionFilenamePattern")]
+ | Annotated[ExactFilenamePattern, Tag("ExactFilenamePattern")],
+ build_discriminator(),
+]
+
+
+SubrepoContextMatchers = tuple[tuple[ContextFormatStyle, FilenamePattern], ...]
+
+
+@functools.lru_cache(maxsize=100)
+def stubify_file_contents_cached(path: str, contents: str) -> str:
+ # TODO: there's various flags here we could try
+ # TODO: we may want an option to suppress comments, which end up being a large percent of the lines
+ if path.endswith(".py"):
+ return stubify_code_file(path, contents, keep_indent=True)
+ else:
+ # For non-Python files, maintain the full contents for now.
+ return contents
+
+
+def stubify_and_format_for_agent_context(path: str, contents: str | None) -> StubFileContext:
+ contents_to_use = contents if contents is not None else "RAW FILE CONTENTS"
+ stub = stubify_file_contents_cached(path=path, contents=contents_to_use)
+ return StubFileContext(path=path, stub=stub)
+
+
+def format_filename_only_for_agent_context(path: str) -> FilenameContext:
+ return FilenameContext(path=path)
+
+
+def format_full_file_for_agent_context(path: str, contents: str) -> FullFileContext:
+ return FullFileContext(path=path, contents=contents)
+
+
+BASE_REPO_CONTEXT_TEMPLATE = """
+<REPO_CONTEXT>
+{{repo_context}}
+</REPO_CONTEXT>
+"""
+
+REPO_CONTEXT_TEMPLATE_WITH_NO_MENTION_OF_DIFF = (
+ """
+{% if not is_shortened %}For context, here are the contents of all files currently in the project:
+{% else %}The project's repository is too large to show in full, so we have chosen a useful subset for your context.
+Files in the repository may be shown in either full, stubified, or filename-only form{% if has_hidden_files %}, or they may be hidden entirely{% endif %}. Here are the contents of some of the files in the repository:{% endif %}
+"""
+ + BASE_REPO_CONTEXT_TEMPLATE
+)
+
+REPO_CONTEXT_TEMPLATE = (
+ REPO_CONTEXT_TEMPLATE_WITH_NO_MENTION_OF_DIFF
+ + """
+
+If any files have been changed, the changes will be described next. Otherwise, you can assume this is the current state of the project.
+"""
+)
+
+
+def format_file_for_agent_context(
+ path: str, contents: str, format_style: ContextFormatStyle
+) -> FileContextUnion | None:
+ if format_style == ContextFormatStyle.FULL_FILE:
+ return format_full_file_for_agent_context(path, contents)
+ elif format_style == ContextFormatStyle.STUB:
+ return stubify_and_format_for_agent_context(path, contents)
+ elif format_style == ContextFormatStyle.FILENAME_ONLY:
+ return format_filename_only_for_agent_context(path)
+ elif format_style == ContextFormatStyle.HIDDEN:
+ return None
+ else:
+ assert_never(format_style) # pyre-ignore[6]: pyre doesn't understand enums
+
+
+@functools.lru_cache(maxsize=20)
+def parse_subrepo_context_matchers_from_toml(
+ subrepo_context_config_toml: str,
+) -> SubrepoContextMatchers:
+ current_mode: ContextFormatStyle
+ matchers = []
+ for line in subrepo_context_config_toml.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ if line.startswith("["):
+ current_mode = ContextFormatStyle[line.replace("[", "").replace("]", "").upper()] # type: ignore
+ continue
+
+ exclude_spec = BaseFilenamePattern.from_lines([line])
+ matchers.append((current_mode, exclude_spec))
+ return tuple(matchers)
+
+
+def compute_file_format_style(
+ file_path: str,
+ subrepo_context_matchers: SubrepoContextMatchers,
+ exclusions: FilenamePattern | None = None,
+) -> ContextFormatStyle:
+ for matcher_format, matcher in subrepo_context_matchers:
+ if matcher.match_file(file_path):
+ if exclusions and matcher_format != ContextFormatStyle.HIDDEN and exclusions.match_file(file_path):
+ # Exclusions override any non-hidden format and downgrade it to FILENAME_ONLY.
+ return ContextFormatStyle.FILENAME_ONLY
+ # Return the first match
+ return matcher_format
+ return ContextFormatStyle.HIDDEN
+
+
+def compute_file_context_format_styles(
+ file_paths: Iterable[str],
+ subrepo_context_matchers: SubrepoContextMatchers,
+ exclusions: FilenamePattern | None = None,
+) -> Mapping[str, ContextFormatStyle]:
+ return {
+ file_path: compute_file_format_style(file_path, subrepo_context_matchers, exclusions)
+ for file_path in file_paths
+ }
+
+
+def get_estimated_lower_bound_token_count_for_text_and_model(text: str, model_name: str) -> int:
+ # A factor of 1/4.5 appears to be a reasonable empirical estimate for current models.
+ # We use a slighly smaller factor (1/5) to give more of a lower bound estimate.
+ return round(len(text) / 5)
+
+
+def format_all_for_agent(repo_contents: tuple[FileContext, ...]) -> dict[str, str]:
+ return {contents.path: contents.format_for_agent() for contents in repo_contents}
+
+
+def format_subrepo(formatted_repo_contents: Mapping[str, str]) -> str:
+ repo_context_str = "".join([contents for contents in formatted_repo_contents.values() if contents is not None])
+ return escape_all_jinja_variables(escape_prompt_markers(repo_context_str))
+
+
+def format_subrepo_context_full(repo: Mapping[str, str]) -> str:
+ """Like get_repo_context but there's no checking for context limits (so we can use the api checks instead)
+ and the selected strategy is always the full repo contents."""
+ formatted_repo_context = format_subrepo(
+ format_all_for_agent(
+ format_subrepo_context_into_filecontexts(
+ full_repo_contents=repo,
+ path_to_format_style=defaultdict(lambda: ContextFormatStyle.FULL_FILE, {}),
+ )
+ )
+ )
+
+ repo_context_core_prompt = formatted_subrepo_to_prompt(
+ repo_context_str=formatted_repo_context,
+ is_shortened=False,
+ has_hidden_files=False,
+ template=BASE_REPO_CONTEXT_TEMPLATE,
+ )
+
+ return repo_context_core_prompt
+
+
+def formatted_subrepo_to_prompt(
+ repo_context_str: str, is_shortened: bool, has_hidden_files: bool, template: str
+) -> str:
+ env = jinja2.Environment(undefined=jinja2.StrictUndefined)
+ jinja_template = env.from_string(template)
+ repo_context_prompt = jinja_template.render(
+ repo_context=repo_context_str,
+ is_shortened=is_shortened,
+ has_hidden_files=has_hidden_files,
+ )
+ return repo_context_prompt
+
+
+def format_subrepo_context_into_filecontexts(
+ full_repo_contents: Mapping[str, str],
+ path_to_format_style: Mapping[str, ContextFormatStyle],
+) -> tuple[FileContextUnion, ...]:
+ repo_contents = tuple(
+ format_file_for_agent_context(path, contents, path_to_format_style[path])
+ for path, contents in full_repo_contents.items()
+ )
+ repo_contents_with_hidden_removed = tuple(contents for contents in repo_contents if contents is not None)
+ return repo_contents_with_hidden_removed
+
+
+def build_context_from_filecontexts(
+ repo_contents_with_hidden_removed: tuple[FileContextUnion, ...],
+ model_config: LanguageModelGenerationConfig,
+ # how many tokens to reserve for additional prompt messages and output
+ tokens_to_reserve: int,
+ template: str = REPO_CONTEXT_TEMPLATE,
+ path_to_format_style: Mapping[str, ContextFormatStyle] | None = None,
+) -> str:
+ """
+ Returns the repo contents formatted according to the format styles as a string.
+ Includes (at least if the default template is used) an explanation at the beginning
+ saying that these are the contents of the repo, potentially truncated or hidden.
+
+ If there are no repo contents, returns an empty string.
+ """
+
+ if not repo_contents_with_hidden_removed:
+ return ""
+
+ max_context_length = model_config.get_max_context_length()
+ available_tokens = max_context_length - tokens_to_reserve
+
+ formatted_repo_contents_with_hidden_removed = format_all_for_agent(repo_contents_with_hidden_removed)
+
+ repo_context_str = format_subrepo(formatted_repo_contents_with_hidden_removed)
+
+ if model_config.is_custom_model():
+ # For custom models, approximate_token_count is already fast, so skip the estimation step.
+ full_repo_context_token_count = model_config.count_tokens(repo_context_str)
+ else:
+ # First use an estimation of the token count to see if we are likely below the maximum length. Then
+ # double-check with the exact token count.
+ # We do this because getting the exact token count is quite slow.
+ estimated_full_repo_context_token_count = get_estimated_lower_bound_token_count_for_text_and_model(
+ repo_context_str, model_config.model_name
+ )
+ if estimated_full_repo_context_token_count > available_tokens:
+ raise ContextLengthExceededError(
+ f"Estimated context has size {estimated_full_repo_context_token_count}; available tokens {available_tokens}"
+ )
+ full_repo_context_token_count = model_config.count_tokens(repo_context_str)
+ if full_repo_context_token_count > available_tokens:
+ raise ContextLengthExceededError(
+ f"Context has size {full_repo_context_token_count}; available tokens {available_tokens}"
+ )
+
+ if path_to_format_style is None:
+ path_to_format_style = {
+ contents.path: ContextFormatStyle.FULL_FILE for contents in repo_contents_with_hidden_removed
+ }
+
+ is_shortened = any([style != ContextFormatStyle.FULL_FILE for style in path_to_format_style.values()])
+ has_hidden_files = any([style == ContextFormatStyle.HIDDEN for style in path_to_format_style.values()])
+
+ repo_context_prompt = formatted_subrepo_to_prompt(
+ repo_context_str=repo_context_str,
+ is_shortened=is_shortened,
+ has_hidden_files=has_hidden_files,
+ template=template,
+ )
+ return repo_context_prompt
+
+
+def format_subrepo_context(
+ full_repo_contents: Mapping[str, str],
+ path_to_format_style: Mapping[str, ContextFormatStyle],
+ model_config: LanguageModelGenerationConfig,
+ # how many tokens to reserve for additional prompt messages and output
+ tokens_to_reserve: int,
+ template: str = REPO_CONTEXT_TEMPLATE,
+) -> tuple[str, tuple[FileContextUnion, ...]]:
+ repo_contents_tuple = format_subrepo_context_into_filecontexts(full_repo_contents, path_to_format_style)
+ repo_context_str = build_context_from_filecontexts(
+ repo_contents_tuple,
+ model_config,
+ tokens_to_reserve,
+ template,
+ path_to_format_style,
+ )
+ return repo_context_str, repo_contents_tuple
diff --git a/imbue_tools/imbue_tools/types/imbue_verify_config.py b/imbue_tools/imbue_tools/types/imbue_verify_config.py
@@ -0,0 +1,100 @@
+from pathlib import Path
+
+from imbue_core.agents.configs import LanguageModelGenerationConfig
+from imbue_core.agents.llm_apis.anthropic_api import AnthropicModelName
+from imbue_core.data_types import IssueCode
+from imbue_core.pydantic_serialization import SerializableModel
+
+DEFAULT_CONFIDENCE_THRESHOLD = 0.8
+
+
+class ImbueVerifyConfig(SerializableModel):
+ """Configuration for the imbue_verify system."""
+
+ # If none, all registered identifiers are used.
+ # Otherwise, only the identifiers in this tuple are used.
+ enabled_identifiers: tuple[str, ...] | None = None
+
+ # Issue identifiers that are disabled are never used.
+ disabled_identifiers: tuple[str, ...] | None = None
+
+ # Similar to the above, but for reporting specific types of issues.
+ # (Use the values from the imbue_verify.data_types.IssueCode enum.)
+ enabled_issue_codes: tuple[IssueCode, ...] | None = None
+ disabled_issue_codes: tuple[IssueCode, ...] | None = ()
+
+ # Todo: Different models for different issue identifiers
+ language_model_generation_config: LanguageModelGenerationConfig = LanguageModelGenerationConfig(
+ model_name=AnthropicModelName.CLAUDE_4_5_HAIKU_2025_10_01
+ )
+ max_identifier_spend_dollars: float = 5.0
+ max_output_tokens: int = 20000
+ enable_parallel_agentic_issue_identification: bool = False
+ max_identify_workers: int | None = None
+ temperature: float = 0.5
+
+ # If True, apply an additional LLM-based filtering stage, where each identified issue is evaluated
+ # according to a number of quality criteria. Only issues that pass the evaluation are returned.
+ filter_issues: bool = True
+ filter_issues_through_llm_evaluator: bool = True
+ filter_issues_below_confidence: float | None = DEFAULT_CONFIDENCE_THRESHOLD
+
+ enable_deduplication: bool = True
+ enable_collation: bool = True
+
+ # If True, we attempt to cache the full prompts including specific inputs with the LLM provider.
+ # There can be an additional cost for such a cache write, but it can help save cost in evaluation
+ # contexts (such as black_box_evals) where the same inputs are being evaluated multiple times.
+ cache_full_prompt: bool = False
+
+ extra_context: str | None = None
+
+ @classmethod
+ def build(
+ cls,
+ language_model_name: str | None = None,
+ language_model_cache_path: Path | None = None,
+ enabled_identifiers: tuple[str, ...] | None = None,
+ enable_parallel_agentic_issue_identification: bool = False,
+ max_identify_workers: int | None = None,
+ filter_issues: bool = True,
+ filter_issues_below_confidence: float | None = DEFAULT_CONFIDENCE_THRESHOLD,
+ enable_deduplication: bool = True,
+ enable_collation: bool = True,
+ enabled_issue_codes: tuple[IssueCode, ...] | None = None,
+ temperature: float = 0.5,
+ retry_jitter_factor: float = 0.0,
+ cache_full_prompt: bool = False,
+ ) -> "ImbueVerifyConfig":
+ if not language_model_name:
+ language_model_name = AnthropicModelName.CLAUDE_4_5_HAIKU_2025_10_01
+ language_model_generation_config = LanguageModelGenerationConfig(
+ model_name=language_model_name,
+ cache_path=language_model_cache_path,
+ retry_jitter_factor=retry_jitter_factor,
+ )
+ return cls(
+ language_model_generation_config=language_model_generation_config,
+ enabled_identifiers=enabled_identifiers,
+ enable_parallel_agentic_issue_identification=enable_parallel_agentic_issue_identification,
+ max_identify_workers=max_identify_workers,
+ filter_issues=filter_issues,
+ filter_issues_below_confidence=filter_issues_below_confidence,
+ enable_deduplication=enable_deduplication,
+ enable_collation=enable_collation,
+ enabled_issue_codes=enabled_issue_codes,
+ temperature=temperature,
+ cache_full_prompt=cache_full_prompt,
+ )
+
+
+def get_enabled_issue_codes(config: ImbueVerifyConfig) -> set[IssueCode]:
+ all_issue_code_values = {item.value for item in IssueCode}
+ explicitly_enabled = config.enabled_issue_codes or tuple()
+ explicitly_disabled = config.disabled_issue_codes or tuple()
+ for code in explicitly_enabled + explicitly_disabled:
+ if code not in all_issue_code_values:
+ raise ValueError(f"Bad config: unknown issue code: {code}")
+ possibly_enabled_values = set(explicitly_enabled) if len(explicitly_enabled) > 0 else set(v for v in IssueCode)
+ disabled_values = set(explicitly_disabled)
+ return possibly_enabled_values - disabled_values
diff --git a/imbue_tools/imbue_tools/util_prompts/conversation_prefix.py b/imbue_tools/imbue_tools/util_prompts/conversation_prefix.py
@@ -0,0 +1,9 @@
+CONVERSATION_PREFIX_TEMPLATE = """[ROLE=SYSTEM_CACHED]
+You will be provided a conversation history between a user and another agent. The other agent may be from any model provider or model family.
+The conversation history includes the user's messages and the agent's text-based messages, but may be missing some automated messages and tool calls/tool call results.
+Examine the conversation carefully and be prepared to answer questions about it.
+Here is the conversation history between the user and the other agent.
+{% filter indent(width=2) %}
+```
+{{ conversation_history }}
+```{% endfilter %}"""
diff --git a/imbue_tools/imbue_tools/util_prompts/goal_from_conversation.py b/imbue_tools/imbue_tools/util_prompts/goal_from_conversation.py
@@ -0,0 +1,64 @@
+import jinja2
+
+from imbue_core.agents.configs import LanguageModelGenerationConfig
+from imbue_core.agents.llm_apis.build_apis import build_language_model_from_config
+from imbue_core.agents.llm_apis.data_types import CostedLanguageModelResponse
+from imbue_core.agents.llm_apis.data_types import LanguageModelGenerationParams
+from imbue_core.itertools import only
+from imbue_core.sculptor.state.messages import ConversationMessageUnion
+from imbue_tools.get_conversation_history.get_conversation_history import (
+ format_conversation_history_for_prompt,
+)
+from imbue_tools.util_prompts.conversation_prefix import CONVERSATION_PREFIX_TEMPLATE
+
+# TODO: see how this does on actual examples where the agent did something other than what the user asked for
+PROMPT_TEMPLATE = (
+ CONVERSATION_PREFIX_TEMPLATE
+ + """
+[ROLE=USER]
+What is the user's goal based on the preceding conversation?
+Pay attention only to what the user asks for, not what the agent does.
+Respond with a brief description of the goal--a few sentences at most.
+The goal should be listed as an imperative; for example "Implement XYZ" rather than "The user's goal is to implement XYZ".
+Do not include any reasoning or other text in your response.
+"""
+)
+
+# should be totally sufficient for a goal that's only supposed to be a few sentences
+MAX_OUTPUT_TOKENS = 500
+
+GOAL_GENERATION_DEFAULT_PARAMS = LanguageModelGenerationParams(temperature=0.0, max_tokens=MAX_OUTPUT_TOKENS)
+
+
+def prompt_for_getting_goal_from_conversation(
+ conversation_history: tuple[ConversationMessageUnion, ...],
+) -> str:
+ env = jinja2.Environment(undefined=jinja2.StrictUndefined)
+ jinja_template = env.from_string(PROMPT_TEMPLATE)
+ return jinja_template.render(conversation_history=format_conversation_history_for_prompt(conversation_history))
+
+
+def get_goal_from_conversation_with_usage(
+ conversation_history: tuple[ConversationMessageUnion, ...],
+ language_model_generation_config: LanguageModelGenerationConfig,
+) -> CostedLanguageModelResponse:
+ """Query an LLM with the conversation history to get the user's goal, and include usage info in the response."""
+ language_model = build_language_model_from_config(language_model_generation_config)
+ prompt = prompt_for_getting_goal_from_conversation(conversation_history)
+ costed_response = language_model.complete_with_usage_sync(
+ prompt,
+ params=GOAL_GENERATION_DEFAULT_PARAMS,
+ is_caching_enabled=language_model.cache_path is not None,
+ )
+ return costed_response
+
+
+def get_goal_from_conversation(
+ conversation_history: tuple[ConversationMessageUnion, ...],
+ language_model_generation_config: LanguageModelGenerationConfig,
+) -> str:
+ """Query an LLM with the conversation history to get the user's goal."""
+ response = only(
+ get_goal_from_conversation_with_usage(conversation_history, language_model_generation_config).responses
+ )
+ return response.text
diff --git a/imbue_tools/pyproject.toml b/imbue_tools/pyproject.toml
@@ -0,0 +1,43 @@
+[build-system]
+requires = ["setuptools", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "imbue-tools"
+version = "0.1.0"
+description = "Utils for imbue-cli tools"
+readme = "README.md"
+dependencies = [
+ "anyio",
+ "async_lru",
+ "attrs",
+ "imbue_core",
+ "jinja2",
+ "libcst",
+ "loguru",
+ "psycopg[binary]",
+ "pydantic",
+ "pydantic-settings",
+ "pygit2",
+ "pytest",
+ "pytest-asyncio",
+ "python-gitlab",
+ "requests",
+ "syrupy",
+]
+requires-python = ">=3.11"
+
+[project.optional-dependencies]
+test = [
+ "imbue-verify",
+]
+
+[tool.setuptools]
+package-data.imbue_tools = ["py.typed"]
+
+[tool.setuptools.packages.find]
+include = ["imbue_tools*"]
+
+[tool.uv.sources]
+imbue_core = { path = "../imbue_core", editable = true }
+imbue-verify = { path = "..", editable = true }
diff --git a/imbue_tools/uv.lock b/imbue_tools/uv.lock
@@ -0,0 +1,2980 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+resolution-markers = [
+ "python_full_version >= '3.14'",
+ "python_full_version == '3.13.*'",
+ "python_full_version == '3.12.*'",
+ "python_full_version < '3.12'",
+]
+
+[[package]]
+name = "aiohappyeyeballs"
+version = "2.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" },
+]
+
+[[package]]
+name = "aiohttp"
+version = "3.13.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohappyeyeballs" },
+ { name = "aiosignal" },
+ { name = "attrs" },
+ { name = "frozenlist" },
+ { name = "multidict" },
+ { name = "propcache" },
+ { name = "yarl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" },
+ { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" },
+ { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" },
+ { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" },
+ { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" },
+ { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" },
+ { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" },
+ { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" },
+ { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" },
+ { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
+ { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
+ { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
+ { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
+ { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
+ { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
+ { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
+ { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
+ { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
+ { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
+ { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
+ { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
+ { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
+ { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
+ { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
+ { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
+ { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
+ { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
+]
+
+[[package]]
+name = "aiosignal"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "frozenlist" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "anthropic"
+version = "0.76.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "distro" },
+ { name = "docstring-parser" },
+ { name = "httpx" },
+ { name = "jiter" },
+ { name = "pydantic" },
+ { name = "sniffio" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6e/be/d11abafaa15d6304826438170f7574d750218f49a106c54424a40cef4494/anthropic-0.76.0.tar.gz", hash = "sha256:e0cae6a368986d5cf6df743dfbb1b9519e6a9eee9c6c942ad8121c0b34416ffe", size = 495483, upload-time = "2026-01-13T18:41:14.908Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/70/7b0fd9c1a738f59d3babe2b4212031c34ab7d0fda4ffef15b58a55c5bcea/anthropic-0.76.0-py3-none-any.whl", hash = "sha256:81efa3113901192af2f0fe977d3ec73fdadb1e691586306c4256cd6d5ccc331c", size = 390309, upload-time = "2026-01-13T18:41:13.483Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.12.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
+]
+
+[[package]]
+name = "astroid"
+version = "3.2.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9e/53/1067e1113ecaf58312357f2cd93063674924119d80d173adc3f6f2387aa2/astroid-3.2.4.tar.gz", hash = "sha256:0e14202810b30da1b735827f78f5157be2bbd4a7a59b7707ca0bfc2fb4c0063a", size = 397576, upload-time = "2024-07-20T12:57:43.26Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/80/96/b32bbbb46170a1c8b8b1f28c794202e25cfe743565e9d3469b8eb1e0cc05/astroid-3.2.4-py3-none-any.whl", hash = "sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25", size = 276348, upload-time = "2024-07-20T12:57:40.886Z" },
+]
+
+[[package]]
+name = "asttokens"
+version = "3.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" },
+]
+
+[[package]]
+name = "async-lru"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ef/c3/bbf34f15ea88dfb649ab2c40f9d75081784a50573a9ea431563cab64adb8/async_lru-2.1.0.tar.gz", hash = "sha256:9eeb2fecd3fe42cc8a787fc32ead53a3a7158cc43d039c3c55ab3e4e5b2a80ed", size = 12041, upload-time = "2026-01-17T22:52:18.931Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2e/e9/eb6a5db5ac505d5d45715388e92bced7a5bb556facc4d0865d192823f2d2/async_lru-2.1.0-py3-none-any.whl", hash = "sha256:fa12dcf99a42ac1280bc16c634bbaf06883809790f6304d85cdab3f666f33a7e", size = 6933, upload-time = "2026-01-17T22:52:17.389Z" },
+]
+
+[[package]]
+name = "attrs"
+version = "25.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
+]
+
+[[package]]
+name = "backoff"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" },
+]
+
+[[package]]
+name = "black"
+version = "25.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "mypy-extensions" },
+ { name = "packaging" },
+ { name = "pathspec" },
+ { name = "platformdirs" },
+ { name = "pytokens" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c4/d9/07b458a3f1c525ac392b5edc6b191ff140b596f9d77092429417a54e249d/black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7", size = 659264, upload-time = "2025-12-08T01:40:52.501Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/60/ad/7ac0d0e1e0612788dbc48e62aef8a8e8feffac7eb3d787db4e43b8462fa8/black-25.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0cfa263e85caea2cff57d8f917f9f51adae8e20b610e2b23de35b5b11ce691a", size = 1877003, upload-time = "2025-12-08T01:43:29.967Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/dd/a237e9f565f3617a88b49284b59cbca2a4f56ebe68676c1aad0ce36a54a7/black-25.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a2f578ae20c19c50a382286ba78bfbeafdf788579b053d8e4980afb079ab9be", size = 1712639, upload-time = "2025-12-08T01:52:46.756Z" },
+ { url = "https://files.pythonhosted.org/packages/12/80/e187079df1ea4c12a0c63282ddd8b81d5107db6d642f7d7b75a6bcd6fc21/black-25.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e1b65634b0e471d07ff86ec338819e2ef860689859ef4501ab7ac290431f9b", size = 1758143, upload-time = "2025-12-08T01:45:29.137Z" },
+ { url = "https://files.pythonhosted.org/packages/93/b5/3096ccee4f29dc2c3aac57274326c4d2d929a77e629f695f544e159bfae4/black-25.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a3fa71e3b8dd9f7c6ac4d818345237dfb4175ed3bf37cd5a581dbc4c034f1ec5", size = 1420698, upload-time = "2025-12-08T01:45:53.379Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/39/f81c0ffbc25ffbe61c7d0385bf277e62ffc3e52f5ee668d7369d9854fadf/black-25.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:51e267458f7e650afed8445dc7edb3187143003d52a1b710c7321aef22aa9655", size = 1229317, upload-time = "2025-12-08T01:46:35.606Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/bd/26083f805115db17fda9877b3c7321d08c647df39d0df4c4ca8f8450593e/black-25.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31f96b7c98c1ddaeb07dc0f56c652e25bdedaac76d5b68a059d998b57c55594a", size = 1924178, upload-time = "2025-12-08T01:49:51.048Z" },
+ { url = "https://files.pythonhosted.org/packages/89/6b/ea00d6651561e2bdd9231c4177f4f2ae19cc13a0b0574f47602a7519b6ca/black-25.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:05dd459a19e218078a1f98178c13f861fe6a9a5f88fc969ca4d9b49eb1809783", size = 1742643, upload-time = "2025-12-08T01:49:59.09Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/f3/360fa4182e36e9875fabcf3a9717db9d27a8d11870f21cff97725c54f35b/black-25.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1f68c5eff61f226934be6b5b80296cf6939e5d2f0c2f7d543ea08b204bfaf59", size = 1800158, upload-time = "2025-12-08T01:44:27.301Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/08/2c64830cb6616278067e040acca21d4f79727b23077633953081c9445d61/black-25.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:274f940c147ddab4442d316b27f9e332ca586d39c85ecf59ebdea82cc9ee8892", size = 1426197, upload-time = "2025-12-08T01:45:51.198Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/60/a93f55fd9b9816b7432cf6842f0e3000fdd5b7869492a04b9011a133ee37/black-25.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:169506ba91ef21e2e0591563deda7f00030cb466e747c4b09cb0a9dae5db2f43", size = 1237266, upload-time = "2025-12-08T01:45:10.556Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/52/c551e36bc95495d2aa1a37d50566267aa47608c81a53f91daa809e03293f/black-25.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a05ddeb656534c3e27a05a29196c962877c83fa5503db89e68857d1161ad08a5", size = 1923809, upload-time = "2025-12-08T01:46:55.126Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/f7/aac9b014140ee56d247e707af8db0aae2e9efc28d4a8aba92d0abd7ae9d1/black-25.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ec77439ef3e34896995503865a85732c94396edcc739f302c5673a2315e1e7f", size = 1742384, upload-time = "2025-12-08T01:49:37.022Z" },
+ { url = "https://files.pythonhosted.org/packages/74/98/38aaa018b2ab06a863974c12b14a6266badc192b20603a81b738c47e902e/black-25.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e509c858adf63aa61d908061b52e580c40eae0dfa72415fa47ac01b12e29baf", size = 1798761, upload-time = "2025-12-08T01:46:05.386Z" },
+ { url = "https://files.pythonhosted.org/packages/16/3a/a8ac542125f61574a3f015b521ca83b47321ed19bb63fe6d7560f348bfe1/black-25.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:252678f07f5bac4ff0d0e9b261fbb029fa530cfa206d0a636a34ab445ef8ca9d", size = 1429180, upload-time = "2025-12-08T01:45:34.903Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/2d/bdc466a3db9145e946762d52cd55b1385509d9f9004fec1c97bdc8debbfb/black-25.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc5b1c09fe3c931ddd20ee548511c64ebf964ada7e6f0763d443947fd1c603ce", size = 1239350, upload-time = "2025-12-08T01:46:09.458Z" },
+ { url = "https://files.pythonhosted.org/packages/35/46/1d8f2542210c502e2ae1060b2e09e47af6a5e5963cb78e22ec1a11170b28/black-25.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5", size = 1917015, upload-time = "2025-12-08T01:53:27.987Z" },
+ { url = "https://files.pythonhosted.org/packages/41/37/68accadf977672beb8e2c64e080f568c74159c1aaa6414b4cd2aef2d7906/black-25.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f", size = 1741830, upload-time = "2025-12-08T01:54:36.861Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/76/03608a9d8f0faad47a3af3a3c8c53af3367f6c0dd2d23a84710456c7ac56/black-25.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f", size = 1791450, upload-time = "2025-12-08T01:44:52.581Z" },
+ { url = "https://files.pythonhosted.org/packages/06/99/b2a4bd7dfaea7964974f947e1c76d6886d65fe5d24f687df2d85406b2609/black-25.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83", size = 1452042, upload-time = "2025-12-08T01:46:13.188Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/7c/d9825de75ae5dd7795d007681b752275ea85a1c5d83269b4b9c754c2aaab/black-25.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b", size = 1267446, upload-time = "2025-12-08T01:46:14.497Z" },
+ { url = "https://files.pythonhosted.org/packages/68/11/21331aed19145a952ad28fca2756a1433ee9308079bd03bd898e903a2e53/black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", size = 206191, upload-time = "2025-12-08T01:40:50.963Z" },
+]
+
+[[package]]
+name = "boto3"
+version = "1.42.37"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "botocore" },
+ { name = "jmespath" },
+ { name = "s3transfer" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a9/ef/0d6ceb88ae2b3638b956190a431e4a8a3697d5769d4bbbede8efcccacaea/boto3-1.42.37.tar.gz", hash = "sha256:d8b6c52c86f3bf04f71a5a53e7fb4d1527592afebffa5170cf3ef7d70966e610", size = 112830, upload-time = "2026-01-28T20:38:43.339Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/a4/cd334f74498acc6ad42a69c48e8c495f6f721d8abe13f8ef0d4b862fb1c0/boto3-1.42.37-py3-none-any.whl", hash = "sha256:e1e38fd178ffc66cfbe9cb6838b8c460000c3eb741e5f40f57eb730780ef0ed4", size = 140604, upload-time = "2026-01-28T20:38:42.135Z" },
+]
+
+[[package]]
+name = "botocore"
+version = "1.42.37"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jmespath" },
+ { name = "python-dateutil" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d5/4d/94292e7686e64d2ede8dae7102bbb11a1474e407c830de4192f2518e6cff/botocore-1.42.37.tar.gz", hash = "sha256:3ec58eb98b0857f67a2ae6aa3ded51597e7335f7640be654e0e86da4f173b5b2", size = 14914621, upload-time = "2026-01-28T20:38:34.586Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/72/30/54042dd3ad8161964f8f47aa418785079bd8d2f17053c40d65bafb9f6eed/botocore-1.42.37-py3-none-any.whl", hash = "sha256:f13bb8b560a10714d96fb7b0c7f17828dfa6e6606a1ead8c01c6ebb8765acbd8", size = 14589390, upload-time = "2026-01-28T20:38:31.306Z" },
+]
+
+[[package]]
+name = "cachetools"
+version = "6.2.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/39/91/d9ae9a66b01102a18cd16db0cf4cd54187ffe10f0865cc80071a4104fbb3/cachetools-6.2.6.tar.gz", hash = "sha256:16c33e1f276b9a9c0b49ab5782d901e3ad3de0dd6da9bf9bcd29ac5672f2f9e6", size = 32363, upload-time = "2026-01-27T20:32:59.956Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" },
+]
+
+[[package]]
+name = "cattrs"
+version = "25.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6e/00/2432bb2d445b39b5407f0a90e01b9a271475eea7caf913d7a86bcb956385/cattrs-25.3.0.tar.gz", hash = "sha256:1ac88d9e5eda10436c4517e390a4142d88638fe682c436c93db7ce4a277b884a", size = 509321, upload-time = "2025-10-07T12:26:08.737Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d8/2b/a40e1488fdfa02d3f9a653a61a5935ea08b3c2225ee818db6a76c7ba9695/cattrs-25.3.0-py3-none-any.whl", hash = "sha256:9896e84e0a5bf723bc7b4b68f4481785367ce07a8a02e7e9ee6eb2819bc306ff", size = 70738, upload-time = "2025-10-07T12:26:06.603Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
+]
+
+[[package]]
+name = "cffi"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
+ { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
+ { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
+ { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
+ { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
+ { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
+ { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
+ { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
+ { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
+ { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
+ { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
+ { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
+ { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" },
+ { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
+ { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" },
+ { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" },
+ { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" },
+ { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" },
+ { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" },
+ { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" },
+ { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
+ { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
+ { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
+ { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
+ { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
+ { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
+ { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
+ { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
+ { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
+ { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
+ { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
+ { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
+ { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
+ { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
+ { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
+ { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
+ { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
+ { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "cryptography"
+version = "46.0.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" },
+ { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" },
+ { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" },
+ { url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" },
+ { url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" },
+ { url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" },
+ { url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" },
+ { url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" },
+ { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" },
+ { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" },
+ { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" },
+ { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" },
+ { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" },
+ { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" },
+ { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" },
+ { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" },
+ { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" },
+ { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" },
+ { url = "https://files.pythonhosted.org/packages/59/e0/f9c6c53e1f2a1c2507f00f2faba00f01d2f334b35b0fbfe5286715da2184/cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b", size = 3476316, upload-time = "2026-01-28T00:24:24.144Z" },
+ { url = "https://files.pythonhosted.org/packages/27/7a/f8d2d13227a9a1a9fe9c7442b057efecffa41f1e3c51d8622f26b9edbe8f/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da", size = 4216693, upload-time = "2026-01-28T00:24:25.758Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/de/3787054e8f7972658370198753835d9d680f6cd4a39df9f877b57f0dd69c/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80", size = 4382765, upload-time = "2026-01-28T00:24:27.577Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/5f/60e0afb019973ba6a0b322e86b3d61edf487a4f5597618a430a2a15f2d22/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822", size = 4216066, upload-time = "2026-01-28T00:24:29.056Z" },
+ { url = "https://files.pythonhosted.org/packages/81/8e/bf4a0de294f147fee66f879d9bae6f8e8d61515558e3d12785dd90eca0be/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947", size = 4382025, upload-time = "2026-01-28T00:24:30.681Z" },
+ { url = "https://files.pythonhosted.org/packages/79/f4/9ceb90cfd6a3847069b0b0b353fd3075dc69b49defc70182d8af0c4ca390/cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3", size = 3406043, upload-time = "2026-01-28T00:24:32.236Z" },
+]
+
+[[package]]
+name = "dill"
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
+]
+
+[[package]]
+name = "diskcache"
+version = "5.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" },
+]
+
+[[package]]
+name = "distro"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
+]
+
+[[package]]
+name = "docstring-parser"
+version = "0.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
+]
+
+[[package]]
+name = "eval-type-backport"
+version = "0.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/30/ea/8b0ac4469d4c347c6a385ff09dc3c048c2d021696664e26c7ee6791631b5/eval_type_backport-0.2.2.tar.gz", hash = "sha256:f0576b4cf01ebb5bd358d02314d31846af5e07678387486e2c798af0e7d849c1", size = 9079, upload-time = "2024-12-21T20:09:46.005Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ce/31/55cd413eaccd39125368be33c46de24a1f639f2e12349b0361b4678f3915/eval_type_backport-0.2.2-py3-none-any.whl", hash = "sha256:cb6ad7c393517f476f96d456d0412ea80f0a8cf96f6892834cd9340149111b0a", size = 5830, upload-time = "2024-12-21T20:09:44.175Z" },
+]
+
+[[package]]
+name = "executing"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
+]
+
+[[package]]
+name = "filelock"
+version = "3.20.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" },
+]
+
+[[package]]
+name = "frozenlist"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" },
+ { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" },
+ { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" },
+ { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" },
+ { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" },
+ { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" },
+ { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" },
+ { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },
+ { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" },
+ { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" },
+ { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" },
+ { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
+ { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" },
+ { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },
+ { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
+ { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
+ { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
+ { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
+ { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
+ { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
+ { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
+ { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
+ { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
+ { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
+ { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
+ { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
+ { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
+ { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
+]
+
+[[package]]
+name = "google-auth"
+version = "2.48.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+ { name = "pyasn1-modules" },
+ { name = "rsa" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" },
+]
+
+[package.optional-dependencies]
+requests = [
+ { name = "requests" },
+]
+
+[[package]]
+name = "google-genai"
+version = "1.60.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "distro" },
+ { name = "google-auth", extra = ["requests"] },
+ { name = "httpx" },
+ { name = "pydantic" },
+ { name = "requests" },
+ { name = "sniffio" },
+ { name = "tenacity" },
+ { name = "typing-extensions" },
+ { name = "websockets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0a/3f/a753be0dcee352b7d63bc6d1ba14a72591d63b6391dac0cdff7ac168c530/google_genai-1.60.0.tar.gz", hash = "sha256:9768061775fddfaecfefb0d6d7a6cabefb3952ebd246cd5f65247151c07d33d1", size = 487721, upload-time = "2026-01-21T22:17:30.398Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/31/e5/384b1f383917b5f0ae92e28f47bc27b16e3d26cd9bacb25e9f8ecab3c8fe/google_genai-1.60.0-py3-none-any.whl", hash = "sha256:967338378ffecebec19a8ed90cf8797b26818bacbefd7846a9280beb1099f7f3", size = 719431, upload-time = "2026-01-21T22:17:28.086Z" },
+]
+
+[[package]]
+name = "groq"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "distro" },
+ { name = "httpx" },
+ { name = "pydantic" },
+ { name = "sniffio" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3f/12/f4099a141677fcd2ed79dcc1fcec431e60c52e0e90c9c5d935f0ffaf8c0e/groq-1.0.0.tar.gz", hash = "sha256:66cb7bb729e6eb644daac7ce8efe945e99e4eb33657f733ee6f13059ef0c25a9", size = 146068, upload-time = "2025-12-17T23:34:23.115Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4a/88/3175759d2ef30406ea721f4d837bfa1ba4339fde3b81ba8c5640a96ed231/groq-1.0.0-py3-none-any.whl", hash = "sha256:6e22bf92ffad988f01d2d4df7729add66b8fd5dbfb2154b5bbf3af245b72c731", size = 138292, upload-time = "2025-12-17T23:34:21.957Z" },
+]
+
+[[package]]
+name = "grpclib"
+version = "0.4.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "h2" },
+ { name = "multidict" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5b/28/5a2c299ec82a876a252c5919aa895a6f1d1d35c96417c5ce4a4660dc3a80/grpclib-0.4.9.tar.gz", hash = "sha256:cc589c330fa81004c6400a52a566407574498cb5b055fa927013361e21466c46", size = 84798, upload-time = "2025-12-14T22:23:14.349Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/90/b0cbbd9efcc82816c58f31a34963071aa19fb792a212a5d9caf8e0fc3097/grpclib-0.4.9-py3-none-any.whl", hash = "sha256:7762ec1c8ed94dfad597475152dd35cbd11aecaaca2f243e29702435ca24cf0e", size = 77063, upload-time = "2025-12-14T22:23:13.224Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "h2"
+version = "4.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "hpack" },
+ { name = "hyperframe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
+]
+
+[[package]]
+name = "hpack"
+version = "4.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "hyperframe"
+version = "6.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
+]
+
+[[package]]
+name = "imbue-core"
+version = "0.0.9"
+source = { editable = "../imbue_core" }
+dependencies = [
+ { name = "anthropic" },
+ { name = "anyio" },
+ { name = "attrs" },
+ { name = "boto3" },
+ { name = "cachetools" },
+ { name = "cattrs" },
+ { name = "diskcache" },
+ { name = "google-genai" },
+ { name = "groq" },
+ { name = "grpclib" },
+ { name = "httpx" },
+ { name = "inline-snapshot" },
+ { name = "loguru" },
+ { name = "openai" },
+ { name = "pathspec" },
+ { name = "posthog" },
+ { name = "prometheus-client" },
+ { name = "pydantic" },
+ { name = "pydantic-settings" },
+ { name = "pygit2" },
+ { name = "pygments" },
+ { name = "pyhumps" },
+ { name = "pylint" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "pytest-mock" },
+ { name = "python-gitlab" },
+ { name = "sentry-sdk" },
+ { name = "syrupy" },
+ { name = "tblib" },
+ { name = "tenacity" },
+ { name = "tiktoken" },
+ { name = "together" },
+ { name = "toml" },
+ { name = "traceback-with-variables" },
+ { name = "typeid-python" },
+ { name = "yasoo" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "anthropic", specifier = "~=0.54" },
+ { name = "anyio" },
+ { name = "attrs" },
+ { name = "boto3", specifier = ">=1.38.27" },
+ { name = "cachetools" },
+ { name = "cattrs" },
+ { name = "diskcache", specifier = ">=5.6.3" },
+ { name = "google-genai", specifier = ">=1.26.0" },
+ { name = "groq", specifier = ">=0.18.0" },
+ { name = "grpclib", specifier = ">=0.4.7" },
+ { name = "httpx" },
+ { name = "inline-snapshot" },
+ { name = "loguru" },
+ { name = "openai", specifier = ">=1.79.0" },
+ { name = "pathspec" },
+ { name = "posthog", specifier = "==5.4.0" },
+ { name = "prometheus-client", specifier = ">=0.20.0" },
+ { name = "pydantic", specifier = ">=2.11.4" },
+ { name = "pydantic-settings" },
+ { name = "pygit2", specifier = ">=1.18.0" },
+ { name = "pygments", specifier = ">=2.0.0" },
+ { name = "pyhumps" },
+ { name = "pylint", specifier = "==3.2.6" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "pytest-mock" },
+ { name = "python-gitlab", specifier = ">=4.5.0" },
+ { name = "sentry-sdk" },
+ { name = "syrupy" },
+ { name = "tblib", specifier = "==2.0.0" },
+ { name = "tenacity", specifier = ">=8.2.2" },
+ { name = "tiktoken" },
+ { name = "together" },
+ { name = "toml" },
+ { name = "traceback-with-variables", specifier = ">=2.2.0" },
+ { name = "typeid-python" },
+ { name = "yasoo" },
+]
+
+[package.metadata.requires-dev]
+dev = [{ name = "moto", specifier = ">=4.1.12" }]
+
+[[package]]
+name = "imbue-tools"
+version = "0.1.0"
+source = { editable = "." }
+dependencies = [
+ { name = "anyio" },
+ { name = "async-lru" },
+ { name = "attrs" },
+ { name = "imbue-core" },
+ { name = "jinja2" },
+ { name = "libcst" },
+ { name = "loguru" },
+ { name = "psycopg", extra = ["binary"] },
+ { name = "pydantic" },
+ { name = "pydantic-settings" },
+ { name = "pygit2" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "python-gitlab" },
+ { name = "requests" },
+ { name = "syrupy" },
+]
+
+[package.optional-dependencies]
+test = [
+ { name = "imbue-verify" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "anyio" },
+ { name = "async-lru" },
+ { name = "attrs" },
+ { name = "imbue-core", editable = "../imbue_core" },
+ { name = "imbue-verify", marker = "extra == 'test'", editable = "../" },
+ { name = "jinja2" },
+ { name = "libcst" },
+ { name = "loguru" },
+ { name = "psycopg", extras = ["binary"] },
+ { name = "pydantic" },
+ { name = "pydantic-settings" },
+ { name = "pygit2" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "python-gitlab" },
+ { name = "requests" },
+ { name = "syrupy" },
+]
+provides-extras = ["test"]
+
+[[package]]
+name = "imbue-verify"
+version = "0.1.0"
+source = { editable = "../" }
+dependencies = [
+ { name = "aiohttp" },
+ { name = "click" },
+ { name = "imbue-core" },
+ { name = "imbue-tools" },
+ { name = "jinja2" },
+ { name = "loguru" },
+ { name = "pydantic" },
+ { name = "pygments" },
+ { name = "pytest" },
+ { name = "syrupy" },
+ { name = "together" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "aiohttp", specifier = ">=3.8.0" },
+ { name = "click" },
+ { name = "imbue-core", editable = "../imbue_core" },
+ { name = "imbue-tools", editable = "." },
+ { name = "jinja2" },
+ { name = "loguru" },
+ { name = "pydantic" },
+ { name = "pygments", specifier = ">=2.0.0" },
+ { name = "pytest" },
+ { name = "syrupy" },
+ { name = "together", specifier = ">=1.5.35" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "inline-snapshot"
+version = "0.31.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asttokens" },
+ { name = "executing" },
+ { name = "pytest" },
+ { name = "rich" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1c/b1/52b5ee59f73ed31d5fe21b10881bf2d121d07d54b23c0b6b74186792e620/inline_snapshot-0.31.1.tar.gz", hash = "sha256:4ea5ed70aa1d652713bbfd750606b94bd8a42483f7d3680433b3e92994495f64", size = 2606338, upload-time = "2025-11-07T07:36:18.932Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/52/945db420380efbda8c69a7a4a16c53df9d7ac50d8217286b9d41e5d825ff/inline_snapshot-0.31.1-py3-none-any.whl", hash = "sha256:7875a73c986a03388c7e758fb5cb8a43d2c3a20328aa1d851bfb4ed536c4496f", size = 71965, upload-time = "2025-11-07T07:36:16.836Z" },
+]
+
+[[package]]
+name = "isort"
+version = "5.13.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/87/f9/c1eb8635a24e87ade2efce21e3ce8cd6b8630bb685ddc9cdaca1349b2eb5/isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", size = 175303, upload-time = "2023-12-13T20:37:26.124Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/b3/8def84f539e7d2289a02f0524b944b15d7c75dab7628bedf1c4f0992029c/isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6", size = 92310, upload-time = "2023-12-13T20:37:23.244Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "jiter"
+version = "0.12.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/45/9d/e0660989c1370e25848bb4c52d061c71837239738ad937e83edca174c273/jiter-0.12.0.tar.gz", hash = "sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b", size = 168294, upload-time = "2025-11-09T20:49:23.302Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/f9/eaca4633486b527ebe7e681c431f529b63fe2709e7c5242fc0f43f77ce63/jiter-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d8f8a7e317190b2c2d60eb2e8aa835270b008139562d70fe732e1c0020ec53c9", size = 316435, upload-time = "2025-11-09T20:47:02.087Z" },
+ { url = "https://files.pythonhosted.org/packages/10/c1/40c9f7c22f5e6ff715f28113ebaba27ab85f9af2660ad6e1dd6425d14c19/jiter-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2218228a077e784c6c8f1a8e5d6b8cb1dea62ce25811c356364848554b2056cd", size = 320548, upload-time = "2025-11-09T20:47:03.409Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/1b/efbb68fe87e7711b00d2cfd1f26bb4bfc25a10539aefeaa7727329ffb9cb/jiter-0.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9354ccaa2982bf2188fd5f57f79f800ef622ec67beb8329903abf6b10da7d423", size = 351915, upload-time = "2025-11-09T20:47:05.171Z" },
+ { url = "https://files.pythonhosted.org/packages/15/2d/c06e659888c128ad1e838123d0638f0efad90cc30860cb5f74dd3f2fc0b3/jiter-0.12.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2607185ea89b4af9a604d4c7ec40e45d3ad03ee66998b031134bc510232bb7", size = 368966, upload-time = "2025-11-09T20:47:06.508Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/20/058db4ae5fb07cf6a4ab2e9b9294416f606d8e467fb74c2184b2a1eeacba/jiter-0.12.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a585a5e42d25f2e71db5f10b171f5e5ea641d3aa44f7df745aa965606111cc2", size = 482047, upload-time = "2025-11-09T20:47:08.382Z" },
+ { url = "https://files.pythonhosted.org/packages/49/bb/dc2b1c122275e1de2eb12905015d61e8316b2f888bdaac34221c301495d6/jiter-0.12.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd9e21d34edff5a663c631f850edcb786719c960ce887a5661e9c828a53a95d9", size = 380835, upload-time = "2025-11-09T20:47:09.81Z" },
+ { url = "https://files.pythonhosted.org/packages/23/7d/38f9cd337575349de16da575ee57ddb2d5a64d425c9367f5ef9e4612e32e/jiter-0.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a612534770470686cd5431478dc5a1b660eceb410abade6b1b74e320ca98de6", size = 364587, upload-time = "2025-11-09T20:47:11.529Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/a3/b13e8e61e70f0bb06085099c4e2462647f53cc2ca97614f7fedcaa2bb9f3/jiter-0.12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3985aea37d40a908f887b34d05111e0aae822943796ebf8338877fee2ab67725", size = 390492, upload-time = "2025-11-09T20:47:12.993Z" },
+ { url = "https://files.pythonhosted.org/packages/07/71/e0d11422ed027e21422f7bc1883c61deba2d9752b720538430c1deadfbca/jiter-0.12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b1207af186495f48f72529f8d86671903c8c10127cac6381b11dddc4aaa52df6", size = 522046, upload-time = "2025-11-09T20:47:14.6Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/59/b968a9aa7102a8375dbbdfbd2aeebe563c7e5dddf0f47c9ef1588a97e224/jiter-0.12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef2fb241de583934c9915a33120ecc06d94aa3381a134570f59eed784e87001e", size = 513392, upload-time = "2025-11-09T20:47:16.011Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/e4/7df62002499080dbd61b505c5cb351aa09e9959d176cac2aa8da6f93b13b/jiter-0.12.0-cp311-cp311-win32.whl", hash = "sha256:453b6035672fecce8007465896a25b28a6b59cfe8fbc974b2563a92f5a92a67c", size = 206096, upload-time = "2025-11-09T20:47:17.344Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/60/1032b30ae0572196b0de0e87dce3b6c26a1eff71aad5fe43dee3082d32e0/jiter-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:ca264b9603973c2ad9435c71a8ec8b49f8f715ab5ba421c85a51cde9887e421f", size = 204899, upload-time = "2025-11-09T20:47:19.365Z" },
+ { url = "https://files.pythonhosted.org/packages/49/d5/c145e526fccdb834063fb45c071df78b0cc426bbaf6de38b0781f45d956f/jiter-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:cb00ef392e7d684f2754598c02c409f376ddcef857aae796d559e6cacc2d78a5", size = 188070, upload-time = "2025-11-09T20:47:20.75Z" },
+ { url = "https://files.pythonhosted.org/packages/92/c9/5b9f7b4983f1b542c64e84165075335e8a236fa9e2ea03a0c79780062be8/jiter-0.12.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:305e061fa82f4680607a775b2e8e0bcb071cd2205ac38e6ef48c8dd5ebe1cf37", size = 314449, upload-time = "2025-11-09T20:47:22.999Z" },
+ { url = "https://files.pythonhosted.org/packages/98/6e/e8efa0e78de00db0aee82c0cf9e8b3f2027efd7f8a71f859d8f4be8e98ef/jiter-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c1860627048e302a528333c9307c818c547f214d8659b0705d2195e1a94b274", size = 319855, upload-time = "2025-11-09T20:47:24.779Z" },
+ { url = "https://files.pythonhosted.org/packages/20/26/894cd88e60b5d58af53bec5c6759d1292bd0b37a8b5f60f07abf7a63ae5f/jiter-0.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df37577a4f8408f7e0ec3205d2a8f87672af8f17008358063a4d6425b6081ce3", size = 350171, upload-time = "2025-11-09T20:47:26.469Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/27/a7b818b9979ac31b3763d25f3653ec3a954044d5e9f5d87f2f247d679fd1/jiter-0.12.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fdd787356c1c13a4f40b43c2156276ef7a71eb487d98472476476d803fb2cf", size = 365590, upload-time = "2025-11-09T20:47:27.918Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/7e/e46195801a97673a83746170b17984aa8ac4a455746354516d02ca5541b4/jiter-0.12.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1eb5db8d9c65b112aacf14fcd0faae9913d07a8afea5ed06ccdd12b724e966a1", size = 479462, upload-time = "2025-11-09T20:47:29.654Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/75/f833bfb009ab4bd11b1c9406d333e3b4357709ed0570bb48c7c06d78c7dd/jiter-0.12.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73c568cc27c473f82480abc15d1301adf333a7ea4f2e813d6a2c7d8b6ba8d0df", size = 378983, upload-time = "2025-11-09T20:47:31.026Z" },
+ { url = "https://files.pythonhosted.org/packages/71/b3/7a69d77943cc837d30165643db753471aff5df39692d598da880a6e51c24/jiter-0.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4321e8a3d868919bcb1abb1db550d41f2b5b326f72df29e53b2df8b006eb9403", size = 361328, upload-time = "2025-11-09T20:47:33.286Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/ac/a78f90caf48d65ba70d8c6efc6f23150bc39dc3389d65bbec2a95c7bc628/jiter-0.12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0a51bad79f8cc9cac2b4b705039f814049142e0050f30d91695a2d9a6611f126", size = 386740, upload-time = "2025-11-09T20:47:34.703Z" },
+ { url = "https://files.pythonhosted.org/packages/39/b6/5d31c2cc8e1b6a6bcf3c5721e4ca0a3633d1ab4754b09bc7084f6c4f5327/jiter-0.12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2a67b678f6a5f1dd6c36d642d7db83e456bc8b104788262aaefc11a22339f5a9", size = 520875, upload-time = "2025-11-09T20:47:36.058Z" },
+ { url = "https://files.pythonhosted.org/packages/30/b5/4df540fae4e9f68c54b8dab004bd8c943a752f0b00efd6e7d64aa3850339/jiter-0.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efe1a211fe1fd14762adea941e3cfd6c611a136e28da6c39272dbb7a1bbe6a86", size = 511457, upload-time = "2025-11-09T20:47:37.932Z" },
+ { url = "https://files.pythonhosted.org/packages/07/65/86b74010e450a1a77b2c1aabb91d4a91dd3cd5afce99f34d75fd1ac64b19/jiter-0.12.0-cp312-cp312-win32.whl", hash = "sha256:d779d97c834b4278276ec703dc3fc1735fca50af63eb7262f05bdb4e62203d44", size = 204546, upload-time = "2025-11-09T20:47:40.47Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/c7/6659f537f9562d963488e3e55573498a442503ced01f7e169e96a6110383/jiter-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:e8269062060212b373316fe69236096aaf4c49022d267c6736eebd66bbbc60bb", size = 205196, upload-time = "2025-11-09T20:47:41.794Z" },
+ { url = "https://files.pythonhosted.org/packages/21/f4/935304f5169edadfec7f9c01eacbce4c90bb9a82035ac1de1f3bd2d40be6/jiter-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:06cb970936c65de926d648af0ed3d21857f026b1cf5525cb2947aa5e01e05789", size = 186100, upload-time = "2025-11-09T20:47:43.007Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/a6/97209693b177716e22576ee1161674d1d58029eb178e01866a0422b69224/jiter-0.12.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6cc49d5130a14b732e0612bc76ae8db3b49898732223ef8b7599aa8d9810683e", size = 313658, upload-time = "2025-11-09T20:47:44.424Z" },
+ { url = "https://files.pythonhosted.org/packages/06/4d/125c5c1537c7d8ee73ad3d530a442d6c619714b95027143f1b61c0b4dfe0/jiter-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37f27a32ce36364d2fa4f7fdc507279db604d27d239ea2e044c8f148410defe1", size = 318605, upload-time = "2025-11-09T20:47:45.973Z" },
+ { url = "https://files.pythonhosted.org/packages/99/bf/a840b89847885064c41a5f52de6e312e91fa84a520848ee56c97e4fa0205/jiter-0.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbc0944aa3d4b4773e348cda635252824a78f4ba44328e042ef1ff3f6080d1cf", size = 349803, upload-time = "2025-11-09T20:47:47.535Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/88/e63441c28e0db50e305ae23e19c1d8fae012d78ed55365da392c1f34b09c/jiter-0.12.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da25c62d4ee1ffbacb97fac6dfe4dcd6759ebdc9015991e92a6eae5816287f44", size = 365120, upload-time = "2025-11-09T20:47:49.284Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/7c/49b02714af4343970eb8aca63396bc1c82fa01197dbb1e9b0d274b550d4e/jiter-0.12.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:048485c654b838140b007390b8182ba9774621103bd4d77c9c3f6f117474ba45", size = 479918, upload-time = "2025-11-09T20:47:50.807Z" },
+ { url = "https://files.pythonhosted.org/packages/69/ba/0a809817fdd5a1db80490b9150645f3aae16afad166960bcd562be194f3b/jiter-0.12.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:635e737fbb7315bef0037c19b88b799143d2d7d3507e61a76751025226b3ac87", size = 379008, upload-time = "2025-11-09T20:47:52.211Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/c3/c9fc0232e736c8877d9e6d83d6eeb0ba4e90c6c073835cc2e8f73fdeef51/jiter-0.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e017c417b1ebda911bd13b1e40612704b1f5420e30695112efdbed8a4b389ed", size = 361785, upload-time = "2025-11-09T20:47:53.512Z" },
+ { url = "https://files.pythonhosted.org/packages/96/61/61f69b7e442e97ca6cd53086ddc1cf59fb830549bc72c0a293713a60c525/jiter-0.12.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:89b0bfb8b2bf2351fba36bb211ef8bfceba73ef58e7f0c68fb67b5a2795ca2f9", size = 386108, upload-time = "2025-11-09T20:47:54.893Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/2e/76bb3332f28550c8f1eba3bf6e5efe211efda0ddbbaf24976bc7078d42a5/jiter-0.12.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:f5aa5427a629a824a543672778c9ce0c5e556550d1569bb6ea28a85015287626", size = 519937, upload-time = "2025-11-09T20:47:56.253Z" },
+ { url = "https://files.pythonhosted.org/packages/84/d6/fa96efa87dc8bff2094fb947f51f66368fa56d8d4fc9e77b25d7fbb23375/jiter-0.12.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed53b3d6acbcb0fd0b90f20c7cb3b24c357fe82a3518934d4edfa8c6898e498c", size = 510853, upload-time = "2025-11-09T20:47:58.32Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/28/93f67fdb4d5904a708119a6ab58a8f1ec226ff10a94a282e0215402a8462/jiter-0.12.0-cp313-cp313-win32.whl", hash = "sha256:4747de73d6b8c78f2e253a2787930f4fffc68da7fa319739f57437f95963c4de", size = 204699, upload-time = "2025-11-09T20:47:59.686Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/1f/30b0eb087045a0abe2a5c9c0c0c8da110875a1d3be83afd4a9a4e548be3c/jiter-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:e25012eb0c456fcc13354255d0338cd5397cce26c77b2832b3c4e2e255ea5d9a", size = 204258, upload-time = "2025-11-09T20:48:01.01Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/f4/2b4daf99b96bce6fc47971890b14b2a36aef88d7beb9f057fafa032c6141/jiter-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:c97b92c54fe6110138c872add030a1f99aea2401ddcdaa21edf74705a646dd60", size = 185503, upload-time = "2025-11-09T20:48:02.35Z" },
+ { url = "https://files.pythonhosted.org/packages/39/ca/67bb15a7061d6fe20b9b2a2fd783e296a1e0f93468252c093481a2f00efa/jiter-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:53839b35a38f56b8be26a7851a48b89bc47e5d88e900929df10ed93b95fea3d6", size = 317965, upload-time = "2025-11-09T20:48:03.783Z" },
+ { url = "https://files.pythonhosted.org/packages/18/af/1788031cd22e29c3b14bc6ca80b16a39a0b10e611367ffd480c06a259831/jiter-0.12.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94f669548e55c91ab47fef8bddd9c954dab1938644e715ea49d7e117015110a4", size = 345831, upload-time = "2025-11-09T20:48:05.55Z" },
+ { url = "https://files.pythonhosted.org/packages/05/17/710bf8472d1dff0d3caf4ced6031060091c1320f84ee7d5dcbed1f352417/jiter-0.12.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:351d54f2b09a41600ffea43d081522d792e81dcfb915f6d2d242744c1cc48beb", size = 361272, upload-time = "2025-11-09T20:48:06.951Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/f1/1dcc4618b59761fef92d10bcbb0b038b5160be653b003651566a185f1a5c/jiter-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2a5e90604620f94bf62264e7c2c038704d38217b7465b863896c6d7c902b06c7", size = 204604, upload-time = "2025-11-09T20:48:08.328Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/32/63cb1d9f1c5c6632a783c0052cde9ef7ba82688f7065e2f0d5f10a7e3edb/jiter-0.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:88ef757017e78d2860f96250f9393b7b577b06a956ad102c29c8237554380db3", size = 185628, upload-time = "2025-11-09T20:48:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/99/45c9f0dbe4a1416b2b9a8a6d1236459540f43d7fb8883cff769a8db0612d/jiter-0.12.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c46d927acd09c67a9fb1416df45c5a04c27e83aae969267e98fba35b74e99525", size = 312478, upload-time = "2025-11-09T20:48:10.898Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/a7/54ae75613ba9e0f55fcb0bc5d1f807823b5167cc944e9333ff322e9f07dd/jiter-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:774ff60b27a84a85b27b88cd5583899c59940bcc126caca97eb2a9df6aa00c49", size = 318706, upload-time = "2025-11-09T20:48:12.266Z" },
+ { url = "https://files.pythonhosted.org/packages/59/31/2aa241ad2c10774baf6c37f8b8e1f39c07db358f1329f4eb40eba179c2a2/jiter-0.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5433fab222fb072237df3f637d01b81f040a07dcac1cb4a5c75c7aa9ed0bef1", size = 351894, upload-time = "2025-11-09T20:48:13.673Z" },
+ { url = "https://files.pythonhosted.org/packages/54/4f/0f2759522719133a9042781b18cc94e335b6d290f5e2d3e6899d6af933e3/jiter-0.12.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8c593c6e71c07866ec6bfb790e202a833eeec885022296aff6b9e0b92d6a70e", size = 365714, upload-time = "2025-11-09T20:48:15.083Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/6f/806b895f476582c62a2f52c453151edd8a0fde5411b0497baaa41018e878/jiter-0.12.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90d32894d4c6877a87ae00c6b915b609406819dce8bc0d4e962e4de2784e567e", size = 478989, upload-time = "2025-11-09T20:48:16.706Z" },
+ { url = "https://files.pythonhosted.org/packages/86/6c/012d894dc6e1033acd8db2b8346add33e413ec1c7c002598915278a37f79/jiter-0.12.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:798e46eed9eb10c3adbbacbd3bdb5ecd4cf7064e453d00dbef08802dae6937ff", size = 378615, upload-time = "2025-11-09T20:48:18.614Z" },
+ { url = "https://files.pythonhosted.org/packages/87/30/d718d599f6700163e28e2c71c0bbaf6dace692e7df2592fd793ac9276717/jiter-0.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3f1368f0a6719ea80013a4eb90ba72e75d7ea67cfc7846db2ca504f3df0169a", size = 364745, upload-time = "2025-11-09T20:48:20.117Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/85/315b45ce4b6ddc7d7fceca24068543b02bdc8782942f4ee49d652e2cc89f/jiter-0.12.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65f04a9d0b4406f7e51279710b27484af411896246200e461d80d3ba0caa901a", size = 386502, upload-time = "2025-11-09T20:48:21.543Z" },
+ { url = "https://files.pythonhosted.org/packages/74/0b/ce0434fb40c5b24b368fe81b17074d2840748b4952256bab451b72290a49/jiter-0.12.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:fd990541982a24281d12b67a335e44f117e4c6cbad3c3b75c7dea68bf4ce3a67", size = 519845, upload-time = "2025-11-09T20:48:22.964Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/a3/7a7a4488ba052767846b9c916d208b3ed114e3eb670ee984e4c565b9cf0d/jiter-0.12.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b111b0e9152fa7df870ecaebb0bd30240d9f7fff1f2003bcb4ed0f519941820b", size = 510701, upload-time = "2025-11-09T20:48:24.483Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/16/052ffbf9d0467b70af24e30f91e0579e13ded0c17bb4a8eb2aed3cb60131/jiter-0.12.0-cp314-cp314-win32.whl", hash = "sha256:a78befb9cc0a45b5a5a0d537b06f8544c2ebb60d19d02c41ff15da28a9e22d42", size = 205029, upload-time = "2025-11-09T20:48:25.749Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/18/3cf1f3f0ccc789f76b9a754bdb7a6977e5d1d671ee97a9e14f7eb728d80e/jiter-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:e1fe01c082f6aafbe5c8faf0ff074f38dfb911d53f07ec333ca03f8f6226debf", size = 204960, upload-time = "2025-11-09T20:48:27.415Z" },
+ { url = "https://files.pythonhosted.org/packages/02/68/736821e52ecfdeeb0f024b8ab01b5a229f6b9293bbdb444c27efade50b0f/jiter-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:d72f3b5a432a4c546ea4bedc84cce0c3404874f1d1676260b9c7f048a9855451", size = 185529, upload-time = "2025-11-09T20:48:29.125Z" },
+ { url = "https://files.pythonhosted.org/packages/30/61/12ed8ee7a643cce29ac97c2281f9ce3956eb76b037e88d290f4ed0d41480/jiter-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e6ded41aeba3603f9728ed2b6196e4df875348ab97b28fc8afff115ed42ba7a7", size = 318974, upload-time = "2025-11-09T20:48:30.87Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/c6/f3041ede6d0ed5e0e79ff0de4c8f14f401bbf196f2ef3971cdbe5fd08d1d/jiter-0.12.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a947920902420a6ada6ad51892082521978e9dd44a802663b001436e4b771684", size = 345932, upload-time = "2025-11-09T20:48:32.658Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/5d/4d94835889edd01ad0e2dbfc05f7bdfaed46292e7b504a6ac7839aa00edb/jiter-0.12.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:add5e227e0554d3a52cf390a7635edaffdf4f8fce4fdbcef3cc2055bb396a30c", size = 367243, upload-time = "2025-11-09T20:48:34.093Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/76/0051b0ac2816253a99d27baf3dda198663aff882fa6ea7deeb94046da24e/jiter-0.12.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f9b1cda8fcb736250d7e8711d4580ebf004a46771432be0ae4796944b5dfa5d", size = 479315, upload-time = "2025-11-09T20:48:35.507Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ae/83f793acd68e5cb24e483f44f482a1a15601848b9b6f199dacb970098f77/jiter-0.12.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:deeb12a2223fe0135c7ff1356a143d57f95bbf1f4a66584f1fc74df21d86b993", size = 380714, upload-time = "2025-11-09T20:48:40.014Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/4808a88338ad2c228b1126b93fcd8ba145e919e886fe910d578230dabe3b/jiter-0.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c596cc0f4cb574877550ce4ecd51f8037469146addd676d7c1a30ebe6391923f", size = 365168, upload-time = "2025-11-09T20:48:41.462Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/d4/04619a9e8095b42aef436b5aeb4c0282b4ff1b27d1db1508df9f5dc82750/jiter-0.12.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ab4c823b216a4aeab3fdbf579c5843165756bd9ad87cc6b1c65919c4715f783", size = 387893, upload-time = "2025-11-09T20:48:42.921Z" },
+ { url = "https://files.pythonhosted.org/packages/17/ea/d3c7e62e4546fdc39197fa4a4315a563a89b95b6d54c0d25373842a59cbe/jiter-0.12.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e427eee51149edf962203ff8db75a7514ab89be5cb623fb9cea1f20b54f1107b", size = 520828, upload-time = "2025-11-09T20:48:44.278Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/0b/c6d3562a03fd767e31cb119d9041ea7958c3c80cb3d753eafb19b3b18349/jiter-0.12.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:edb868841f84c111255ba5e80339d386d937ec1fdce419518ce1bd9370fac5b6", size = 511009, upload-time = "2025-11-09T20:48:45.726Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/51/2cb4468b3448a8385ebcd15059d325c9ce67df4e2758d133ab9442b19834/jiter-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8bbcfe2791dfdb7c5e48baf646d37a6a3dcb5a97a032017741dea9f817dca183", size = 205110, upload-time = "2025-11-09T20:48:47.033Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/c5/ae5ec83dec9c2d1af805fd5fe8f74ebded9c8670c5210ec7820ce0dbeb1e/jiter-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2fa940963bf02e1d8226027ef461e36af472dea85d36054ff835aeed944dd873", size = 205223, upload-time = "2025-11-09T20:48:49.076Z" },
+ { url = "https://files.pythonhosted.org/packages/97/9a/3c5391907277f0e55195550cf3fa8e293ae9ee0c00fb402fec1e38c0c82f/jiter-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:506c9708dd29b27288f9f8f1140c3cb0e3d8ddb045956d7757b1fa0e0f39a473", size = 185564, upload-time = "2025-11-09T20:48:50.376Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/54/5339ef1ecaa881c6948669956567a64d2670941925f245c434f494ffb0e5/jiter-0.12.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:4739a4657179ebf08f85914ce50332495811004cc1747852e8b2041ed2aab9b8", size = 311144, upload-time = "2025-11-09T20:49:10.503Z" },
+ { url = "https://files.pythonhosted.org/packages/27/74/3446c652bffbd5e81ab354e388b1b5fc1d20daac34ee0ed11ff096b1b01a/jiter-0.12.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:41da8def934bf7bec16cb24bd33c0ca62126d2d45d81d17b864bd5ad721393c3", size = 305877, upload-time = "2025-11-09T20:49:12.269Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/f4/ed76ef9043450f57aac2d4fbeb27175aa0eb9c38f833be6ef6379b3b9a86/jiter-0.12.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c44ee814f499c082e69872d426b624987dbc5943ab06e9bbaa4f81989fdb79e", size = 340419, upload-time = "2025-11-09T20:49:13.803Z" },
+ { url = "https://files.pythonhosted.org/packages/21/01/857d4608f5edb0664aa791a3d45702e1a5bcfff9934da74035e7b9803846/jiter-0.12.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd2097de91cf03eaa27b3cbdb969addf83f0179c6afc41bbc4513705e013c65d", size = 347212, upload-time = "2025-11-09T20:49:15.643Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/f5/12efb8ada5f5c9edc1d4555fe383c1fb2eac05ac5859258a72d61981d999/jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:e8547883d7b96ef2e5fe22b88f8a4c8725a56e7f4abafff20fd5272d634c7ecb", size = 309974, upload-time = "2025-11-09T20:49:17.187Z" },
+ { url = "https://files.pythonhosted.org/packages/85/15/d6eb3b770f6a0d332675141ab3962fd4a7c270ede3515d9f3583e1d28276/jiter-0.12.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:89163163c0934854a668ed783a2546a0617f71706a2551a4a0666d91ab365d6b", size = 304233, upload-time = "2025-11-09T20:49:18.734Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/3e/e7e06743294eea2cf02ced6aa0ff2ad237367394e37a0e2b4a1108c67a36/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d96b264ab7d34bbb2312dedc47ce07cd53f06835eacbc16dde3761f47c3a9e7f", size = 338537, upload-time = "2025-11-09T20:49:20.317Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/9c/6753e6522b8d0ef07d3a3d239426669e984fb0eba15a315cdbc1253904e4/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24e864cb30ab82311c6425655b0cdab0a98c5d973b065c66a3f020740c2324c", size = 346110, upload-time = "2025-11-09T20:49:21.817Z" },
+]
+
+[[package]]
+name = "jmespath"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
+]
+
+[[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/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 = "loguru"
+version = "0.7.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "win32-setctime", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdurl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
+ { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
+ { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
+ { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
+ { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
+ { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
+ { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
+ { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
+ { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
+ { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
+ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
+ { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
+ { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
+ { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
+ { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
+ { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
+ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+]
+
+[[package]]
+name = "mccabe"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+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 = "more-itertools"
+version = "10.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" },
+]
+
+[[package]]
+name = "multidict"
+version = "6.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" },
+ { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" },
+ { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" },
+ { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" },
+ { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" },
+ { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" },
+ { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" },
+ { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" },
+ { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" },
+ { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" },
+ { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" },
+ { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" },
+ { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" },
+ { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" },
+ { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" },
+ { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" },
+ { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" },
+ { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" },
+ { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" },
+ { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },
+ { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },
+ { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },
+ { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },
+ { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },
+ { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
+ { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },
+ { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },
+ { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },
+ { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },
+ { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
+ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a5/34/2b1bc18424f3ad9af577f6ce23600319968a70575bd7db31ce66731bbef9/numpy-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0cce2a669e3c8ba02ee563c7835f92c153cf02edff1ae05e1823f1dde21b16a5", size = 16944563, upload-time = "2026-01-10T06:42:14.615Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/57/26e5f97d075aef3794045a6ca9eada6a4ed70eb9a40e7a4a93f9ac80d704/numpy-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:899d2c18024984814ac7e83f8f49d8e8180e2fbe1b2e252f2e7f1d06bea92425", size = 12645658, upload-time = "2026-01-10T06:42:17.298Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/ba/80fc0b1e3cb2fd5c6143f00f42eb67762aa043eaa05ca924ecc3222a7849/numpy-2.4.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:09aa8a87e45b55a1c2c205d42e2808849ece5c484b2aab11fecabec3841cafba", size = 5474132, upload-time = "2026-01-10T06:42:19.637Z" },
+ { url = "https://files.pythonhosted.org/packages/40/ae/0a5b9a397f0e865ec171187c78d9b57e5588afc439a04ba9cab1ebb2c945/numpy-2.4.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:edee228f76ee2dab4579fad6f51f6a305de09d444280109e0f75df247ff21501", size = 6804159, upload-time = "2026-01-10T06:42:21.44Z" },
+ { url = "https://files.pythonhosted.org/packages/86/9c/841c15e691c7085caa6fd162f063eff494099c8327aeccd509d1ab1e36ab/numpy-2.4.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a92f227dbcdc9e4c3e193add1a189a9909947d4f8504c576f4a732fd0b54240a", size = 14708058, upload-time = "2026-01-10T06:42:23.546Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/9d/7862db06743f489e6a502a3b93136d73aea27d97b2cf91504f70a27501d6/numpy-2.4.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:538bf4ec353709c765ff75ae616c34d3c3dca1a68312727e8f2676ea644f8509", size = 16651501, upload-time = "2026-01-10T06:42:25.909Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/9c/6fc34ebcbd4015c6e5f0c0ce38264010ce8a546cb6beacb457b84a75dfc8/numpy-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ac08c63cb7779b85e9d5318e6c3518b424bc1f364ac4cb2c6136f12e5ff2dccc", size = 16492627, upload-time = "2026-01-10T06:42:28.938Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/63/2494a8597502dacda439f61b3c0db4da59928150e62be0e99395c3ad23c5/numpy-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f9c360ecef085e5841c539a9a12b883dff005fbd7ce46722f5e9cef52634d82", size = 18585052, upload-time = "2026-01-10T06:42:31.312Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/93/098e1162ae7522fc9b618d6272b77404c4656c72432ecee3abc029aa3de0/numpy-2.4.1-cp311-cp311-win32.whl", hash = "sha256:0f118ce6b972080ba0758c6087c3617b5ba243d806268623dc34216d69099ba0", size = 6236575, upload-time = "2026-01-10T06:42:33.872Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/de/f5e79650d23d9e12f38a7bc6b03ea0835b9575494f8ec94c11c6e773b1b1/numpy-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:18e14c4d09d55eef39a6ab5b08406e84bc6869c1e34eef45564804f90b7e0574", size = 12604479, upload-time = "2026-01-10T06:42:35.778Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/65/e1097a7047cff12ce3369bd003811516b20ba1078dbdec135e1cd7c16c56/numpy-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:6461de5113088b399d655d45c3897fa188766415d0f568f175ab071c8873bd73", size = 10578325, upload-time = "2026-01-10T06:42:38.518Z" },
+ { url = "https://files.pythonhosted.org/packages/78/7f/ec53e32bf10c813604edf07a3682616bd931d026fcde7b6d13195dfb684a/numpy-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2", size = 16656888, upload-time = "2026-01-10T06:42:40.913Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/e0/1f9585d7dae8f14864e948fd7fa86c6cb72dee2676ca2748e63b1c5acfe0/numpy-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8", size = 12373956, upload-time = "2026-01-10T06:42:43.091Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/43/9762e88909ff2326f5e7536fa8cb3c49fb03a7d92705f23e6e7f553d9cb3/numpy-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a", size = 5202567, upload-time = "2026-01-10T06:42:45.107Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/ee/34b7930eb61e79feb4478800a4b95b46566969d837546aa7c034c742ef98/numpy-2.4.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0", size = 6549459, upload-time = "2026-01-10T06:42:48.152Z" },
+ { url = "https://files.pythonhosted.org/packages/79/e3/5f115fae982565771be994867c89bcd8d7208dbfe9469185497d70de5ddf/numpy-2.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c", size = 14404859, upload-time = "2026-01-10T06:42:49.947Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/7d/9c8a781c88933725445a859cac5d01b5871588a15969ee6aeb618ba99eee/numpy-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02", size = 16371419, upload-time = "2026-01-10T06:42:52.409Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/d2/8aa084818554543f17cf4162c42f162acbd3bb42688aefdba6628a859f77/numpy-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162", size = 16182131, upload-time = "2026-01-10T06:42:54.694Z" },
+ { url = "https://files.pythonhosted.org/packages/60/db/0425216684297c58a8df35f3284ef56ec4a043e6d283f8a59c53562caf1b/numpy-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9", size = 18295342, upload-time = "2026-01-10T06:42:56.991Z" },
+ { url = "https://files.pythonhosted.org/packages/31/4c/14cb9d86240bd8c386c881bafbe43f001284b7cce3bc01623ac9475da163/numpy-2.4.1-cp312-cp312-win32.whl", hash = "sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f", size = 5959015, upload-time = "2026-01-10T06:42:59.631Z" },
+ { url = "https://files.pythonhosted.org/packages/51/cf/52a703dbeb0c65807540d29699fef5fda073434ff61846a564d5c296420f/numpy-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87", size = 12310730, upload-time = "2026-01-10T06:43:01.627Z" },
+ { url = "https://files.pythonhosted.org/packages/69/80/a828b2d0ade5e74a9fe0f4e0a17c30fdc26232ad2bc8c9f8b3197cf7cf18/numpy-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8", size = 10312166, upload-time = "2026-01-10T06:43:03.673Z" },
+ { url = "https://files.pythonhosted.org/packages/04/68/732d4b7811c00775f3bd522a21e8dd5a23f77eb11acdeb663e4a4ebf0ef4/numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b", size = 16652495, upload-time = "2026-01-10T06:43:06.283Z" },
+ { url = "https://files.pythonhosted.org/packages/20/ca/857722353421a27f1465652b2c66813eeeccea9d76d5f7b74b99f298e60e/numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f", size = 12368657, upload-time = "2026-01-10T06:43:09.094Z" },
+ { url = "https://files.pythonhosted.org/packages/81/0d/2377c917513449cc6240031a79d30eb9a163d32a91e79e0da47c43f2c0c8/numpy-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9", size = 5197256, upload-time = "2026-01-10T06:43:13.634Z" },
+ { url = "https://files.pythonhosted.org/packages/17/39/569452228de3f5de9064ac75137082c6214be1f5c532016549a7923ab4b5/numpy-2.4.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e", size = 6545212, upload-time = "2026-01-10T06:43:15.661Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/a4/77333f4d1e4dac4395385482557aeecf4826e6ff517e32ca48e1dafbe42a/numpy-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5", size = 14402871, upload-time = "2026-01-10T06:43:17.324Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", size = 16359305, upload-time = "2026-01-10T06:43:19.376Z" },
+ { url = "https://files.pythonhosted.org/packages/32/91/789132c6666288eaa20ae8066bb99eba1939362e8f1a534949a215246e97/numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", size = 16181909, upload-time = "2026-01-10T06:43:21.808Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/b8/090b8bd27b82a844bb22ff8fdf7935cb1980b48d6e439ae116f53cdc2143/numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", size = 18284380, upload-time = "2026-01-10T06:43:23.957Z" },
+ { url = "https://files.pythonhosted.org/packages/67/78/722b62bd31842ff029412271556a1a27a98f45359dea78b1548a3a9996aa/numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", size = 5957089, upload-time = "2026-01-10T06:43:27.535Z" },
+ { url = "https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", size = 12307230, upload-time = "2026-01-10T06:43:29.298Z" },
+ { url = "https://files.pythonhosted.org/packages/44/6c/534d692bfb7d0afe30611320c5fb713659dcb5104d7cc182aff2aea092f5/numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", size = 10313125, upload-time = "2026-01-10T06:43:31.782Z" },
+ { url = "https://files.pythonhosted.org/packages/da/a1/354583ac5c4caa566de6ddfbc42744409b515039e085fab6e0ff942e0df5/numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", size = 12496156, upload-time = "2026-01-10T06:43:34.237Z" },
+ { url = "https://files.pythonhosted.org/packages/51/b0/42807c6e8cce58c00127b1dc24d365305189991f2a7917aa694a109c8d7d/numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", size = 5324663, upload-time = "2026-01-10T06:43:36.211Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/55/7a621694010d92375ed82f312b2f28017694ed784775269115323e37f5e2/numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", size = 6645224, upload-time = "2026-01-10T06:43:37.884Z" },
+ { url = "https://files.pythonhosted.org/packages/50/96/9fa8635ed9d7c847d87e30c834f7109fac5e88549d79ef3324ab5c20919f/numpy-2.4.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9", size = 14462352, upload-time = "2026-01-10T06:43:39.479Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d1/8cf62d8bb2062da4fb82dd5d49e47c923f9c0738032f054e0a75342faba7/numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", size = 16407279, upload-time = "2026-01-10T06:43:41.93Z" },
+ { url = "https://files.pythonhosted.org/packages/86/1c/95c86e17c6b0b31ce6ef219da00f71113b220bcb14938c8d9a05cee0ff53/numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", size = 16248316, upload-time = "2026-01-10T06:43:44.121Z" },
+ { url = "https://files.pythonhosted.org/packages/30/b4/e7f5ff8697274c9d0fa82398b6a372a27e5cef069b37df6355ccb1f1db1a/numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", size = 18329884, upload-time = "2026-01-10T06:43:46.613Z" },
+ { url = "https://files.pythonhosted.org/packages/37/a4/b073f3e9d77f9aec8debe8ca7f9f6a09e888ad1ba7488f0c3b36a94c03ac/numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", size = 6081138, upload-time = "2026-01-10T06:43:48.854Z" },
+ { url = "https://files.pythonhosted.org/packages/16/16/af42337b53844e67752a092481ab869c0523bc95c4e5c98e4dac4e9581ac/numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", size = 12447478, upload-time = "2026-01-10T06:43:50.476Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981, upload-time = "2026-01-10T06:43:52.575Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/a7/ef08d25698e0e4b4efbad8d55251d20fe2a15f6d9aa7c9b30cd03c165e6f/numpy-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc", size = 16652046, upload-time = "2026-01-10T06:43:54.797Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/39/e378b3e3ca13477e5ac70293ec027c438d1927f18637e396fe90b1addd72/numpy-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3", size = 12378858, upload-time = "2026-01-10T06:43:57.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/74/7ec6154f0006910ed1fdbb7591cf4432307033102b8a22041599935f8969/numpy-2.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220", size = 5207417, upload-time = "2026-01-10T06:43:59.037Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/b7/053ac11820d84e42f8feea5cb81cc4fcd1091499b45b1ed8c7415b1bf831/numpy-2.4.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee", size = 6542643, upload-time = "2026-01-10T06:44:01.852Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/c4/2e7908915c0e32ca636b92e4e4a3bdec4cb1e7eb0f8aedf1ed3c68a0d8cd/numpy-2.4.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556", size = 14418963, upload-time = "2026-01-10T06:44:04.047Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/c0/3ed5083d94e7ffd7c404e54619c088e11f2e1939a9544f5397f4adb1b8ba/numpy-2.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844", size = 16363811, upload-time = "2026-01-10T06:44:06.207Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/68/42b66f1852bf525050a67315a4fb94586ab7e9eaa541b1bef530fab0c5dd/numpy-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3", size = 16197643, upload-time = "2026-01-10T06:44:08.33Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/40/e8714fc933d85f82c6bfc7b998a0649ad9769a32f3494ba86598aaf18a48/numpy-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205", size = 18289601, upload-time = "2026-01-10T06:44:10.841Z" },
+ { url = "https://files.pythonhosted.org/packages/80/9a/0d44b468cad50315127e884802351723daca7cf1c98d102929468c81d439/numpy-2.4.1-cp314-cp314-win32.whl", hash = "sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745", size = 6005722, upload-time = "2026-01-10T06:44:13.332Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/bb/c6513edcce5a831810e2dddc0d3452ce84d208af92405a0c2e58fd8e7881/numpy-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d", size = 12438590, upload-time = "2026-01-10T06:44:15.006Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/da/a598d5cb260780cf4d255102deba35c1d072dc028c4547832f45dd3323a8/numpy-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df", size = 10596180, upload-time = "2026-01-10T06:44:17.386Z" },
+ { url = "https://files.pythonhosted.org/packages/de/bc/ea3f2c96fcb382311827231f911723aeff596364eb6e1b6d1d91128aa29b/numpy-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f", size = 12498774, upload-time = "2026-01-10T06:44:19.467Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/ab/ef9d939fe4a812648c7a712610b2ca6140b0853c5efea361301006c02ae5/numpy-2.4.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0", size = 5327274, upload-time = "2026-01-10T06:44:23.189Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/31/d381368e2a95c3b08b8cf7faac6004849e960f4a042d920337f71cef0cae/numpy-2.4.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c", size = 6648306, upload-time = "2026-01-10T06:44:25.012Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/e5/0989b44ade47430be6323d05c23207636d67d7362a1796ccbccac6773dd2/numpy-2.4.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93", size = 14464653, upload-time = "2026-01-10T06:44:26.706Z" },
+ { url = "https://files.pythonhosted.org/packages/10/a7/cfbe475c35371cae1358e61f20c5f075badc18c4797ab4354140e1d283cf/numpy-2.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42", size = 16405144, upload-time = "2026-01-10T06:44:29.378Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/a3/0c63fe66b534888fa5177cc7cef061541064dbe2b4b60dcc60ffaf0d2157/numpy-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01", size = 16247425, upload-time = "2026-01-10T06:44:31.721Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/2b/55d980cfa2c93bd40ff4c290bf824d792bd41d2fe3487b07707559071760/numpy-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b", size = 18330053, upload-time = "2026-01-10T06:44:34.617Z" },
+ { url = "https://files.pythonhosted.org/packages/23/12/8b5fc6b9c487a09a7957188e0943c9ff08432c65e34567cabc1623b03a51/numpy-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a", size = 6152482, upload-time = "2026-01-10T06:44:36.798Z" },
+ { url = "https://files.pythonhosted.org/packages/00/a5/9f8ca5856b8940492fc24fbe13c1bc34d65ddf4079097cf9e53164d094e1/numpy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2", size = 12627117, upload-time = "2026-01-10T06:44:38.828Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/48/d86f97919e79314a1cdee4c832178763e6e98e623e123d0bada19e92c15a/numpy-2.4.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8ad35f20be147a204e28b6a0575fbf3540c5e5f802634d4258d55b1ff5facce1", size = 16822202, upload-time = "2026-01-10T06:44:43.738Z" },
+ { url = "https://files.pythonhosted.org/packages/51/e9/1e62a7f77e0f37dcfb0ad6a9744e65df00242b6ea37dfafb55debcbf5b55/numpy-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8097529164c0f3e32bb89412a0905d9100bf434d9692d9fc275e18dcf53c9344", size = 12569985, upload-time = "2026-01-10T06:44:45.945Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/7e/914d54f0c801342306fdcdce3e994a56476f1b818c46c47fc21ae968088c/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:ea66d2b41ca4a1630aae5507ee0a71647d3124d1741980138aa8f28f44dac36e", size = 5398484, upload-time = "2026-01-10T06:44:48.012Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/d8/9570b68584e293a33474e7b5a77ca404f1dcc655e40050a600dee81d27fb/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:d3f8f0df9f4b8be57b3bf74a1d087fec68f927a2fab68231fdb442bf2c12e426", size = 6713216, upload-time = "2026-01-10T06:44:49.725Z" },
+ { url = "https://files.pythonhosted.org/packages/33/9b/9dd6e2db8d49eb24f86acaaa5258e5f4c8ed38209a4ee9de2d1a0ca25045/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2023ef86243690c2791fd6353e5b4848eedaa88ca8a2d129f462049f6d484696", size = 14538937, upload-time = "2026-01-10T06:44:51.498Z" },
+ { url = "https://files.pythonhosted.org/packages/53/87/d5bd995b0f798a37105b876350d346eea5838bd8f77ea3d7a48392f3812b/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8361ea4220d763e54cff2fbe7d8c93526b744f7cd9ddab47afeff7e14e8503be", size = 16479830, upload-time = "2026-01-10T06:44:53.931Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/c7/b801bf98514b6ae6475e941ac05c58e6411dd863ea92916bfd6d510b08c1/numpy-2.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4f1b68ff47680c2925f8063402a693ede215f0257f02596b1318ecdfb1d79e33", size = 12492579, upload-time = "2026-01-10T06:44:57.094Z" },
+]
+
+[[package]]
+name = "openai"
+version = "2.16.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "distro" },
+ { name = "httpx" },
+ { name = "jiter" },
+ { name = "pydantic" },
+ { name = "sniffio" },
+ { name = "tqdm" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/6c/e4c964fcf1d527fdf4739e7cc940c60075a4114d50d03871d5d5b1e13a88/openai-2.16.0.tar.gz", hash = "sha256:42eaa22ca0d8ded4367a77374104d7a2feafee5bd60a107c3c11b5243a11cd12", size = 629649, upload-time = "2026-01-27T23:28:02.579Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/83/0315bf2cfd75a2ce8a7e54188e9456c60cec6c0cf66728ed07bd9859ff26/openai-2.16.0-py3-none-any.whl", hash = "sha256:5f46643a8f42899a84e80c38838135d7038e7718333ce61396994f887b09a59b", size = 1068612, upload-time = "2026-01-27T23:28:00.356Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
+]
+
+[[package]]
+name = "pathspec"
+version = "1.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
+]
+
+[[package]]
+name = "pillow"
+version = "11.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" },
+ { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" },
+ { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" },
+ { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" },
+ { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" },
+ { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" },
+ { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" },
+ { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" },
+ { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" },
+ { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" },
+ { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" },
+ { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" },
+ { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" },
+ { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" },
+ { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" },
+ { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" },
+ { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" },
+ { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" },
+ { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" },
+ { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" },
+ { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" },
+ { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" },
+ { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" },
+ { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" },
+ { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" },
+ { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" },
+ { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "posthog"
+version = "5.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "backoff" },
+ { name = "distro" },
+ { name = "python-dateutil" },
+ { name = "requests" },
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/48/20/60ae67bb9d82f00427946218d49e2e7e80fb41c15dc5019482289ec9ce8d/posthog-5.4.0.tar.gz", hash = "sha256:701669261b8d07cdde0276e5bc096b87f9e200e3b9589c5ebff14df658c5893c", size = 88076, upload-time = "2025-06-20T23:19:23.485Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4f/98/e480cab9a08d1c09b1c59a93dade92c1bb7544826684ff2acbfd10fcfbd4/posthog-5.4.0-py3-none-any.whl", hash = "sha256:284dfa302f64353484420b52d4ad81ff5c2c2d1d607c4e2db602ac72761831bd", size = 105364, upload-time = "2025-06-20T23:19:22.001Z" },
+]
+
+[[package]]
+name = "prometheus-client"
+version = "0.24.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" },
+]
+
+[[package]]
+name = "propcache"
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" },
+ { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" },
+ { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" },
+ { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" },
+ { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" },
+ { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" },
+ { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" },
+ { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" },
+ { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" },
+ { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" },
+ { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" },
+ { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" },
+ { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" },
+ { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" },
+ { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" },
+ { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" },
+ { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" },
+ { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" },
+ { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" },
+ { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" },
+ { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" },
+ { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" },
+ { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" },
+ { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" },
+ { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" },
+ { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" },
+ { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" },
+ { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" },
+ { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" },
+ { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" },
+ { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" },
+ { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" },
+ { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" },
+ { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" },
+ { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" },
+ { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" },
+ { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" },
+ { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" },
+ { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" },
+ { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
+]
+
+[[package]]
+name = "psycopg"
+version = "3.3.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e0/1a/7d9ef4fdc13ef7f15b934c393edc97a35c281bb7d3c3329fbfcbe915a7c2/psycopg-3.3.2.tar.gz", hash = "sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7", size = 165630, upload-time = "2025-12-06T17:34:53.899Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8c/51/2779ccdf9305981a06b21a6b27e8547c948d85c41c76ff434192784a4c93/psycopg-3.3.2-py3-none-any.whl", hash = "sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b", size = 212774, upload-time = "2025-12-06T17:31:41.414Z" },
+]
+
+[package.optional-dependencies]
+binary = [
+ { name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
+]
+
+[[package]]
+name = "psycopg-binary"
+version = "3.3.2"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/57/d9/49640360fc090d27afc4655021544aa71d5393ebae124ffa53a04474b493/psycopg_binary-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:94503b79f7da0b65c80d0dbb2f81dd78b300319ec2435d5e6dcf9622160bc2fa", size = 4597890, upload-time = "2025-12-06T17:32:23.087Z" },
+ { url = "https://files.pythonhosted.org/packages/85/cf/99634bbccc8af0dd86df4bce705eea5540d06bb7f5ab3067446ae9ffdae4/psycopg_binary-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07a5f030e0902ec3e27d0506ceb01238c0aecbc73ecd7fa0ee55f86134600b5b", size = 4664396, upload-time = "2025-12-06T17:32:26.421Z" },
+ { url = "https://files.pythonhosted.org/packages/40/db/6035dff6d5c6dfca3a4ab0d2ac62ede623646e327e9f99e21e0cf08976c6/psycopg_binary-3.3.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e09d0d93d35c134704a2cb2b15f81ffc8174fd602f3e08f7b1a3d8896156cf0", size = 5478743, upload-time = "2025-12-06T17:32:29.901Z" },
+ { url = "https://files.pythonhosted.org/packages/03/0f/fc06bbc8e87f09458d2ce04a59cd90565e54e8efca33e0802daee6d2b0e6/psycopg_binary-3.3.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:649c1d33bedda431e0c1df646985fbbeb9274afa964e1aef4be053c0f23a2924", size = 5151820, upload-time = "2025-12-06T17:32:33.562Z" },
+ { url = "https://files.pythonhosted.org/packages/86/ab/bcc0397c96a0ad29463e33ed03285826e0fabc43595c195f419d9291ee70/psycopg_binary-3.3.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5774272f754605059521ff037a86e680342e3847498b0aa86b0f3560c70963c", size = 6747711, upload-time = "2025-12-06T17:32:38.074Z" },
+ { url = "https://files.pythonhosted.org/packages/96/eb/7450bc75c31d5be5f7a6d02d26beef6989a4ca6f5efdec65eea6cf612d0e/psycopg_binary-3.3.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d391b70c9cc23f6e1142729772a011f364199d2c5ddc0d596f5f43316fbf982d", size = 4991626, upload-time = "2025-12-06T17:32:41.373Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/85/65f14453804c82a7fba31cd1a984b90349c0f327b809102c4b99115c0930/psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f3f601f32244a677c7b029ec39412db2772ad04a28bc2cbb4b1f0931ed0ffad7", size = 4516760, upload-time = "2025-12-06T17:32:44.921Z" },
+ { url = "https://files.pythonhosted.org/packages/24/8c/3105f00a91d73d9a443932f95156eae8159d5d9cb68a9d2cf512710d484f/psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0ae60e910531cfcc364a8f615a7941cac89efeb3f0fffe0c4824a6d11461eef7", size = 4204028, upload-time = "2025-12-06T17:32:48.355Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/dd/74f64a383342ef7c22d1eb2768ed86411c7f877ed2580cd33c17f436fe3c/psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c43a773dd1a481dbb2fe64576aa303d80f328cce0eae5e3e4894947c41d1da7", size = 3935780, upload-time = "2025-12-06T17:32:51.347Z" },
+ { url = "https://files.pythonhosted.org/packages/85/30/f3f207d1c292949a26cdea6727c9c325b4ee41e04bf2736a4afbe45eb61f/psycopg_binary-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5a327327f1188b3fbecac41bf1973a60b86b2eb237db10dc945bd3dc97ec39e4", size = 4243239, upload-time = "2025-12-06T17:32:54.924Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/08/8f1b5d6231338bf7bc46f635c4d4965facec52e1c9a7952ca8a70cb57dc0/psycopg_binary-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:136c43f185244893a527540307167f5d3ef4e08786508afe45d6f146228f5aa9", size = 3548102, upload-time = "2025-12-06T17:32:57.944Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/1e/8614b01c549dd7e385dacdcd83fe194f6b3acb255a53cc67154ee6bf00e7/psycopg_binary-3.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9387ab615f929e71ef0f4a8a51e986fa06236ccfa9f3ec98a88f60fbf230634", size = 4579832, upload-time = "2025-12-06T17:33:01.388Z" },
+ { url = "https://files.pythonhosted.org/packages/26/97/0bb093570fae2f4454d42c1ae6000f15934391867402f680254e4a7def54/psycopg_binary-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3ff7489df5e06c12d1829544eaec64970fe27fe300f7cf04c8495fe682064688", size = 4658786, upload-time = "2025-12-06T17:33:05.022Z" },
+ { url = "https://files.pythonhosted.org/packages/61/20/1d9383e3f2038826900a14137b0647d755f67551aab316e1021443105ed5/psycopg_binary-3.3.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9742580ecc8e1ac45164e98d32ca6df90da509c2d3ff26be245d94c430f92db4", size = 5454896, upload-time = "2025-12-06T17:33:09.023Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/62/513c80ad8bbb545e364f7737bf2492d34a4c05eef4f7b5c16428dc42260d/psycopg_binary-3.3.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d45acedcaa58619355f18e0f42af542fcad3fd84ace4b8355d3a5dea23318578", size = 5132731, upload-time = "2025-12-06T17:33:12.519Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/28/ddf5f5905f088024bccb19857949467407c693389a14feb527d6171d8215/psycopg_binary-3.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d88f32ff8c47cb7f4e7e7a9d1747dcee6f3baa19ed9afa9e5694fd2fb32b61ed", size = 6724495, upload-time = "2025-12-06T17:33:16.624Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/93/a1157ebcc650960b264542b547f7914d87a42ff0cc15a7584b29d5807e6b/psycopg_binary-3.3.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59d0163c4617a2c577cb34afbed93d7a45b8c8364e54b2bd2020ff25d5f5f860", size = 4964979, upload-time = "2025-12-06T17:33:20.179Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/27/65939ba6798f9c5be4a5d9cd2061ebaf0851798525c6811d347821c8132d/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e750afe74e6c17b2c7046d2c3e3173b5a3f6080084671c8aa327215323df155b", size = 4493648, upload-time = "2025-12-06T17:33:23.464Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/c4/5e9e4b9b1c1e27026e43387b0ba4aaf3537c7806465dd3f1d5bde631752a/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f26f113013c4dcfbfe9ced57b5bad2035dda1a7349f64bf726021968f9bccad3", size = 4173392, upload-time = "2025-12-06T17:33:26.88Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/81/cf43fb76993190cee9af1cbcfe28afb47b1928bdf45a252001017e5af26e/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8309ee4569dced5e81df5aa2dcd48c7340c8dee603a66430f042dfbd2878edca", size = 3909241, upload-time = "2025-12-06T17:33:30.092Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/20/c6377a0d17434674351627489deca493ea0b137c522b99c81d3a106372c8/psycopg_binary-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6464150e25b68ae3cb04c4e57496ea11ebfaae4d98126aea2f4702dd43e3c12", size = 4219746, upload-time = "2025-12-06T17:33:33.097Z" },
+ { url = "https://files.pythonhosted.org/packages/25/32/716c57b28eefe02a57a4c9d5bf956849597f5ea476c7010397199e56cfde/psycopg_binary-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:716a586f99bbe4f710dc58b40069fcb33c7627e95cc6fc936f73c9235e07f9cf", size = 3537494, upload-time = "2025-12-06T17:33:35.82Z" },
+ { url = "https://files.pythonhosted.org/packages/14/73/7ca7cb22b9ac7393fb5de7d28ca97e8347c375c8498b3bff2c99c1f38038/psycopg_binary-3.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc5a189e89cbfff174588665bb18d28d2d0428366cc9dae5864afcaa2e57380b", size = 4579068, upload-time = "2025-12-06T17:33:39.303Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/42/0cf38ff6c62c792fc5b55398a853a77663210ebd51ed6f0c4a05b06f95a6/psycopg_binary-3.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:083c2e182be433f290dc2c516fd72b9b47054fcd305cce791e0a50d9e93e06f2", size = 4657520, upload-time = "2025-12-06T17:33:42.536Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/60/df846bc84cbf2231e01b0fff48b09841fe486fa177665e50f4995b1bfa44/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ac230e3643d1c436a2dfb59ca84357dfc6862c9f372fc5dbd96bafecae581f9f", size = 5452086, upload-time = "2025-12-06T17:33:46.54Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/85/30c846a00db86b1b53fd5bfd4b4edfbd0c00de8f2c75dd105610bd7568fc/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8c899a540f6c7585cee53cddc929dd4d2db90fd828e37f5d4017b63acbc1a5d", size = 5131125, upload-time = "2025-12-06T17:33:50.413Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/15/9968732013373f36f8a2a3fb76104dffc8efd9db78709caa5ae1a87b1f80/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50ff10ab8c0abdb5a5451b9315538865b50ba64c907742a1385fdf5f5772b73e", size = 6722914, upload-time = "2025-12-06T17:33:54.544Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/ba/29e361fe02143ac5ff5a1ca3e45697344cfbebe2eaf8c4e7eec164bff9a0/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23d2594af848c1fd3d874a9364bef50730124e72df7bb145a20cb45e728c50ed", size = 4966081, upload-time = "2025-12-06T17:33:58.477Z" },
+ { url = "https://files.pythonhosted.org/packages/99/45/1be90c8f1a1a237046903e91202fb06708745c179f220b361d6333ed7641/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fe6b4ead3bbbe27244ea224fcd1f53cb119afc38b71a2f3ce570149a03e30", size = 4493332, upload-time = "2025-12-06T17:34:02.011Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/b5/bbdc07d5f0a5e90c617abd624368182aa131485e18038b2c6c85fc054aed/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:742ce48cde825b8e52fb1a658253d6d1ff66d152081cbc76aa45e2986534858d", size = 4170781, upload-time = "2025-12-06T17:34:05.298Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/2a/0d45e4f4da2bd78c3237ffa03475ef3751f69a81919c54a6e610eb1a7c96/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e22bf6b54df994aff37ab52695d635f1ef73155e781eee1f5fa75bc08b58c8da", size = 3910544, upload-time = "2025-12-06T17:34:08.251Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/62/a8e0f092f4dbef9a94b032fb71e214cf0a375010692fbe7493a766339e47/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8db9034cde3bcdafc66980f0130813f5c5d19e74b3f2a19fb3cfbc25ad113121", size = 4220070, upload-time = "2025-12-06T17:34:11.392Z" },
+ { url = "https://files.pythonhosted.org/packages/09/e6/5fc8d8aff8afa114bb4a94a0341b9309311e8bf3ab32d816032f8b984d4e/psycopg_binary-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:df65174c7cf6b05ea273ce955927d3270b3a6e27b0b12762b009ce6082b8d3fc", size = 3540922, upload-time = "2025-12-06T17:34:14.88Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/75/ad18c0b97b852aba286d06befb398cc6d383e9dfd0a518369af275a5a526/psycopg_binary-3.3.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9ca24062cd9b2270e4d77576042e9cc2b1d543f09da5aba1f1a3d016cea28390", size = 4596371, upload-time = "2025-12-06T17:34:18.007Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/79/91649d94c8d89f84af5da7c9d474bfba35b08eb8f492ca3422b08f0a6427/psycopg_binary-3.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c749770da0947bc972e512f35366dd4950c0e34afad89e60b9787a37e97cb443", size = 4675139, upload-time = "2025-12-06T17:34:21.374Z" },
+ { url = "https://files.pythonhosted.org/packages/56/ac/b26e004880f054549ec9396594e1ffe435810b0673e428e619ed722e4244/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03b7cd73fb8c45d272a34ae7249713e32492891492681e3cf11dff9531cf37e9", size = 5456120, upload-time = "2025-12-06T17:34:25.102Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/8d/410681dccd6f2999fb115cc248521ec50dd2b0aba66ae8de7e81efdebbee/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43b130e3b6edcb5ee856c7167ccb8561b473308c870ed83978ae478613764f1c", size = 5133484, upload-time = "2025-12-06T17:34:28.933Z" },
+ { url = "https://files.pythonhosted.org/packages/66/30/ebbab99ea2cfa099d7b11b742ce13415d44f800555bfa4ad2911dc645b71/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1feba5a8c617922321aef945865334e468337b8fc5c73074f5e63143013b5a", size = 6731818, upload-time = "2025-12-06T17:34:33.094Z" },
+ { url = "https://files.pythonhosted.org/packages/70/02/d260646253b7ad805d60e0de47f9b811d6544078452579466a098598b6f4/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cabb2a554d9a0a6bf84037d86ca91782f087dfff2a61298d0b00c19c0bc43f6d", size = 4983859, upload-time = "2025-12-06T17:34:36.457Z" },
+ { url = "https://files.pythonhosted.org/packages/72/8d/e778d7bad1a7910aa36281f092bd85c5702f508fd9bb0ea2020ffbb6585c/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74bc306c4b4df35b09bc8cecf806b271e1c5d708f7900145e4e54a2e5dedfed0", size = 4516388, upload-time = "2025-12-06T17:34:40.129Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/f1/64e82098722e2ab3521797584caf515284be09c1e08a872551b6edbb0074/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d79b0093f0fbf7a962d6a46ae292dc056c65d16a8ee9361f3cfbafd4c197ab14", size = 4192382, upload-time = "2025-12-06T17:34:43.279Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/d0/c20f4e668e89494972e551c31be2a0016e3f50d552d7ae9ac07086407599/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1586e220be05547c77afc326741dd41cc7fba38a81f9931f616ae98865439678", size = 3928660, upload-time = "2025-12-06T17:34:46.757Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/e1/99746c171de22539fd5eb1c9ca21dc805b54cfae502d7451d237d1dbc349/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:458696a5fa5dad5b6fb5d5862c22454434ce4fe1cf66ca6c0de5f904cbc1ae3e", size = 4239169, upload-time = "2025-12-06T17:34:49.751Z" },
+ { url = "https://files.pythonhosted.org/packages/72/f7/212343c1c9cfac35fd943c527af85e9091d633176e2a407a0797856ff7b9/psycopg_binary-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1", size = 3642122, upload-time = "2025-12-06T17:34:52.506Z" },
+]
+
+[[package]]
+name = "pyasn1"
+version = "0.6.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
+]
+
+[[package]]
+name = "pyasn1-modules"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyasn1" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.12.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.41.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
+ { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
+ { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
+ { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
+ { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
+ { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
+ { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
+ { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
+ { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
+ { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
+ { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
+ { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
+ { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
+ { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
+ { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
+ { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
+ { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
+ { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
+ { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
+ { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
+ { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
+ { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
+ { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
+ { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
+ { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
+ { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
+ { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
+ { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
+ { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
+ { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
+ { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
+ { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
+ { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
+ { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
+ { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
+ { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
+ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
+]
+
+[[package]]
+name = "pydantic-settings"
+version = "2.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "python-dotenv" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
+]
+
+[[package]]
+name = "pygit2"
+version = "1.19.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/17/49/cf8350817de19f4cafe4ae47881e38f56d9bbebaa9e5ef31a5458af4bcf8/pygit2-1.19.1.tar.gz", hash = "sha256:3165f784aae56a309a27d8eeae7923d53da2e8f6094308c7f5b428deec925cf9", size = 800869, upload-time = "2025-12-29T11:47:48.618Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f8/4f/c8c29c4af2de6b8b7e086cad24e200ec7f165587caa77b7d2d495366204e/pygit2-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2b54f3a94648ac8e287f5e4333710d9fe05f9e09de3da232d50df753bb01b643", size = 5702353, upload-time = "2025-12-29T11:46:28.548Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/04/814b305804f067fd8d1cd7166dc3704900704a8fa71280703212abbacf9f/pygit2-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e46618a912fc984b8a9f4d8322704620f1315264359c7fa61c899128e23e226", size = 5691612, upload-time = "2025-12-29T11:46:30.754Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/04/61c84d1ab2585f50a2551199e4228f3a800635c482e451e93f2cd0c0ae3d/pygit2-1.19.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2eb386b3e98f7056d76bc7e805e8fce3cd0a773cbbb30b0f7e144c0ac37270f2", size = 6021372, upload-time = "2025-12-29T11:46:32.439Z" },
+ { url = "https://files.pythonhosted.org/packages/be/7a/daca8780c72b0d5a56165e0bff3b76d2fa8e0a8f7269f40aa17f10ed0356/pygit2-1.19.1-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f41a9b866676922ac9b0ec60f0dc9735a5d1ba6bb34146a6212dc0012d7959f", size = 4623817, upload-time = "2025-12-29T11:46:33.964Z" },
+ { url = "https://files.pythonhosted.org/packages/92/f6/d065bb189c9fd86c5e540eb264567b4fe3eb06447da1408c03a35e15096b/pygit2-1.19.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2cdc81ecffd990d8c6dce44a16b1dc4494b5dd5381d6e1f508e459c4bca09e0", size = 5781284, upload-time = "2025-12-29T11:46:35.703Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/8a/2b9195619a9a0dc6e25525e784f7474174614ebc064a91b2a2087952a583/pygit2-1.19.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a1c8645287556aa9b670886dbc0d5daa1d49040511940822fd43dbda13cfe4e8", size = 6027281, upload-time = "2025-12-29T11:46:37.331Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/b7/20837029e8f5177d4ac48396a4448d02dfe455e988bb722d43dc42f6b0af/pygit2-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e388d1eb0c44d92d8ff01b25eb9a969fc28748966843c2e26e9e084e42567f7d", size = 5750642, upload-time = "2025-12-29T11:46:38.626Z" },
+ { url = "https://files.pythonhosted.org/packages/41/42/18cc94976a35451a5653abf047356f94b5f503b1c0b02223a6d9e72979d3/pygit2-1.19.1-cp311-cp311-win32.whl", hash = "sha256:815c0b12845253929f2275759d623b3b4093e67e6536d2463177e6ff1d9ff0df", size = 942173, upload-time = "2025-12-29T11:46:40.087Z" },
+ { url = "https://files.pythonhosted.org/packages/61/19/590708fc3182d47b40f0274f80671ccdf9c1a8fa5a838b554e6fe15a2bb3/pygit2-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:93f4986b35984aaaa5e7613ceb1ba4c184d890589df60b0d8d74e7dccec1d8cb", size = 1159463, upload-time = "2025-12-29T11:46:41.338Z" },
+ { url = "https://files.pythonhosted.org/packages/90/a8/a2c1eb6f8c5f30cb5633a3c21e60ee6be2e4a3148b302f578e4b48e769ef/pygit2-1.19.1-cp311-cp311-win_arm64.whl", hash = "sha256:fef27b206955e66e3a63664e2ec93821e00ce2d917f8b4eae87c738163c00e14", size = 966795, upload-time = "2025-12-29T11:46:42.842Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/36/0784870218794d6069bf8ebae55679964edc44b8e59279f4526aa1220569/pygit2-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8e6a4f4a711750c286a13cea0007b40f7466c4d741c3d9b223ffbc3bbfbafe7", size = 5700218, upload-time = "2025-12-29T11:46:44.537Z" },
+ { url = "https://files.pythonhosted.org/packages/56/65/47206823900ddca606022025369ba3e136de9d2310585acac10d8cef81fd/pygit2-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3f2340a668eb3e2d8927dcbeb1a043d3a65d2dd39a913995b34fc437da5e73af", size = 5692231, upload-time = "2025-12-29T11:46:45.821Z" },
+ { url = "https://files.pythonhosted.org/packages/19/27/c6b52f53ee16b9d7eaacc575f08add3c336f53b5561cf94fe41ceeab1589/pygit2-1.19.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe41f09b1e334c43def6636b1133d2f4c91a20d9a6691bb4e7558e42a31bcb4e", size = 6022217, upload-time = "2025-12-29T11:46:47.086Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/ac/41d7a1ed69e117e9cd99b2f40f63898f9725ac6c4245b2b531ae0b7e59da/pygit2-1.19.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527e57133d30ff6ea96634da6bf428f7d551958207fa73f9e9a18582b885e192", size = 4622846, upload-time = "2025-12-29T11:46:48.679Z" },
+ { url = "https://files.pythonhosted.org/packages/09/22/f8fc7860b7b7ba15f7bf802ae3bce52b3e765b48846db115cb1c8372f971/pygit2-1.19.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a9340cb85b7be40080186a9d4dbf712a6be8a842556acbbfb305baebfb854f3", size = 5785236, upload-time = "2025-12-29T11:46:50.24Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/62/ee9275c48ecc119a7f5c48209aaa06d5f71d8476703c7700182c49c8a7a8/pygit2-1.19.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:66ecfa69f2287f50ec95dfc04821219c2f664c4cd292c7b33c10ed9afe975132", size = 6028266, upload-time = "2025-12-29T11:46:51.5Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/98/311112a50e6e319921f06c20ff237360c10bb2e8a1f959361567e48835f3/pygit2-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:14c76ec968ae20a6689c7b6fa833ef546c7bc176127d71e7b67cb2345a9813fb", size = 5755041, upload-time = "2025-12-29T11:46:53.337Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/45/f6a24326fb94e56ddae9906e21d4e4a006a36131a3a73819be1177e30e93/pygit2-1.19.1-cp312-cp312-win32.whl", hash = "sha256:ffe94118d39f6969fda594224b2b6df1ae79306adaf090ede65bcaf1a41b3a81", size = 942948, upload-time = "2025-12-29T11:46:54.465Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/1a/912ee3a33ba665f82cf8ed0087e7446f1f8e117aba1627e0c4ccc9b2a8c5/pygit2-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:c2ee3f2e91b0a5674ab7cb373234c23cf5f1cf6d84e56e6d12ff3db21414cf47", size = 1159880, upload-time = "2025-12-29T11:46:55.523Z" },
+ { url = "https://files.pythonhosted.org/packages/24/fc/784eeceab43c2b4978aa46f03c267409f2502331fa18d0a8e58116d143d0/pygit2-1.19.1-cp312-cp312-win_arm64.whl", hash = "sha256:c8747d968d8d6b9d390263907f014d38a0f67bd26d8243e5bc3384cb252ec3d3", size = 966904, upload-time = "2025-12-29T11:46:56.888Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/2b/b3c8661e710ec49f7f38f992b913d6fef21e21ef6b9b327111b85bf1460c/pygit2-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:39af62f3e18dfdfb15c347c12b51231fdb3db3c9d5105d9046847ead14b42fce", size = 5700202, upload-time = "2025-12-29T11:46:58.294Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/1f/f67ec7f78a34ed14dbd3acf05ed23c4c8c2336ba6f3ca78d6b9962878435/pygit2-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed39106f1d9560709191093ed5251471dfb6b9e4aa35299dde45f4b91f7c984e", size = 5692171, upload-time = "2025-12-29T11:46:59.535Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/02/02f0f56b9b0b044018d9047adf68ba842ebda662ba43ace942ed904f8e9d/pygit2-1.19.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb4da746c92e23281890e865887d83f24e662fc3e1c481420e4993c5a13203fe", size = 6023018, upload-time = "2025-12-29T11:47:00.984Z" },
+ { url = "https://files.pythonhosted.org/packages/da/a6/5ec78c14ca00fbffe6aa32eb6f5fbeb7fb06eb39e6929b06f7635f501a45/pygit2-1.19.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:93ccfab2340d38374f91ecf6cae6658bebc73883c376eb81eeb293781f6aef94", size = 4623392, upload-time = "2025-12-29T11:47:02.598Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/80/1a87f6e043e04cfa125380a73ef9f87a8c58292b7d4a6ed2e6203b4cd534/pygit2-1.19.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef18f1208422d3cac1c109417a5fc6143704cfff8e5de4e1665fa4a89ffe3902", size = 5786360, upload-time = "2025-12-29T11:47:03.898Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/6e/f5e38a4645d7fbba40083f94278814b9863b0afd14e905ebbd7ef31a27ec/pygit2-1.19.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:344f4c1e84eaa2434fbb43d96a1dd79796ab9559587a8533331fef92eab0ec7d", size = 6029576, upload-time = "2025-12-29T11:47:05.109Z" },
+ { url = "https://files.pythonhosted.org/packages/53/cc/e5ff546f003c3fa635495105e3e039de3a863da66c82289b7a8baf6d5b48/pygit2-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1ae2f408206c67d395e8dc77425f8ab457cad59faaa58c700164398a62823e82", size = 5756457, upload-time = "2025-12-29T11:47:06.483Z" },
+ { url = "https://files.pythonhosted.org/packages/da/dd/1331e3bdabd811992f511ebfa96f56c7b13d5f16837d74ac34dac93ce999/pygit2-1.19.1-cp313-cp313-win32.whl", hash = "sha256:9d6cf97c2da5c589b65371a8115be920cf417c46a80a2b12edb26e54a5238190", size = 942919, upload-time = "2025-12-29T11:47:07.833Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/01/98f74ecbe92f042d27e4de3cd7f093422d523cc67fdc74e6a65dbe4efbb8/pygit2-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d73aedffad280f6b655394e303533fcff15545d4d8f322011179c9474bb1b13", size = 1159846, upload-time = "2025-12-29T11:47:09.228Z" },
+ { url = "https://files.pythonhosted.org/packages/27/4e/df8fa9a9f4e4e9aec417f8a674466d613985efb67453aa206f0455003738/pygit2-1.19.1-cp313-cp313-win_arm64.whl", hash = "sha256:8b067241c03a29440507e78637e233998fe1a11d2082169bd8177694ec4ee747", size = 966896, upload-time = "2025-12-29T11:47:10.233Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/45/1284c7714070b51e3413e66b677fa4ecf8c840d2f86d1bebc77d2390fe3d/pygit2-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d10a46285b9ae39b9de2d9f44ac7f933993aecfab189c2932320b3df596311c8", size = 5702338, upload-time = "2025-12-29T11:47:12.807Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/9f/7a39d4c612e12966130504e1610f500b397d7968feb6d25e1353614dab74/pygit2-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d0f3924d8d0d54a7fe186761c76dc1b6e5fcf41794a6daba1630db3bc216b9ba", size = 5692261, upload-time = "2025-12-29T11:47:14.276Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/7c/7806cf0ae9200bd773628be6d8c345d277b8f0161de950b572a4ce200105/pygit2-1.19.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4fcc301cfe9c29f3e29f0f80d81ae65c0bee368672b23566467dc91b5edae4b", size = 6025106, upload-time = "2025-12-29T11:47:15.904Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/30/7f1b67711705eb0220dcc4581a97b4aebad4ffde2f6f6b94314690e1cfa1/pygit2-1.19.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c6eacf82f15e001121dc0f60057f462627045447d8bd8587b33b13159ae5155", size = 4627355, upload-time = "2025-12-29T11:47:17.365Z" },
+ { url = "https://files.pythonhosted.org/packages/14/88/25f1e65ff6ed678e1be9aaeabeedcb26531d17b6b86c4b1d50d8f0c50825/pygit2-1.19.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:074b0b14c6f3c7e2c6ea0b01a90832407a71520c920918aa07f509c91f1691f9", size = 5788548, upload-time = "2025-12-29T11:47:18.98Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/5d/ff1b12d3682918ac6c3c6629a6c6272db1b4041994d38045d3c334034570/pygit2-1.19.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ada5d3e813e21918e004a33c66aba4a2b829cd5c0c0e85b92dd70f84cf95ac56", size = 6030078, upload-time = "2025-12-29T11:47:20.324Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/c5/c078ed6f1f5d7f3feba4b86d53e464c8358112ec32943e11e36557009818/pygit2-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:19ebe25fd8e95ed8a0be0a9dd4cecc1233db4f2a44a2a73984620909e98e907f", size = 5757154, upload-time = "2025-12-29T11:47:21.971Z" },
+ { url = "https://files.pythonhosted.org/packages/76/90/1722d7c2db5d563becb59a54b2f49b44964ff699826629f96594064d972a/pygit2-1.19.1-cp314-cp314-win32.whl", hash = "sha256:5bc0738a49cceb76f0fba7cdb24532857a980e4a36b9a0da025c359dfe3676b4", size = 964159, upload-time = "2025-12-29T11:47:23.508Z" },
+ { url = "https://files.pythonhosted.org/packages/74/72/80558b71ed780a732c9ff10003c3a73b68fbf320c3125ae11bb664a8076c/pygit2-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:527d40925bb85b86da0e96ecc90e9ca74d0a0273ab645bac0787b95923d93160", size = 1190612, upload-time = "2025-12-29T11:47:24.889Z" },
+ { url = "https://files.pythonhosted.org/packages/28/68/c60ff9ae38543a520ca93c0d52a52c2e375ac44b9a5c5da99044cca8c5c5/pygit2-1.19.1-cp314-cp314-win_arm64.whl", hash = "sha256:21c7c8b5aa2f48cefdb8521185f0cd3c110a340e2d9f62a46a94db01a907db73", size = 994766, upload-time = "2025-12-29T11:47:25.902Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/42/4da546bf55183877e7da4327594ab138db92aa00921d46d513626bcad19f/pygit2-1.19.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9c5e4eb975b664b6821fe6a05b03bbc51052d1fb22f20652e1d4349ae24ed7ac", size = 5705642, upload-time = "2025-12-29T11:47:27.034Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/b7/74a9cf3d2e6cd6bd2fa6a7bc3530054c2f720fc59e3b731251bbdebd8983/pygit2-1.19.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8752eae5780ee51edae326cac394868917704624b63d03a5217c5e94a532a0e3", size = 5695192, upload-time = "2025-12-29T11:47:28.98Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/b9/bde02249c2c5deecc8e483ee9132f86f67114eec154ee10219d23a1ce9f9/pygit2-1.19.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:457f5a2e6d8527b5ad7a8bd16586c72ad2ce0aa218a37380f16d07520569ceaf", size = 6085318, upload-time = "2025-12-29T11:47:30.268Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/ae/b3a14edaa579700aee33a25a788f5f4fe67713a6e2273a897635e6742b35/pygit2-1.19.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c8a9d53c84724c97d7e298f6628655c19f9911a90b88c362cb7d5daa645464f", size = 4684691, upload-time = "2025-12-29T11:47:31.829Z" },
+ { url = "https://files.pythonhosted.org/packages/00/8d/5f557be149931ef7d692b66296a44263a1769070466eb1e63d6d1b3b97c1/pygit2-1.19.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d8442ad863be83be86baff006a6e11de3cddf17c7ee77eac2d389765987b554", size = 5841500, upload-time = "2025-12-29T11:47:33.636Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/f7/0101b3058e64df334c48193dfd6f1493a24b0c7813382c6b2e4db7a09ffa/pygit2-1.19.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ae9c775be518c7f20bf340091d329d3b9203cbd4273bf1b5505dc82dccf08147", size = 6087805, upload-time = "2025-12-29T11:47:34.926Z" },
+ { url = "https://files.pythonhosted.org/packages/60/26/7d3fa88362b1703cd5b9bde411f37cded3b1f99dc83b720fc0c65ac8f37b/pygit2-1.19.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d5a45d466a4bc5d9eb0619ffc26b17e4018285e35ba9e2fe39576f13480b63bc", size = 5809156, upload-time = "2025-12-29T11:47:36.396Z" },
+ { url = "https://files.pythonhosted.org/packages/90/38/f1952af3f61b3a7a49c417ffb67a5140c1183e6b04ec714c8941937860bf/pygit2-1.19.1-cp314-cp314t-win32.whl", hash = "sha256:6621acaaf2670e8fd0727c15271e5209a99769b127300ef7fc56b49babc8b1c1", size = 969317, upload-time = "2025-12-29T11:47:38.01Z" },
+ { url = "https://files.pythonhosted.org/packages/31/02/205a4d10cb1195f6abf0a509883ede90caddefca6d9c3b54ef96e79e8e8a/pygit2-1.19.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4418dea6936fe3c1a9375d7cd31a69e72997e645e588ed31c40d785c71adde35", size = 1197068, upload-time = "2025-12-29T11:47:39.065Z" },
+ { url = "https://files.pythonhosted.org/packages/00/20/4571edf9bebc9d60dcf5d5c3cd0a12e55a79b91b02ef960c44e4ffc24c70/pygit2-1.19.1-cp314-cp314t-win_arm64.whl", hash = "sha256:3cbb8ab952224c0b305aa56f8759bcad5d9a9de885b00fe0ff8bed9ac365472e", size = 995635, upload-time = "2025-12-29T11:47:40.327Z" },
+ { url = "https://files.pythonhosted.org/packages/45/01/607b8a400ffe46340df083d67cb05296f90e0d302d09addac5dc1afee47f/pygit2-1.19.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3c56ef9ac89e020ca005a39db4e045792b1ce98c2450a53f79815e9d831c006a", size = 5646594, upload-time = "2025-12-29T11:47:41.437Z" },
+ { url = "https://files.pythonhosted.org/packages/18/59/45e517b86692120fd927b8949916203c50ffce0cd7a7124131d90d816fde/pygit2-1.19.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a6d89079f3af32f25abb8680eabea31143a4f02f3d1da6644c296ba89b6a2fc", size = 5644506, upload-time = "2025-12-29T11:47:42.779Z" },
+ { url = "https://files.pythonhosted.org/packages/db/25/41c0c37c0f8b23677364d9f82ddbb1377d2342666045d39b508acc3d6f97/pygit2-1.19.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bfd44dc6f1d5b1165cc2097c39000c4a5cc05443d27a3a5f2791ad338f52b07", size = 5559864, upload-time = "2025-12-29T11:47:44.399Z" },
+ { url = "https://files.pythonhosted.org/packages/76/c0/16ff6c4d732d8644ab84a5d48141b55f6b353e08da5ffcbee03a5c58c3a5/pygit2-1.19.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0aca00ff7e3420f9c06d9386b0bfc76c18fd8a2c5234412db0e200a6cc47ed03", size = 5312681, upload-time = "2025-12-29T11:47:46.022Z" },
+ { url = "https://files.pythonhosted.org/packages/08/cc/f762a2378d148ae40766fcac3f1ae1b5d925ae80128422366788eea9f5e6/pygit2-1.19.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f89f047667a218b71ebc96c398aca1e5109f149045a8d59ca9fd4a557d1e932e", size = 1130023, upload-time = "2025-12-29T11:47:47.55Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.19.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+]
+
+[[package]]
+name = "pyhumps"
+version = "3.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c4/83/fa6f8fb7accb21f39e8f2b6a18f76f6d90626bdb0a5e5448e5cc9b8ab014/pyhumps-3.8.0.tar.gz", hash = "sha256:498026258f7ee1a8e447c2e28526c0bea9407f9a59c03260aee4bd6c04d681a3", size = 9018, upload-time = "2022-10-21T10:38:59.496Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/11/a1938340ecb32d71e47ad4914843775011e6e9da59ba1229f181fef3119e/pyhumps-3.8.0-py3-none-any.whl", hash = "sha256:060e1954d9069f428232a1adda165db0b9d8dfdce1d265d36df7fbff540acfd6", size = 6095, upload-time = "2022-10-21T10:38:58.231Z" },
+]
+
+[[package]]
+name = "pylint"
+version = "3.2.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "astroid" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "dill" },
+ { name = "isort" },
+ { name = "mccabe" },
+ { name = "platformdirs" },
+ { name = "tomlkit" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/30/10/abee071c1d52b2bca48be40fe9f64ca878a77e0beef6504597e8c9c1ed84/pylint-3.2.6.tar.gz", hash = "sha256:a5d01678349454806cff6d886fb072294f56a58c4761278c97fb557d708e1eb3", size = 1510167, upload-time = "2024-07-21T19:48:38.032Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/09/88/1a406dd0b17a4796f025d8c937d8d56f97869cffa55c21d9edb07f5a3912/pylint-3.2.6-py3-none-any.whl", hash = "sha256:03c8e3baa1d9fb995b12c1dbe00aa6c4bcef210c2a2634374aedeb22fb4a8f8f", size = 519798, upload-time = "2024-07-21T19:48:34.788Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
+]
+
+[[package]]
+name = "pytest-asyncio"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
+]
+
+[[package]]
+name = "pytest-mock"
+version = "3.15.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
+]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
+]
+
+[[package]]
+name = "python-gitlab"
+version = "8.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "requests" },
+ { name = "requests-toolbelt" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c4/68/02645bc9d71554e7a263b118e4e55dafe4c4735c1ba74f9712232ed84054/python_gitlab-8.0.0.tar.gz", hash = "sha256:03eae5a9d105448796e6c0e192d402c266057e75790cf4f42c143dddf91313ce", size = 401334, upload-time = "2026-01-28T01:22:27.005Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/52/60/ba68e51e90a99b14af639463e5d617239029ec25927a0990ff28bd851916/python_gitlab-8.0.0-py3-none-any.whl", hash = "sha256:c635e6722c5710d35ddadfcf95c362b0aa8de11ab3972bc4f230ebd58a6c49ee", size = 144483, upload-time = "2026-01-28T01:22:25.772Z" },
+]
+
+[[package]]
+name = "pytokens"
+version = "0.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e5/16/4b9cfd90d55e66ffdb277d7ebe3bc25250c2311336ec3fc73b2673c794d5/pytokens-0.4.0.tar.gz", hash = "sha256:6b0b03e6ea7c9f9d47c5c61164b69ad30f4f0d70a5d9fe7eac4d19f24f77af2d", size = 15039, upload-time = "2026-01-19T07:59:50.623Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b4/05/3196399a353dd4cd99138a88f662810979ee2f1a1cdb0b417cb2f4507836/pytokens-0.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:92eb3ef88f27c22dc9dbab966ace4d61f6826e02ba04dac8e2d65ea31df56c8e", size = 160075, upload-time = "2026-01-19T07:59:00.316Z" },
+ { url = "https://files.pythonhosted.org/packages/28/1d/c8fc4ed0a1c4f660391b201cda00b1d5bbcc00e2998e8bcd48b15eefd708/pytokens-0.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4b77858a680635ee9904306f54b0ee4781effb89e211ba0a773d76539537165", size = 247318, upload-time = "2026-01-19T07:59:01.636Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/0e/53e55ba01f3e858d229cd84b02481542f42ba59050483a78bf2447ee1af7/pytokens-0.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25cacc20c2ad90acb56f3739d87905473c54ca1fa5967ffcd675463fe965865e", size = 259752, upload-time = "2026-01-19T07:59:04.229Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/56/2d930d7f899e3f21868ca6e8ec739ac31e8fc532f66e09cbe45d3df0a84f/pytokens-0.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:628fab535ebc9079e4db35cd63cb401901c7ce8720a9834f9ad44b9eb4e0f1d4", size = 262842, upload-time = "2026-01-19T07:59:06.14Z" },
+ { url = "https://files.pythonhosted.org/packages/42/dd/4e7e6920d23deffaf66e6f40d45f7610dcbc132ca5d90ab4faccef22f624/pytokens-0.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:4d0f568d7e82b7e96be56d03b5081de40e43c904eb6492bf09aaca47cd55f35b", size = 102620, upload-time = "2026-01-19T07:59:07.839Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/65/65460ebbfefd0bc1b160457904370d44f269e6e4582e0a9b6cba7c267b04/pytokens-0.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cd8da894e5a29ba6b6da8be06a4f7589d7220c099b5e363cb0643234b9b38c2a", size = 159864, upload-time = "2026-01-19T07:59:08.908Z" },
+ { url = "https://files.pythonhosted.org/packages/25/70/a46669ec55876c392036b4da9808b5c3b1c5870bbca3d4cc923bf68bdbc1/pytokens-0.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:237ba7cfb677dbd3b01b09860810aceb448871150566b93cd24501d5734a04b1", size = 254448, upload-time = "2026-01-19T07:59:10.594Z" },
+ { url = "https://files.pythonhosted.org/packages/62/0b/c486fc61299c2fc3b7f88ee4e115d4c8b6ffd1a7f88dc94b398b5b1bc4b8/pytokens-0.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01d1a61e36812e4e971cfe2c0e4c1f2d66d8311031dac8bf168af8a249fa04dd", size = 268863, upload-time = "2026-01-19T07:59:12.31Z" },
+ { url = "https://files.pythonhosted.org/packages/79/92/b036af846707d25feaff7cafbd5280f1bd6a1034c16bb06a7c910209c1ab/pytokens-0.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e47e2ef3ec6ee86909e520d79f965f9b23389fda47460303cf715d510a6fe544", size = 267181, upload-time = "2026-01-19T07:59:13.856Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/c0/6d011fc00fefa74ce34816c84a923d2dd7c46b8dbc6ee52d13419786834c/pytokens-0.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:3d36954aba4557fd5a418a03cf595ecbb1cdcce119f91a49b19ef09d691a22ae", size = 102814, upload-time = "2026-01-19T07:59:15.288Z" },
+ { url = "https://files.pythonhosted.org/packages/98/63/627b7e71d557383da5a97f473ad50f8d9c2c1f55c7d3c2531a120c796f6e/pytokens-0.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73eff3bdd8ad08da679867992782568db0529b887bed4c85694f84cdf35eafc6", size = 159744, upload-time = "2026-01-19T07:59:16.88Z" },
+ { url = "https://files.pythonhosted.org/packages/28/d7/16f434c37ec3824eba6bcb6e798e5381a8dc83af7a1eda0f95c16fe3ade5/pytokens-0.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d97cc1f91b1a8e8ebccf31c367f28225699bea26592df27141deade771ed0afb", size = 253207, upload-time = "2026-01-19T07:59:18.069Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/96/04102856b9527701ae57d74a6393d1aca5bad18a1b1ca48ccffb3c93b392/pytokens-0.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c8952c537cb73a1a74369501a83b7f9d208c3cf92c41dd88a17814e68d48ce", size = 267452, upload-time = "2026-01-19T07:59:19.328Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/ef/0936eb472b89ab2d2c2c24bb81c50417e803fa89c731930d9fb01176fe9f/pytokens-0.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5dbf56f3c748aed9310b310d5b8b14e2c96d3ad682ad5a943f381bdbbdddf753", size = 265965, upload-time = "2026-01-19T07:59:20.613Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/f5/64f3d6f7df4a9e92ebda35ee85061f6260e16eac82df9396020eebbca775/pytokens-0.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:e131804513597f2dff2b18f9911d9b6276e21ef3699abeffc1c087c65a3d975e", size = 102813, upload-time = "2026-01-19T07:59:22.012Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/f1/d07e6209f18ef378fc2ae9dee8d1dfe91fd2447c2e2dbfa32867b6dd30cf/pytokens-0.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0d7374c917197106d3c4761374718bc55ea2e9ac0fb94171588ef5840ee1f016", size = 159968, upload-time = "2026-01-19T07:59:23.07Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/73/0eb111400abd382a04f253b269819db9fcc748aa40748441cebdcb6d068f/pytokens-0.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cd3fa1caf9e47a72ee134a29ca6b5bea84712724bba165d6628baa190c6ea5b", size = 253373, upload-time = "2026-01-19T07:59:24.381Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/8d/9e4e2fdb5bcaba679e54afcc304e9f13f488eb4d626e6b613f9553e03dbd/pytokens-0.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c6986576b7b07fe9791854caa5347923005a80b079d45b63b0be70d50cce5f1", size = 267024, upload-time = "2026-01-19T07:59:25.74Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/b7/e0a370321af2deb772cff14ff337e1140d1eac2c29a8876bfee995f486f0/pytokens-0.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9940f7c2e2f54fb1cb5fe17d0803c54da7a2bf62222704eb4217433664a186a7", size = 270912, upload-time = "2026-01-19T07:59:27.072Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/54/4348f916c440d4c3e68b53b4ed0e66b292d119e799fa07afa159566dcc86/pytokens-0.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:54691cf8f299e7efabcc25adb4ce715d3cef1491e1c930eaf555182f898ef66a", size = 103836, upload-time = "2026-01-19T07:59:28.112Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/f8/a693c0cfa9c783a2a8c4500b7b2a8bab420f8ca4f2d496153226bf1c12e3/pytokens-0.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:94ff5db97a0d3cd7248a5b07ba2167bd3edc1db92f76c6db00137bbaf068ddf8", size = 167643, upload-time = "2026-01-19T07:59:29.292Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/dd/a64eb1e9f3ec277b69b33ef1b40ffbcc8f0a3bafcde120997efc7bdefebf/pytokens-0.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0dd6261cd9cc95fae1227b1b6ebee023a5fd4a4b6330b071c73a516f5f59b63", size = 289553, upload-time = "2026-01-19T07:59:30.537Z" },
+ { url = "https://files.pythonhosted.org/packages/df/22/06c1079d93dbc3bca5d013e1795f3d8b9ed6c87290acd6913c1c526a6bb2/pytokens-0.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cdca8159df407dbd669145af4171a0d967006e0be25f3b520896bc7068f02c4", size = 302490, upload-time = "2026-01-19T07:59:32.352Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/de/a6f5e43115b4fbf4b93aa87d6c83c79932cdb084f9711daae04549e1e4ad/pytokens-0.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4b5770abeb2a24347380a1164a558f0ebe06e98aedbd54c45f7929527a5fb26e", size = 305652, upload-time = "2026-01-19T07:59:33.685Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/3d/c136e057cb622e36e0c3ff7a8aaa19ff9720050c4078235691da885fe6ee/pytokens-0.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:74500d72c561dad14c037a9e86a657afd63e277dd5a3bb7570932ab7a3b12551", size = 115472, upload-time = "2026-01-19T07:59:34.734Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/3c/6941a82f4f130af6e1c68c076b6789069ef10c04559bd4733650f902fd3b/pytokens-0.4.0-py3-none-any.whl", hash = "sha256:0508d11b4de157ee12063901603be87fb0253e8f4cb9305eb168b1202ab92068", size = 13224, upload-time = "2026-01-19T07:59:49.822Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
+ { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
+ { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
+ { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
+ { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "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 = "regex"
+version = "2026.1.15"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d0/c9/0c80c96eab96948363d270143138d671d5731c3a692b417629bf3492a9d6/regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a", size = 488168, upload-time = "2026-01-14T23:14:16.129Z" },
+ { url = "https://files.pythonhosted.org/packages/17/f0/271c92f5389a552494c429e5cc38d76d1322eb142fb5db3c8ccc47751468/regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f", size = 290636, upload-time = "2026-01-14T23:14:17.715Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/f9/5f1fd077d106ca5655a0f9ff8f25a1ab55b92128b5713a91ed7134ff688e/regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1", size = 288496, upload-time = "2026-01-14T23:14:19.326Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/e1/8f43b03a4968c748858ec77f746c286d81f896c2e437ccf050ebc5d3128c/regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b", size = 793503, upload-time = "2026-01-14T23:14:20.922Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/4e/a39a5e8edc5377a46a7c875c2f9a626ed3338cb3bb06931be461c3e1a34a/regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8", size = 860535, upload-time = "2026-01-14T23:14:22.405Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/1c/9dce667a32a9477f7a2869c1c767dc00727284a9fa3ff5c09a5c6c03575e/regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413", size = 907225, upload-time = "2026-01-14T23:14:23.897Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/3c/87ca0a02736d16b6262921425e84b48984e77d8e4e572c9072ce96e66c30/regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026", size = 800526, upload-time = "2026-01-14T23:14:26.039Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/ff/647d5715aeea7c87bdcbd2f578f47b415f55c24e361e639fe8c0cc88878f/regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785", size = 773446, upload-time = "2026-01-14T23:14:28.109Z" },
+ { url = "https://files.pythonhosted.org/packages/af/89/bf22cac25cb4ba0fe6bff52ebedbb65b77a179052a9d6037136ae93f42f4/regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e", size = 783051, upload-time = "2026-01-14T23:14:29.929Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/f4/6ed03e71dca6348a5188363a34f5e26ffd5db1404780288ff0d79513bce4/regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763", size = 854485, upload-time = "2026-01-14T23:14:31.366Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/9a/8e8560bd78caded8eb137e3e47612430a05b9a772caf60876435192d670a/regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb", size = 762195, upload-time = "2026-01-14T23:14:32.802Z" },
+ { url = "https://files.pythonhosted.org/packages/38/6b/61fc710f9aa8dfcd764fe27d37edfaa023b1a23305a0d84fccd5adb346ea/regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2", size = 845986, upload-time = "2026-01-14T23:14:34.898Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/2e/fbee4cb93f9d686901a7ca8d94285b80405e8c34fe4107f63ffcbfb56379/regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1", size = 788992, upload-time = "2026-01-14T23:14:37.116Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/14/3076348f3f586de64b1ab75a3fbabdaab7684af7f308ad43be7ef1849e55/regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569", size = 265893, upload-time = "2026-01-14T23:14:38.426Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/19/772cf8b5fc803f5c89ba85d8b1870a1ca580dc482aa030383a9289c82e44/regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7", size = 277840, upload-time = "2026-01-14T23:14:39.785Z" },
+ { url = "https://files.pythonhosted.org/packages/78/84/d05f61142709474da3c0853222d91086d3e1372bcdab516c6fd8d80f3297/regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec", size = 270374, upload-time = "2026-01-14T23:14:41.592Z" },
+ { url = "https://files.pythonhosted.org/packages/92/81/10d8cf43c807d0326efe874c1b79f22bfb0fb226027b0b19ebc26d301408/regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1", size = 489398, upload-time = "2026-01-14T23:14:43.741Z" },
+ { url = "https://files.pythonhosted.org/packages/90/b0/7c2a74e74ef2a7c32de724658a69a862880e3e4155cba992ba04d1c70400/regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681", size = 291339, upload-time = "2026-01-14T23:14:45.183Z" },
+ { url = "https://files.pythonhosted.org/packages/19/4d/16d0773d0c818417f4cc20aa0da90064b966d22cd62a8c46765b5bd2d643/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f", size = 289003, upload-time = "2026-01-14T23:14:47.25Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/e4/1fc4599450c9f0863d9406e944592d968b8d6dfd0d552a7d569e43bceada/regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa", size = 798656, upload-time = "2026-01-14T23:14:48.77Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/e6/59650d73a73fa8a60b3a590545bfcf1172b4384a7df2e7fe7b9aab4e2da9/regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804", size = 864252, upload-time = "2026-01-14T23:14:50.528Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/ab/1d0f4d50a1638849a97d731364c9a80fa304fec46325e48330c170ee8e80/regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c", size = 912268, upload-time = "2026-01-14T23:14:52.952Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/df/0d722c030c82faa1d331d1921ee268a4e8fb55ca8b9042c9341c352f17fa/regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5", size = 803589, upload-time = "2026-01-14T23:14:55.182Z" },
+ { url = "https://files.pythonhosted.org/packages/66/23/33289beba7ccb8b805c6610a8913d0131f834928afc555b241caabd422a9/regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3", size = 775700, upload-time = "2026-01-14T23:14:56.707Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/65/bf3a42fa6897a0d3afa81acb25c42f4b71c274f698ceabd75523259f6688/regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb", size = 787928, upload-time = "2026-01-14T23:14:58.312Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f5/13bf65864fc314f68cdd6d8ca94adcab064d4d39dbd0b10fef29a9da48fc/regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410", size = 858607, upload-time = "2026-01-14T23:15:00.657Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/31/040e589834d7a439ee43fb0e1e902bc81bd58a5ba81acffe586bb3321d35/regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4", size = 763729, upload-time = "2026-01-14T23:15:02.248Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/84/6921e8129687a427edf25a34a5594b588b6d88f491320b9de5b6339a4fcb/regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d", size = 850697, upload-time = "2026-01-14T23:15:03.878Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/87/3d06143d4b128f4229158f2de5de6c8f2485170c7221e61bf381313314b2/regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22", size = 789849, upload-time = "2026-01-14T23:15:06.102Z" },
+ { url = "https://files.pythonhosted.org/packages/77/69/c50a63842b6bd48850ebc7ab22d46e7a2a32d824ad6c605b218441814639/regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913", size = 266279, upload-time = "2026-01-14T23:15:07.678Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/36/39d0b29d087e2b11fd8191e15e81cce1b635fcc845297c67f11d0d19274d/regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a", size = 277166, upload-time = "2026-01-14T23:15:09.257Z" },
+ { url = "https://files.pythonhosted.org/packages/28/32/5b8e476a12262748851fa8ab1b0be540360692325975b094e594dfebbb52/regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056", size = 270415, upload-time = "2026-01-14T23:15:10.743Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/2e/6870bb16e982669b674cce3ee9ff2d1d46ab80528ee6bcc20fb2292efb60/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e", size = 489164, upload-time = "2026-01-14T23:15:13.962Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/67/9774542e203849b0286badf67199970a44ebdb0cc5fb739f06e47ada72f8/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10", size = 291218, upload-time = "2026-01-14T23:15:15.647Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/87/b0cda79f22b8dee05f774922a214da109f9a4c0eca5da2c9d72d77ea062c/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc", size = 288895, upload-time = "2026-01-14T23:15:17.788Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/6a/0041f0a2170d32be01ab981d6346c83a8934277d82c780d60b127331f264/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599", size = 798680, upload-time = "2026-01-14T23:15:19.342Z" },
+ { url = "https://files.pythonhosted.org/packages/58/de/30e1cfcdbe3e891324aa7568b7c968771f82190df5524fabc1138cb2d45a/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae", size = 864210, upload-time = "2026-01-14T23:15:22.005Z" },
+ { url = "https://files.pythonhosted.org/packages/64/44/4db2f5c5ca0ccd40ff052ae7b1e9731352fcdad946c2b812285a7505ca75/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5", size = 912358, upload-time = "2026-01-14T23:15:24.569Z" },
+ { url = "https://files.pythonhosted.org/packages/79/b6/e6a5665d43a7c42467138c8a2549be432bad22cbd206f5ec87162de74bd7/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6", size = 803583, upload-time = "2026-01-14T23:15:26.526Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/53/7cd478222169d85d74d7437e74750005e993f52f335f7c04ff7adfda3310/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788", size = 775782, upload-time = "2026-01-14T23:15:29.352Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/b5/75f9a9ee4b03a7c009fe60500fe550b45df94f0955ca29af16333ef557c5/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714", size = 787978, upload-time = "2026-01-14T23:15:31.295Z" },
+ { url = "https://files.pythonhosted.org/packages/72/b3/79821c826245bbe9ccbb54f6eadb7879c722fd3e0248c17bfc90bf54e123/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d", size = 858550, upload-time = "2026-01-14T23:15:33.558Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/85/2ab5f77a1c465745bfbfcb3ad63178a58337ae8d5274315e2cc623a822fa/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3", size = 763747, upload-time = "2026-01-14T23:15:35.206Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/84/c27df502d4bfe2873a3e3a7cf1bdb2b9cc10284d1a44797cf38bed790470/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31", size = 850615, upload-time = "2026-01-14T23:15:37.523Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/b7/658a9782fb253680aa8ecb5ccbb51f69e088ed48142c46d9f0c99b46c575/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3", size = 789951, upload-time = "2026-01-14T23:15:39.582Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/2a/5928af114441e059f15b2f63e188bd00c6529b3051c974ade7444b85fcda/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f", size = 266275, upload-time = "2026-01-14T23:15:42.108Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/16/5bfbb89e435897bff28cf0352a992ca719d9e55ebf8b629203c96b6ce4f7/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e", size = 277145, upload-time = "2026-01-14T23:15:44.244Z" },
+ { url = "https://files.pythonhosted.org/packages/56/c1/a09ff7392ef4233296e821aec5f78c51be5e91ffde0d163059e50fd75835/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337", size = 270411, upload-time = "2026-01-14T23:15:45.858Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/38/0cfd5a78e5c6db00e6782fdae70458f89850ce95baa5e8694ab91d89744f/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be", size = 492068, upload-time = "2026-01-14T23:15:47.616Z" },
+ { url = "https://files.pythonhosted.org/packages/50/72/6c86acff16cb7c959c4355826bbf06aad670682d07c8f3998d9ef4fee7cd/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8", size = 292756, upload-time = "2026-01-14T23:15:49.307Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/58/df7fb69eadfe76526ddfce28abdc0af09ffe65f20c2c90932e89d705153f/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd", size = 291114, upload-time = "2026-01-14T23:15:51.484Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/6c/a4011cd1cf96b90d2cdc7e156f91efbd26531e822a7fbb82a43c1016678e/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a", size = 807524, upload-time = "2026-01-14T23:15:53.102Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/25/a53ffb73183f69c3e9f4355c4922b76d2840aee160af6af5fac229b6201d/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93", size = 873455, upload-time = "2026-01-14T23:15:54.956Z" },
+ { url = "https://files.pythonhosted.org/packages/66/0b/8b47fc2e8f97d9b4a851736f3890a5f786443aa8901061c55f24c955f45b/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af", size = 915007, upload-time = "2026-01-14T23:15:57.041Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/fa/97de0d681e6d26fabe71968dbee06dd52819e9a22fdce5dac7256c31ed84/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09", size = 812794, upload-time = "2026-01-14T23:15:58.916Z" },
+ { url = "https://files.pythonhosted.org/packages/22/38/e752f94e860d429654aa2b1c51880bff8dfe8f084268258adf9151cf1f53/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5", size = 781159, upload-time = "2026-01-14T23:16:00.817Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/a7/d739ffaef33c378fc888302a018d7f81080393d96c476b058b8c64fd2b0d/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794", size = 795558, upload-time = "2026-01-14T23:16:03.267Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/c4/542876f9a0ac576100fc73e9c75b779f5c31e3527576cfc9cb3009dcc58a/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a", size = 868427, upload-time = "2026-01-14T23:16:05.646Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/0f/d5655bea5b22069e32ae85a947aa564912f23758e112cdb74212848a1a1b/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80", size = 769939, upload-time = "2026-01-14T23:16:07.542Z" },
+ { url = "https://files.pythonhosted.org/packages/20/06/7e18a4fa9d326daeda46d471a44ef94201c46eaa26dbbb780b5d92cbfdda/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2", size = 854753, upload-time = "2026-01-14T23:16:10.395Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/67/dc8946ef3965e166f558ef3b47f492bc364e96a265eb4a2bb3ca765c8e46/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60", size = 799559, upload-time = "2026-01-14T23:16:12.347Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/61/1bba81ff6d50c86c65d9fd84ce9699dd106438ee4cdb105bf60374ee8412/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952", size = 268879, upload-time = "2026-01-14T23:16:14.049Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/5e/cef7d4c5fb0ea3ac5c775fd37db5747f7378b29526cc83f572198924ff47/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10", size = 280317, upload-time = "2026-01-14T23:16:15.718Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/52/4317f7a5988544e34ab57b4bde0f04944c4786128c933fb09825924d3e82/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829", size = 271551, upload-time = "2026-01-14T23:16:17.533Z" },
+ { url = "https://files.pythonhosted.org/packages/52/0a/47fa888ec7cbbc7d62c5f2a6a888878e76169170ead271a35239edd8f0e8/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac", size = 489170, upload-time = "2026-01-14T23:16:19.835Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6", size = 291146, upload-time = "2026-01-14T23:16:21.541Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/b6/921cc61982e538682bdf3bdf5b2c6ab6b34368da1f8e98a6c1ddc503c9cf/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2", size = 288986, upload-time = "2026-01-14T23:16:23.381Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/33/eb7383dde0bbc93f4fb9d03453aab97e18ad4024ac7e26cef8d1f0a2cff0/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846", size = 799098, upload-time = "2026-01-14T23:16:25.088Z" },
+ { url = "https://files.pythonhosted.org/packages/27/56/b664dccae898fc8d8b4c23accd853f723bde0f026c747b6f6262b688029c/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b", size = 864980, upload-time = "2026-01-14T23:16:27.297Z" },
+ { url = "https://files.pythonhosted.org/packages/16/40/0999e064a170eddd237bae9ccfcd8f28b3aa98a38bf727a086425542a4fc/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e", size = 911607, upload-time = "2026-01-14T23:16:29.235Z" },
+ { url = "https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde", size = 803358, upload-time = "2026-01-14T23:16:31.369Z" },
+ { url = "https://files.pythonhosted.org/packages/27/31/d4292ea8566eaa551fafc07797961c5963cf5235c797cc2ae19b85dfd04d/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5", size = 775833, upload-time = "2026-01-14T23:16:33.141Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/b2/cff3bf2fea4133aa6fb0d1e370b37544d18c8350a2fa118c7e11d1db0e14/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34", size = 788045, upload-time = "2026-01-14T23:16:35.005Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/99/2cb9b69045372ec877b6f5124bda4eb4253bc58b8fe5848c973f752bc52c/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75", size = 859374, upload-time = "2026-01-14T23:16:36.919Z" },
+ { url = "https://files.pythonhosted.org/packages/09/16/710b0a5abe8e077b1729a562d2f297224ad079f3a66dce46844c193416c8/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e", size = 763940, upload-time = "2026-01-14T23:16:38.685Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/d1/7585c8e744e40eb3d32f119191969b91de04c073fca98ec14299041f6e7e/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160", size = 850112, upload-time = "2026-01-14T23:16:40.646Z" },
+ { url = "https://files.pythonhosted.org/packages/af/d6/43e1dd85df86c49a347aa57c1f69d12c652c7b60e37ec162e3096194a278/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1", size = 789586, upload-time = "2026-01-14T23:16:42.799Z" },
+ { url = "https://files.pythonhosted.org/packages/93/38/77142422f631e013f316aaae83234c629555729a9fbc952b8a63ac91462a/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1", size = 271691, upload-time = "2026-01-14T23:16:44.671Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903", size = 280422, upload-time = "2026-01-14T23:16:46.607Z" },
+ { url = "https://files.pythonhosted.org/packages/be/2a/20fd057bf3521cb4791f69f869635f73e0aaf2b9ad2d260f728144f9047c/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705", size = 273467, upload-time = "2026-01-14T23:16:48.967Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/77/0b1e81857060b92b9cad239104c46507dd481b3ff1fa79f8e7f865aae38a/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8", size = 492073, upload-time = "2026-01-14T23:16:51.154Z" },
+ { url = "https://files.pythonhosted.org/packages/70/f3/f8302b0c208b22c1e4f423147e1913fd475ddd6230565b299925353de644/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf", size = 292757, upload-time = "2026-01-14T23:16:53.08Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/f0/ef55de2460f3b4a6da9d9e7daacd0cb79d4ef75c64a2af316e68447f0df0/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d", size = 291122, upload-time = "2026-01-14T23:16:55.383Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/55/bb8ccbacabbc3a11d863ee62a9f18b160a83084ea95cdfc5d207bfc3dd75/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84", size = 807761, upload-time = "2026-01-14T23:16:57.251Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/84/f75d937f17f81e55679a0509e86176e29caa7298c38bd1db7ce9c0bf6075/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df", size = 873538, upload-time = "2026-01-14T23:16:59.349Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/d9/0da86327df70349aa8d86390da91171bd3ca4f0e7c1d1d453a9c10344da3/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434", size = 915066, upload-time = "2026-01-14T23:17:01.607Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/5e/f660fb23fc77baa2a61aa1f1fe3a4eea2bbb8a286ddec148030672e18834/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a", size = 812938, upload-time = "2026-01-14T23:17:04.366Z" },
+ { url = "https://files.pythonhosted.org/packages/69/33/a47a29bfecebbbfd1e5cd3f26b28020a97e4820f1c5148e66e3b7d4b4992/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10", size = 781314, upload-time = "2026-01-14T23:17:06.378Z" },
+ { url = "https://files.pythonhosted.org/packages/65/ec/7ec2bbfd4c3f4e494a24dec4c6943a668e2030426b1b8b949a6462d2c17b/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac", size = 795652, upload-time = "2026-01-14T23:17:08.521Z" },
+ { url = "https://files.pythonhosted.org/packages/46/79/a5d8651ae131fe27d7c521ad300aa7f1c7be1dbeee4d446498af5411b8a9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea", size = 868550, upload-time = "2026-01-14T23:17:10.573Z" },
+ { url = "https://files.pythonhosted.org/packages/06/b7/25635d2809664b79f183070786a5552dd4e627e5aedb0065f4e3cf8ee37d/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e", size = 769981, upload-time = "2026-01-14T23:17:12.871Z" },
+ { url = "https://files.pythonhosted.org/packages/16/8b/fc3fcbb2393dcfa4a6c5ffad92dc498e842df4581ea9d14309fcd3c55fb9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521", size = 854780, upload-time = "2026-01-14T23:17:14.837Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/38/dde117c76c624713c8a2842530be9c93ca8b606c0f6102d86e8cd1ce8bea/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db", size = 799778, upload-time = "2026-01-14T23:17:17.369Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/0d/3a6cfa9ae99606afb612d8fb7a66b245a9d5ff0f29bb347c8a30b6ad561b/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e", size = 274667, upload-time = "2026-01-14T23:17:19.301Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/b2/297293bb0742fd06b8d8e2572db41a855cdf1cae0bf009b1cb74fe07e196/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf", size = 284386, upload-time = "2026-01-14T23:17:21.231Z" },
+ { url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837, upload-time = "2026-01-14T23:17:23.146Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.32.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
+]
+
+[[package]]
+name = "requests-toolbelt"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
+]
+
+[[package]]
+name = "rich"
+version = "14.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a1/84/4831f881aa6ff3c976f6d6809b58cdfa350593ffc0dc3c58f5f6586780fb/rich-14.3.1.tar.gz", hash = "sha256:b8c5f568a3a749f9290ec6bddedf835cec33696bfc1e48bcfecb276c7386e4b8", size = 230125, upload-time = "2026-01-24T21:40:44.847Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/87/2a/a1810c8627b9ec8c57ec5ec325d306701ae7be50235e8fd81266e002a3cc/rich-14.3.1-py3-none-any.whl", hash = "sha256:da750b1aebbff0b372557426fb3f35ba56de8ef954b3190315eb64076d6fb54e", size = 309952, upload-time = "2026-01-24T21:40:42.969Z" },
+]
+
+[[package]]
+name = "rsa"
+version = "4.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyasn1" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
+]
+
+[[package]]
+name = "s3transfer"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "botocore" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" },
+]
+
+[[package]]
+name = "sentry-sdk"
+version = "2.51.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6f/9f/094bbb6be5cf218ab6712c6528310687f3d3fe8818249fcfe1d74192f7c5/sentry_sdk-2.51.0.tar.gz", hash = "sha256:b89d64577075fd8c13088bc3609a2ce77a154e5beb8cba7cc16560b0539df4f7", size = 407447, upload-time = "2026-01-28T10:29:50.962Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/da/df379404d484ca9dede4ad8abead5de828cdcff35623cd44f0351cf6869c/sentry_sdk-2.51.0-py2.py3-none-any.whl", hash = "sha256:e21016d318a097c2b617bb980afd9fc737e1efc55f9b4f0cdc819982c9717d5f", size = 431426, upload-time = "2026-01-28T10:29:48.868Z" },
+]
+
+[[package]]
+name = "shellingham"
+version = "1.5.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
+]
+
+[[package]]
+name = "syrupy"
+version = "5.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2e/b0/24bca682d6a6337854be37f242d116cceeda9942571d5804c44bc1bdd427/syrupy-5.1.0.tar.gz", hash = "sha256:df543c7aa50d3cf1246e83d58fe490afe5f7dab7b41e74ecc0d8d23ae19bd4b8", size = 50495, upload-time = "2026-01-25T14:53:06.2Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/70/cf880c3b95a6034ef673e74b369941b42315c01f1554a5637a4f8b911009/syrupy-5.1.0-py3-none-any.whl", hash = "sha256:95162d2b05e61ed3e13f117b88dfab7c58bd6f90e66ebbf918e8a77114ad51c5", size = 51658, upload-time = "2026-01-25T14:53:05.105Z" },
+]
+
+[[package]]
+name = "tabulate"
+version = "0.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" },
+]
+
+[[package]]
+name = "tblib"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/64/e3/d9aebe40d15d2c4c73a0ff8555326ef42a62ce3e5320ceb1aa762e4fbb54/tblib-2.0.0.tar.gz", hash = "sha256:a6df30f272c08bf8be66e0775fad862005d950a6b8449b94f7c788731d70ecd7", size = 28695, upload-time = "2023-06-22T08:24:16.494Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/bd/ccb241b97e39dd8ec143418f89b3fd5752f872c862877a0f1b2d9fb9e815/tblib-2.0.0-py3-none-any.whl", hash = "sha256:9100bfa016b047d5b980d66e7efed952fbd20bd85b56110aaf473cb97d18709a", size = 11455, upload-time = "2023-06-22T08:24:14.248Z" },
+]
+
+[[package]]
+name = "tenacity"
+version = "9.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" },
+]
+
+[[package]]
+name = "tiktoken"
+version = "0.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "regex" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/46/21ea696b21f1d6d1efec8639c204bdf20fde8bafb351e1355c72c5d7de52/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb", size = 1051565, upload-time = "2025-10-06T20:21:44.566Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/d9/35c5d2d9e22bb2a5f74ba48266fb56c63d76ae6f66e02feb628671c0283e/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa", size = 995284, upload-time = "2025-10-06T20:21:45.622Z" },
+ { url = "https://files.pythonhosted.org/packages/01/84/961106c37b8e49b9fdcf33fe007bb3a8fdcc380c528b20cc7fbba80578b8/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc", size = 1129201, upload-time = "2025-10-06T20:21:47.074Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/d0/3d9275198e067f8b65076a68894bb52fd253875f3644f0a321a720277b8a/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded", size = 1152444, upload-time = "2025-10-06T20:21:48.139Z" },
+ { url = "https://files.pythonhosted.org/packages/78/db/a58e09687c1698a7c592e1038e01c206569b86a0377828d51635561f8ebf/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd", size = 1195080, upload-time = "2025-10-06T20:21:49.246Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/1b/a9e4d2bf91d515c0f74afc526fd773a812232dd6cda33ebea7f531202325/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967", size = 1255240, upload-time = "2025-10-06T20:21:50.274Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/15/963819345f1b1fb0809070a79e9dd96938d4ca41297367d471733e79c76c/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def", size = 879422, upload-time = "2025-10-06T20:21:51.734Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" },
+ { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" },
+ { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" },
+ { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" },
+ { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" },
+ { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" },
+ { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" },
+ { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" },
+ { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" },
+ { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" },
+ { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" },
+ { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" },
+ { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" },
+ { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" },
+ { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" },
+ { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" },
+]
+
+[[package]]
+name = "together"
+version = "1.5.35"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohttp" },
+ { name = "black" },
+ { name = "click" },
+ { name = "eval-type-backport" },
+ { name = "filelock" },
+ { name = "numpy" },
+ { name = "pillow" },
+ { name = "pydantic" },
+ { name = "requests" },
+ { name = "rich" },
+ { name = "tabulate" },
+ { name = "tqdm" },
+ { name = "typer" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5a/1d/6c50e0e32af097d966723e63b8e5ee02cfb002a40b6095c8ac65d6c08fe8/together-1.5.35.tar.gz", hash = "sha256:db3fc7dbc04dca044f437cd28224432e17567e6650dc1afd09780b48c0187cff", size = 91037, upload-time = "2026-01-21T23:15:15.909Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/68/e6/cd079d92f9eab83cd48c932e9b1e5e7fe25d90576f913dde66373135c392/together-1.5.35-py3-none-any.whl", hash = "sha256:74b6192e26492dbce2570fb801f884e74739bae1045b20c5b070a71639d7d5fc", size = 120461, upload-time = "2026-01-21T23:15:14.054Z" },
+]
+
+[[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 = "tomlkit"
+version = "0.14.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" },
+]
+
+[[package]]
+name = "tqdm"
+version = "4.67.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
+]
+
+[[package]]
+name = "traceback-with-variables"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/34/b1/25ee53be3125145cef9385f159a44547b4a01e1b2d2828055ca69b7e18aa/traceback_with_variables-2.2.1.tar.gz", hash = "sha256:ea7c695f9b401762f68f75df0439d661112b8dbd58bcd6910e402cff925ad7e0", size = 26145, upload-time = "2025-10-24T13:39:35.141Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4a/06/fda9970d55fbbb7cd5cc856da2a9c693107e20f84386596da1f25b90a8cf/traceback_with_variables-2.2.1-py3-none-any.whl", hash = "sha256:ab6d75c72d26d61217962d11db44c98c62dccd2fedb2d4fb0ae4f9faf9db23c2", size = 22388, upload-time = "2025-10-24T13:29:32.712Z" },
+]
+
+[[package]]
+name = "typeid-python"
+version = "0.3.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "uuid-utils" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/83/d1b140e4941a05ab7f4ccc2a5466b37fa559f48a9d3684d8107a7511508f/typeid_python-0.3.9.tar.gz", hash = "sha256:7cf7ede21e6ba8f272981dbae504d1256261d03edab42fb05d36787dbfd589bd", size = 25201, upload-time = "2026-01-28T18:23:38.917Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5b/30/523b728eab3157d818818ac022579b37b2d5f7994eb6e2bb9636a08a712a/typeid_python-0.3.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c37992e3aeee2ca2d1c5412a8a1bbbff71454c7db30b343a40ac2ab7ffe0d892", size = 241454, upload-time = "2026-01-28T18:23:14.452Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/da/13d577f76f5fceb77dbdc94b8435d86fbbbe472d5fa86245a2ece22ece20/typeid_python-0.3.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5b72f9e75eb8ae229e56c00a25330fbb9881f76f9b3a67fd3e35470328db1078", size = 238088, upload-time = "2026-01-28T18:23:16.429Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/36/a81e397a341d51315288835a809c5038835f925da00d11dce3e10115693f/typeid_python-0.3.9-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b035ef63c243776783551963983d12146b134c52475edfc42a33ce0a9baea337", size = 271957, upload-time = "2026-01-28T18:23:18.236Z" },
+ { url = "https://files.pythonhosted.org/packages/03/06/dd85db0de10c6337e3e9fc457fda009f4cdac83fa873418c7e19c6041ecb/typeid_python-0.3.9-cp311-cp311-win_amd64.whl", hash = "sha256:751b0aaeeb5c8c0bd87d15c77ffe52df86c372d66572e6b2ec3fb2df056d790e", size = 132071, upload-time = "2026-01-28T18:23:19.583Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/7b/bb76c6016f862b9cb5a4e7d6cb3d1378cc342793644ba6a2022f05790dc6/typeid_python-0.3.9-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:fc193d8a17529c65915273a9734e7d724b8b279450d01b2559a77b8bd53f52da", size = 240430, upload-time = "2026-01-28T18:23:21.506Z" },
+ { url = "https://files.pythonhosted.org/packages/20/0c/f8834a0d465afc4ea194d43fe10dd11e23874f5c102cec260172108a5cdc/typeid_python-0.3.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7dedb9894dea2a39653822224af6292710a54af0c9f48ba896bea6c22b6bff06", size = 236578, upload-time = "2026-01-28T18:23:22.874Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/29/c9842c47610213821cddbb274d093e36dc5d4346195eb5f036856f7d15f3/typeid_python-0.3.9-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:20ce022fe7d6be11a0d332d794e75a056b5aa225969f4b8ef9dbe4a94e651f2c", size = 270059, upload-time = "2026-01-28T18:23:24.396Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/87/9c3ae9936491f29ff09dcdbfdc4476368cc75b09be515ab31c5a2519ea7d/typeid_python-0.3.9-cp312-cp312-win_amd64.whl", hash = "sha256:bba27b8d1708e9654b85ffbcc4fc89a7c1d6cea65fbdab7fb9069d8cd71c2acd", size = 131062, upload-time = "2026-01-28T18:23:25.832Z" },
+ { url = "https://files.pythonhosted.org/packages/37/d8/366968632ad71e09125761f5313289e001695424ae68847e5f6df1e22b7b/typeid_python-0.3.9-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:11521763da6308ce0119d5a145c51b0e9d22b04b93e151c3ba73d5a359ec714b", size = 240529, upload-time = "2026-01-28T18:23:27.555Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/43/dd71c7d5db5ca1bcf0f4c10771c45792655a4b4552cc4482d10a9fbc3838/typeid_python-0.3.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c8e32ba0b0da2f7520bc2d2b8301dfe56dc1a1094405751eb8a24d9a8b59c4a", size = 236828, upload-time = "2026-01-28T18:23:29.288Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/31/778a97d5f6dd0f0c4c8dd469a77be2bc8006ff76521bb27146a284ec0757/typeid_python-0.3.9-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:22c617e05319768e3cc8e0b4750238d4b1b5359746f0a0293e06dc4cd298bc80", size = 269743, upload-time = "2026-01-28T18:23:31.022Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/55/9891583b35aff55c9161005de8eb1444f928f1a4718e5a76fd8d86ec292d/typeid_python-0.3.9-cp313-cp313-win_amd64.whl", hash = "sha256:630cbcb17f3ac81ae346959cdf1ee7e69204e467bbfbf577fb6d98f38c769468", size = 130958, upload-time = "2026-01-28T18:23:32.358Z" },
+ { url = "https://files.pythonhosted.org/packages/67/00/8c53af3d3859aeac99a6907018dfc118191bb05e08be4e4f2ddcab2cf5f6/typeid_python-0.3.9-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4b88012e1ba62933f1b4161c0fb22e80b1047a45391cbe6ef1580ffebf95ed0a", size = 240337, upload-time = "2026-01-28T18:23:33.53Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/90/e451b06da5c28dc3ba7f08bd5d2cbe5dbd4b25e79c20eb60641ebb42c2c6/typeid_python-0.3.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:54e15462494f04c5bb3ea396ff65b3e1820eb80e9ffcd70575c3b2055dc4d3e9", size = 236451, upload-time = "2026-01-28T18:23:35.001Z" },
+ { url = "https://files.pythonhosted.org/packages/87/fd/3929b7ce31298cd2ae716e6e991a248e24641e483f9d66b16755939d83ff/typeid_python-0.3.9-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:f2ae659428e88f382fadfb3801f9aa0d4c0bec5a67205570ef95ce0ca81ed5b8", size = 269235, upload-time = "2026-01-28T18:23:36.419Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/56/f1548a9f0c9c71a8d67150f50bc3a2ef36a74b89f060ab90685ef07ea859/typeid_python-0.3.9-cp314-cp314-win_amd64.whl", hash = "sha256:1fc43c61b228c281c099e7d68824a75202dd81427cdb4f411b93383d8350c428", size = 130948, upload-time = "2026-01-28T18:23:37.705Z" },
+]
+
+[[package]]
+name = "typer"
+version = "0.19.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "rich" },
+ { name = "shellingham" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/21/ca/950278884e2ca20547ff3eb109478c6baf6b8cf219318e6bc4f666fad8e8/typer-0.19.2.tar.gz", hash = "sha256:9ad824308ded0ad06cc716434705f691d4ee0bfd0fb081839d2e426860e7fdca", size = 104755, upload-time = "2025-09-23T09:47:48.256Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/00/22/35617eee79080a5d071d0f14ad698d325ee6b3bf824fc0467c03b30e7fa8/typer-0.19.2-py3-none-any.whl", hash = "sha256:755e7e19670ffad8283db353267cb81ef252f595aa6834a0d1ca9312d9326cb9", size = 46748, upload-time = "2025-09-23T09:47:46.777Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
+
+[[package]]
+name = "tzdata"
+version = "2025.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
+]
+
+[[package]]
+name = "uuid-utils"
+version = "0.14.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/57/7c/3a926e847516e67bc6838634f2e54e24381105b4e80f9338dc35cca0086b/uuid_utils-0.14.0.tar.gz", hash = "sha256:fc5bac21e9933ea6c590433c11aa54aaca599f690c08069e364eb13a12f670b4", size = 22072, upload-time = "2026-01-20T20:37:15.729Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/42/42d003f4a99ddc901eef2fd41acb3694163835e037fb6dde79ad68a72342/uuid_utils-0.14.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:f6695c0bed8b18a904321e115afe73b34444bc8451d0ce3244a1ec3b84deb0e5", size = 601786, upload-time = "2026-01-20T20:37:09.843Z" },
+ { url = "https://files.pythonhosted.org/packages/96/e6/775dfb91f74b18f7207e3201eb31ee666d286579990dc69dd50db2d92813/uuid_utils-0.14.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4f0a730bbf2d8bb2c11b93e1005e91769f2f533fa1125ed1f00fd15b6fcc732b", size = 303943, upload-time = "2026-01-20T20:37:18.767Z" },
+ { url = "https://files.pythonhosted.org/packages/17/82/ea5f5e85560b08a1f30cdc65f75e76494dc7aba9773f679e7eaa27370229/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40ce3fd1a4fdedae618fc3edc8faf91897012469169d600133470f49fd699ed3", size = 340467, upload-time = "2026-01-20T20:37:11.794Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/33/54b06415767f4569882e99b6470c6c8eeb97422686a6d432464f9967fd91/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:09ae4a98416a440e78f7d9543d11b11cae4bab538b7ed94ec5da5221481748f2", size = 346333, upload-time = "2026-01-20T20:37:12.818Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/10/a6bce636b8f95e65dc84bf4a58ce8205b8e0a2a300a38cdbc83a3f763d27/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:971e8c26b90d8ae727e7f2ac3ee23e265971d448b3672882f2eb44828b2b8c3e", size = 470859, upload-time = "2026-01-20T20:37:01.512Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/27/84121c51ea72f013f0e03d0886bcdfa96b31c9b83c98300a7bd5cc4fa191/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5cde1fa82804a8f9d2907b7aec2009d440062c63f04abbdb825fce717a5e860", size = 341988, upload-time = "2026-01-20T20:37:22.881Z" },
+ { url = "https://files.pythonhosted.org/packages/90/a4/01c1c7af5e6a44f20b40183e8dac37d6ed83e7dc9e8df85370a15959b804/uuid_utils-0.14.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7343862a2359e0bd48a7f3dfb5105877a1728677818bb694d9f40703264a2db", size = 365784, upload-time = "2026-01-20T20:37:10.808Z" },
+ { url = "https://files.pythonhosted.org/packages/04/f0/65ee43ec617b8b6b1bf2a5aecd56a069a08cca3d9340c1de86024331bde3/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c51e4818fdb08ccec12dc7083a01f49507b4608770a0ab22368001685d59381b", size = 523750, upload-time = "2026-01-20T20:37:06.152Z" },
+ { url = "https://files.pythonhosted.org/packages/95/d3/6bf503e3f135a5dfe705a65e6f89f19bccd55ac3fb16cb5d3ec5ba5388b8/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:181bbcccb6f93d80a8504b5bd47b311a1c31395139596edbc47b154b0685b533", size = 615818, upload-time = "2026-01-20T20:37:21.816Z" },
+ { url = "https://files.pythonhosted.org/packages/df/6c/99937dd78d07f73bba831c8dc9469dfe4696539eba2fc269ae1b92752f9e/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:5c8ae96101c3524ba8dbf762b6f05e9e9d896544786c503a727c5bf5cb9af1a7", size = 580831, upload-time = "2026-01-20T20:37:19.691Z" },
+ { url = "https://files.pythonhosted.org/packages/44/fa/bbc9e2c25abd09a293b9b097a0d8fc16acd6a92854f0ec080f1ea7ad8bb3/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00ac3c6edfdaff7e1eed041f4800ae09a3361287be780d7610a90fdcde9befdc", size = 546333, upload-time = "2026-01-20T20:37:03.117Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/9b/e5e99b324b1b5f0c62882230455786df0bc66f67eff3b452447e703f45d2/uuid_utils-0.14.0-cp39-abi3-win32.whl", hash = "sha256:ec2fd80adf8e0e6589d40699e6f6df94c93edcc16dd999be0438dd007c77b151", size = 177319, upload-time = "2026-01-20T20:37:04.208Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/28/2c7d417ea483b6ff7820c948678fdf2ac98899dc7e43bb15852faa95acaf/uuid_utils-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:efe881eb43a5504fad922644cb93d725fd8a6a6d949bd5a4b4b7d1a1587c7fd1", size = 182566, upload-time = "2026-01-20T20:37:16.868Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/86/49e4bdda28e962fbd7266684171ee29b3d92019116971d58783e51770745/uuid_utils-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:32b372b8fd4ebd44d3a219e093fe981af4afdeda2994ee7db208ab065cfcd080", size = 182809, upload-time = "2026-01-20T20:37:05.139Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/03/1f1146e32e94d1f260dfabc81e1649102083303fb4ad549775c943425d9a/uuid_utils-0.14.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:762e8d67992ac4d2454e24a141a1c82142b5bde10409818c62adbe9924ebc86d", size = 587430, upload-time = "2026-01-20T20:37:24.998Z" },
+ { url = "https://files.pythonhosted.org/packages/87/ba/d5a7469362594d885fd9219fe9e851efbe65101d3ef1ef25ea321d7ce841/uuid_utils-0.14.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:40be5bf0b13aa849d9062abc86c198be6a25ff35316ce0b89fc25f3bac6d525e", size = 298106, upload-time = "2026-01-20T20:37:23.896Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/11/3dafb2a5502586f59fd49e93f5802cd5face82921b3a0f3abb5f357cb879/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:191a90a6f3940d1b7322b6e6cceff4dd533c943659e0a15f788674407856a515", size = 333423, upload-time = "2026-01-20T20:37:17.828Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f2/c8987663f0cdcf4d717a36d85b5db2a5589df0a4e129aa10f16f4380ef48/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4aa4525f4ad82f9d9c842f9a3703f1539c1808affbaec07bb1b842f6b8b96aa5", size = 338659, upload-time = "2026-01-20T20:37:14.286Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/c8/929d81665d83f0b2ffaecb8e66c3091a50f62c7cb5b65e678bd75a96684e/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdbd82ff20147461caefc375551595ecf77ebb384e46267f128aca45a0f2cdfc", size = 467029, upload-time = "2026-01-20T20:37:08.277Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/a0/27d7daa1bfed7163f4ccaf52d7d2f4ad7bb1002a85b45077938b91ee584f/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eff57e8a5d540006ce73cf0841a643d445afe78ba12e75ac53a95ca2924a56be", size = 333298, upload-time = "2026-01-20T20:37:07.271Z" },
+ { url = "https://files.pythonhosted.org/packages/63/d4/acad86ce012b42ce18a12f31ee2aa3cbeeb98664f865f05f68c882945913/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3fd9112ca96978361201e669729784f26c71fecc9c13a7f8a07162c31bd4d1e2", size = 359217, upload-time = "2026-01-20T20:36:59.687Z" },
+]
+
+[[package]]
+name = "websockets"
+version = "15.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
+ { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
+ { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
+ { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
+ { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
+ { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
+ { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
+ { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
+ { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
+ { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
+ { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
+ { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
+ { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
+ { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
+ { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
+ { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
+]
+
+[[package]]
+name = "win32-setctime"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
+]
+
+[[package]]
+name = "yarl"
+version = "1.22.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "multidict" },
+ { name = "propcache" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" },
+ { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" },
+ { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" },
+ { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" },
+ { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" },
+ { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" },
+ { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" },
+ { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" },
+ { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" },
+ { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" },
+ { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" },
+ { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" },
+ { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" },
+ { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" },
+ { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" },
+ { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" },
+ { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" },
+ { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" },
+ { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" },
+ { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" },
+ { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" },
+ { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" },
+ { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" },
+ { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" },
+ { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" },
+ { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" },
+ { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" },
+ { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" },
+ { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" },
+ { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" },
+ { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" },
+ { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" },
+ { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" },
+ { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" },
+ { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" },
+ { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" },
+ { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" },
+ { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" },
+ { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" },
+ { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" },
+ { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" },
+ { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" },
+ { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" },
+ { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" },
+ { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" },
+ { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" },
+ { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" },
+ { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" },
+]
+
+[[package]]
+name = "yasoo"
+version = "0.12.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "more-itertools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cf/00/a1ed9035b00254227c684161c3d4037f767ed53a1993b69c00c9d4d94f25/yasoo-0.12.6.tar.gz", hash = "sha256:aec81e790045198e8f51f92353f11923580f1c94f49eed2b543f286e4cc1c5cc", size = 13705, upload-time = "2022-10-22T10:05:39.619Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/68/21/cfd73fd9cc69855bffdda55cbeefa45ffd415f97704f67ceda1eb57141ad/yasoo-0.12.6-py3-none-any.whl", hash = "sha256:7400ff055c2153d670c04c82621fd4aeafc3f1fc7c0f99240831752357b67e1f", size = 14850, upload-time = "2022-10-22T10:05:38.4Z" },
+]
diff --git a/imbue_verify/api.py b/imbue_verify/api.py
@@ -11,7 +11,9 @@ from loguru import logger
from imbue_core.data_types import IdentifiedVerifyIssue
from imbue_core.data_types import IssueIdentificationDebugInfo
from imbue_core.sculptor.state.messages import ConversationMessageUnion
-from imbue_tools.get_conversation_history.get_conversation_history import ConversationLoadingError
+from imbue_tools.get_conversation_history.get_conversation_history import (
+ ConversationLoadingError,
+)
from imbue_tools.get_conversation_history.input_data_types import IdentifierInputs
from imbue_tools.repo_utils.project_context import LazyProjectContext
from imbue_tools.repo_utils.project_context import ProjectContext
diff --git a/imbue_verify/cli/config/cli_config_test.py b/imbue_verify/cli/config/cli_config_test.py
@@ -180,7 +180,8 @@ def test_get_cli_config_file_paths_finds_git_root(tmp_path: Path) -> None:
def test_load_cli_config_file_loads_valid_toml(tmp_path: Path) -> None:
config_file = tmp_path / "config.toml"
- config_file.write_text("""
+ config_file.write_text(
+ """
[ci]
confidence_threshold = 0.9
max_workers = 4
@@ -189,7 +190,8 @@ quiet = true
[strict]
confidence_threshold = 0.6
model = "claude-4-sonnet"
-""")
+"""
+ )
result = _load_cli_config_file(config_file)
@@ -213,10 +215,12 @@ def test_load_cli_config_file_raises_on_invalid_toml(tmp_path: Path) -> None:
def test_load_cli_config_file_raises_on_invalid_schema(tmp_path: Path) -> None:
config_file = tmp_path / "config.toml"
- config_file.write_text("""
+ config_file.write_text(
+ """
[ci]
confidence_threshold = "not-a-float"
-""")
+"""
+ )
with pytest.raises(ConfigLoadError) as exc_info:
_load_cli_config_file(config_file)
@@ -226,10 +230,12 @@ confidence_threshold = "not-a-float"
def test_load_cli_config_file_raises_on_unknown_field(tmp_path: Path) -> None:
config_file = tmp_path / "config.toml"
- config_file.write_text("""
+ config_file.write_text(
+ """
[ci]
unknown_field = "value"
-""")
+"""
+ )
with pytest.raises(ConfigLoadError) as exc_info:
_load_cli_config_file(config_file)
@@ -248,10 +254,12 @@ def test_load_cli_config_loads_single_file(tmp_path: Path) -> None:
repo_path = tmp_path / "repo"
repo_path.mkdir()
config_file = repo_path / "imbue-verify.toml"
- config_file.write_text("""
+ config_file.write_text(
+ """
[ci]
confidence_threshold = 0.9
-""")
+"""
+ )
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(tmp_path / "nonexistent")}):
result = load_cli_config(repo_path=repo_path)
@@ -264,25 +272,29 @@ def test_load_cli_config_merges_global_and_project(tmp_path: Path) -> None:
xdg_config = tmp_path / "xdg"
(xdg_config / "imbue-verify").mkdir(parents=True)
global_config = xdg_config / "imbue-verify" / "config.toml"
- global_config.write_text("""
+ global_config.write_text(
+ """
[ci]
confidence_threshold = 0.8
max_workers = 2
[global-only]
model = "global-model"
-""")
+"""
+ )
repo_path = tmp_path / "repo"
repo_path.mkdir()
project_config = repo_path / "imbue-verify.toml"
- project_config.write_text("""
+ project_config.write_text(
+ """
[ci]
confidence_threshold = 0.9
[project-only]
model = "project-model"
-""")
+"""
+ )
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(xdg_config)}):
result = load_cli_config(repo_path=repo_path)
diff --git a/imbue_verify/cli/config/loader.py b/imbue_verify/cli/config/loader.py
@@ -50,7 +50,10 @@ def find_git_repo_root(start_path: Path) -> Path | None:
def _get_config_file_paths(
- global_subpath: str, global_filename: str, project_filename: str, repo_path: Path | None = None
+ global_subpath: str,
+ global_filename: str,
+ project_filename: str,
+ repo_path: Path | None = None,
) -> list[Path]:
paths = [get_xdg_config_home() / global_subpath / global_filename]
@@ -189,14 +192,14 @@ def load_cli_config(repo_path: Path | None = None) -> dict[str, CliConfigPreset]
def get_config_preset(
- config_name: str, cli_configs: dict[str, CliConfigPreset], repo_path: Path | None = None
+ config_name: str,
+ cli_configs: dict[str, CliConfigPreset],
+ repo_path: Path | None = None,
) -> CliConfigPreset:
if config_name not in cli_configs:
available = sorted(cli_configs.keys())
if available:
- raise ConfigLoadError(
- f"Configuration '{config_name}' not found. Available configs: {', '.join(available)}"
- )
+ raise ConfigLoadError(f"Configuration '{config_name}' not found. Available configs: {', '.join(available)}")
else:
paths = get_cli_config_file_paths(repo_path)
paths_list = "\n".join(f" - {p} ({'global' if i == 0 else 'project'})" for i, p in enumerate(paths))
diff --git a/imbue_verify/cli/config/loader_test.py b/imbue_verify/cli/config/loader_test.py
@@ -86,7 +86,11 @@ def test_load_single_config_file_loads_valid_config(tmp_path: Path) -> None:
"base_url": "http://localhost:8080/v1",
"api_key_env": "TEST_API_KEY",
"models": {
- "test-model": {"model_id": "test-model-v1", "context_window": 128000, "max_output_tokens": 16384}
+ "test-model": {
+ "model_id": "test-model-v1",
+ "context_window": 128000,
+ "max_output_tokens": 16384,
+ }
},
}
}
@@ -164,7 +168,12 @@ def test_load_models_config_loads_project_config(tmp_path: Path) -> None:
"project-provider": {
"base_url": "http://project:8080/v1",
"api_key_env": "PROJECT_KEY",
- "models": {"project-model": {"context_window": 128000, "max_output_tokens": 16384}},
+ "models": {
+ "project-model": {
+ "context_window": 128000,
+ "max_output_tokens": 16384,
+ }
+ },
}
}
}
@@ -188,7 +197,12 @@ def test_load_models_config_project_overrides_global(tmp_path: Path) -> None:
"name": "Global Name",
"base_url": "http://global:8080/v1",
"api_key_env": "GLOBAL_KEY",
- "models": {"global-model": {"context_window": 128000, "max_output_tokens": 16384}},
+ "models": {
+ "global-model": {
+ "context_window": 128000,
+ "max_output_tokens": 16384,
+ }
+ },
}
}
}
@@ -206,7 +220,12 @@ def test_load_models_config_project_overrides_global(tmp_path: Path) -> None:
"name": "Project Name",
"base_url": "http://project:8080/v1",
"api_key_env": "PROJECT_KEY",
- "models": {"project-model": {"context_window": 128000, "max_output_tokens": 16384}},
+ "models": {
+ "project-model": {
+ "context_window": 128000,
+ "max_output_tokens": 16384,
+ }
+ },
}
}
}
diff --git a/imbue_verify/cli/main.py b/imbue_verify/cli/main.py
@@ -13,7 +13,9 @@ from loguru import logger
from imbue_core.data_types import IssueCode
from imbue_core.log_utils import ensure_core_log_levels_configured
-from imbue_tools.get_conversation_history.get_conversation_history import parse_conversation_history
+from imbue_tools.get_conversation_history.get_conversation_history import (
+ parse_conversation_history,
+)
from imbue_tools.types.imbue_verify_config import ImbueVerifyConfig
from imbue_verify.api import find_issues
from imbue_verify.cli.config.cli_config_schema import CLI_DEFAULTS
@@ -451,8 +453,8 @@ def main(argv: list[str] | None = None) -> int:
config = ImbueVerifyConfig(
disabled_identifiers=("agentic_issue_identifier",),
language_model_generation_config=language_model_config,
- enabled_issue_codes=tuple(args.enabled_issue_codes) if args.enabled_issue_codes else None,
- disabled_issue_codes=tuple(args.disabled_issue_codes) if args.disabled_issue_codes else None,
+ enabled_issue_codes=(tuple(args.enabled_issue_codes) if args.enabled_issue_codes else None),
+ disabled_issue_codes=(tuple(args.disabled_issue_codes) if args.disabled_issue_codes else None),
temperature=args.temperature,
filter_issues_below_confidence=args.confidence_threshold,
max_identify_workers=args.max_workers,
diff --git a/imbue_verify/cli/models.py b/imbue_verify/cli/models.py
@@ -52,7 +52,9 @@ def get_builtin_models_by_provider() -> dict[str, list[str]]:
}
-def get_models_by_provider(user_config: ModelsConfig | None = None) -> dict[str, list[str]]:
+def get_models_by_provider(
+ user_config: ModelsConfig | None = None,
+) -> dict[str, list[str]]:
providers = get_builtin_models_by_provider()
if user_config:
diff --git a/imbue_verify/formatters.py b/imbue_verify/formatters.py
@@ -35,7 +35,7 @@ def issue_to_output(issue: IdentifiedVerifyIssue) -> IssueOutput:
return IssueOutput(
issue_code=str(issue.code),
- confidence=issue.confidence_score.normalized if issue.confidence_score else None,
+ confidence=(issue.confidence_score.normalized if issue.confidence_score else None),
file_path=issue.location[0].filename if issue.location else None,
line_number=issue.location[0].line_start if issue.location else None,
line_number_end=line_number_end,
diff --git a/imbue_verify/issue_identifiers/agentic_issue_collation.py b/imbue_verify/issue_identifiers/agentic_issue_collation.py
@@ -11,18 +11,24 @@ from imbue_core.data_types import IssueIdentificationLLMResponseMetadata
from imbue_core.data_types import LLMResponse
from imbue_tools.get_conversation_history.input_data_types import CommitInputs
from imbue_tools.get_conversation_history.input_data_types import IdentifierInputs
-from imbue_tools.get_conversation_history.input_data_types import to_specific_inputs_type
+from imbue_tools.get_conversation_history.input_data_types import (
+ to_specific_inputs_type,
+)
from imbue_tools.repo_utils.context_utils import escape_prompt_markers
from imbue_tools.repo_utils.project_context import ProjectContext
from imbue_tools.types.imbue_verify_config import ImbueVerifyConfig
from imbue_verify.issue_identifiers.common import GeneratedIssueSchema
from imbue_verify.issue_identifiers.common import GeneratedResponseSchema
from imbue_verify.issue_identifiers.common import extract_invocation_info_from_messages
-from imbue_verify.issue_identifiers.common import format_issue_identification_guide_for_llm
+from imbue_verify.issue_identifiers.common import (
+ format_issue_identification_guide_for_llm,
+)
from imbue_verify.issue_identifiers.common import generate_issues_from_response_texts
from imbue_verify.issue_identifiers.common import generate_response_from_claude_code
from imbue_verify.issue_identifiers.common import get_claude_code_options
-from imbue_verify.issue_identifiers.identification_guides import ISSUE_IDENTIFICATION_GUIDES_BY_ISSUE_CODE
+from imbue_verify.issue_identifiers.identification_guides import (
+ ISSUE_IDENTIFICATION_GUIDES_BY_ISSUE_CODE,
+)
from imbue_verify.issue_identifiers.utils import ReturnCapturingGenerator
COLLATION_PROMPT_TEMPLATE = """You are reviewing the results from parallel code analysis for potential issues.
@@ -96,7 +102,9 @@ def _get_collation_prompt(
return prompt
-def _convert_parsed_issues_to_combined_string(all_parsed_issues: Iterable[GeneratedIssueSchema]) -> str:
+def _convert_parsed_issues_to_combined_string(
+ all_parsed_issues: Iterable[GeneratedIssueSchema],
+) -> str:
"""Convert all parsed issues from all issue types to a combined string for collation prompt."""
combined_issues = []
@@ -142,7 +150,8 @@ def collate_issues_with_agent(
issue_generator_debug_info = issue_generator_with_capture.return_value
options = get_claude_code_options(
- cwd=project_context.repo_path, model_name=config.language_model_generation_config.model_name
+ cwd=project_context.repo_path,
+ model_name=config.language_model_generation_config.model_name,
)
combined_issues_string = _convert_parsed_issues_to_combined_string(all_issues)
collation_prompt = _get_collation_prompt(
diff --git a/imbue_verify/issue_identifiers/base.py b/imbue_verify/issue_identifiers/base.py
@@ -7,7 +7,9 @@ from imbue_core.data_types import IssueCode
from imbue_core.data_types import IssueIdentificationDebugInfo
from imbue_core.pydantic_serialization import SerializableModel
from imbue_tools.get_conversation_history.input_data_types import IdentifierInputs
-from imbue_tools.get_conversation_history.input_data_types import to_specific_inputs_type
+from imbue_tools.get_conversation_history.input_data_types import (
+ to_specific_inputs_type,
+)
from imbue_tools.repo_utils.project_context import ProjectContext
from imbue_tools.types.imbue_verify_config import ImbueVerifyConfig
from imbue_verify.issue_identifiers.common import GeneratedIssueSchema
@@ -26,7 +28,10 @@ class IssueIdentifier(SerializableModel, abc.ABC, Generic[T]):
@abc.abstractmethod
def identify_issues(
- self, identifier_inputs: T, project_context: ProjectContext, config: ImbueVerifyConfig
+ self,
+ identifier_inputs: T,
+ project_context: ProjectContext,
+ config: ImbueVerifyConfig,
) -> Generator[GeneratedIssueSchema, None, IssueIdentificationDebugInfo]:
"""
Identify issues given the identifier inputs.
diff --git a/imbue_verify/issue_identifiers/common.py b/imbue_verify/issue_identifiers/common.py
@@ -33,12 +33,16 @@ from imbue_core.data_types import IssueLocation
from imbue_core.data_types import LineRange
from imbue_core.data_types import SeverityScore
from imbue_core.pydantic_serialization import SerializableModel
-from imbue_core.sculptor.telemetry import send_exception_to_posthog
-from imbue_core.sculptor.telemetry_constants import SculptorPosthogEvent
-from imbue_tools.llm_output_parsing.parse_model_json_response import ResponseParsingError
-from imbue_tools.llm_output_parsing.parse_model_json_response import parse_model_json_response
+from imbue_tools.llm_output_parsing.parse_model_json_response import (
+ ResponseParsingError,
+)
+from imbue_tools.llm_output_parsing.parse_model_json_response import (
+ parse_model_json_response,
+)
from imbue_tools.repo_utils.project_context import ProjectContext
-from imbue_verify.issue_identifiers.identification_guides import IssueIdentificationGuide
+from imbue_verify.issue_identifiers.identification_guides import (
+ IssueIdentificationGuide,
+)
from imbue_verify.issue_identifiers.utils import ReturnCapturingGenerator
@@ -80,17 +84,15 @@ class GeneratedResponseSchema(SerializableModel):
issues: list[GeneratedIssueSchema] = Field(default_factory=list, description="List of identified issues")
-def generate_issues_from_response_texts(response_texts: Iterable[str]) -> Generator[GeneratedIssueSchema, None, None]:
+def generate_issues_from_response_texts(
+ response_texts: Iterable[str],
+) -> Generator[GeneratedIssueSchema, None, None]:
"""Generate IssueIdentifierResult objects from LLM response text."""
for response_text in response_texts:
try:
parsed_data = parse_model_json_response(response_text, GeneratedResponseSchema)
- except ResponseParsingError as e:
- send_exception_to_posthog(
- SculptorPosthogEvent.FAILED_TO_PARSE_LLM_RESPONSE_WHEN_GENERATING_ISSUES,
- e,
- message=f"Failed to parse response text: {response_text}",
- )
+ except ResponseParsingError:
+ logger.warning(f"Failed to parse response text: {response_text}")
continue
for raw_issue in parsed_data.issues:
@@ -136,13 +138,9 @@ def convert_generated_issue_to_identified_issue(
repo_path = project_context.repo_path
assert repo_path is not None
issue_location_path = issue_location_path.relative_to(repo_path)
- except ValueError as e:
+ except ValueError:
issue_location_path = None
- send_exception_to_posthog(
- SculptorPosthogEvent.INVALID_FILE_PATH_FROM_LLM_IN_ISSUE_LOCATION,
- e,
- message=f"Invalid location '{issue_location}', skipping line range detection.",
- )
+ logger.warning(f"Invalid location '{issue_location}', skipping line range detection.")
issue_code_part = issue_data.code_part
if issue_location_path and issue_code_part:
contents = project_context.file_contents_by_path.get(issue_location_path.as_posix())
@@ -155,11 +153,7 @@ def convert_generated_issue_to_identified_issue(
code_part_repr=repr(issue_code_part),
)
else:
- send_exception_to_posthog(
- SculptorPosthogEvent.INVALID_FILE_PATH_FROM_LLM_IN_ISSUE_LOCATION,
- KeyError(issue_location),
- message=f"Unknown location '{issue_location}', skipping line range detection.",
- )
+ logger.warning(f"Unknown location '{issue_location}', skipping line range detection.")
# Convert severity (1-5) to normalized score (0-1)
severity_normalized = (issue_data.severity - 1) / 4.0 # Map 1-5 to 0-1
@@ -193,7 +187,9 @@ def convert_to_issue_identifier_result(
generator_with_capture = ReturnCapturingGenerator(generator)
for issue_data in generator_with_capture:
issue = convert_generated_issue_to_identified_issue(
- issue_data=issue_data, project_context=project_context, enabled_issue_codes=enabled_issue_codes
+ issue_data=issue_data,
+ project_context=project_context,
+ enabled_issue_codes=enabled_issue_codes,
)
if issue:
yield IssueIdentifierResult(issue=issue, passes_filtration=issue_data.passes_filtration)
@@ -277,7 +273,9 @@ def extract_invocation_info_from_costed_response(
)
-def extract_invocation_info_from_messages(messages: list[AgentMessage]) -> InvocationInfo:
+def extract_invocation_info_from_messages(
+ messages: list[AgentMessage],
+) -> InvocationInfo:
"""Extract invocation information from Agent messages."""
for message in messages:
if isinstance(message, AgentResultMessage):
diff --git a/imbue_verify/issue_identifiers/common_test.py b/imbue_verify/issue_identifiers/common_test.py
@@ -7,19 +7,33 @@ from imbue_core.data_types import IdentifiedVerifyIssue
from imbue_core.data_types import IssueCode
from imbue_core.frozen_utils import FrozenDict
from imbue_core.itertools import only
-from imbue_tools.llm_output_parsing.parse_model_json_response import ResponseParsingError
-from imbue_tools.llm_output_parsing.parse_model_json_response import parse_model_json_response
+from imbue_tools.llm_output_parsing.parse_model_json_response import (
+ ResponseParsingError,
+)
+from imbue_tools.llm_output_parsing.parse_model_json_response import (
+ parse_model_json_response,
+)
from imbue_tools.repo_utils.project_context import BaseProjectContext
from imbue_tools.repo_utils.project_context import ProjectContext
from imbue_verify.issue_identifiers.common import GeneratedResponseSchema
-from imbue_verify.issue_identifiers.common import convert_generated_issue_to_identified_issue
-from imbue_verify.issue_identifiers.common import format_issue_identification_guide_for_llm
-from imbue_verify.issue_identifiers.identification_guides import ISSUE_CODES_FOR_CORRECTNESS_CHECK
-from imbue_verify.issue_identifiers.identification_guides import IssueIdentificationGuide
+from imbue_verify.issue_identifiers.common import (
+ convert_generated_issue_to_identified_issue,
+)
+from imbue_verify.issue_identifiers.common import (
+ format_issue_identification_guide_for_llm,
+)
+from imbue_verify.issue_identifiers.identification_guides import (
+ ISSUE_CODES_FOR_CORRECTNESS_CHECK,
+)
+from imbue_verify.issue_identifiers.identification_guides import (
+ IssueIdentificationGuide,
+)
def _parse_issues(
- valid_response: str, project_context: ProjectContext, enabled_issue_codes: Iterable[IssueCode]
+ valid_response: str,
+ project_context: ProjectContext,
+ enabled_issue_codes: Iterable[IssueCode],
) -> list[IdentifiedVerifyIssue]:
issues = []
try:
@@ -28,7 +42,9 @@ def _parse_issues(
return []
for parsed_issue in issue_data.issues:
issue = convert_generated_issue_to_identified_issue(
- issue_data=parsed_issue, project_context=project_context, enabled_issue_codes=tuple(enabled_issue_codes)
+ issue_data=parsed_issue,
+ project_context=project_context,
+ enabled_issue_codes=tuple(enabled_issue_codes),
)
if issue:
issues.append(issue)
@@ -87,8 +103,8 @@ def test_parse_response_with_leading_and_trailing_text() -> None:
)
response_text = "Some leading text\n```json\n" + valid_response + "\n```\nSome trailing text"
- with expect_exact_logged_errors(["Unknown location"]):
- issues = _parse_issues(response_text, project_context, ISSUE_CODES_FOR_CORRECTNESS_CHECK)
+ # Note: This logs a warning about "Unknown location" since test.py isn't in the project context
+ issues = _parse_issues(response_text, project_context, ISSUE_CODES_FOR_CORRECTNESS_CHECK)
issue = only(issues)
assert issue.code == IssueCode.LOGIC_ERROR
assert issue.description == "Infinite loop detected"
@@ -119,7 +135,8 @@ def test_parse_issues_invalid_json() -> None:
def test_parse_issues_with_markdown_formatting() -> None:
project_context = BaseProjectContext(
- file_contents_by_path=FrozenDict({"test.py": "x = 1"}), cached_prompt_prefix="test"
+ file_contents_by_path=FrozenDict({"test.py": "x = 1"}),
+ cached_prompt_prefix="test",
)
markdown_response = (
@@ -161,7 +178,11 @@ def test_parse_issues_invalid_severity() -> None:
)
with expect_exact_logged_errors(["Response does not match the expected schema"]):
- issues = _parse_issues(invalid_severity_response, project_context, ISSUE_CODES_FOR_CORRECTNESS_CHECK)
+ issues = _parse_issues(
+ invalid_severity_response,
+ project_context,
+ ISSUE_CODES_FOR_CORRECTNESS_CHECK,
+ )
assert len(issues) == 0 # Should be skipped due to invalid severity
@@ -187,9 +208,7 @@ def test_parse_issues_unknown_issue_code() -> None:
def test_parse_issues_missing_required_fields() -> None:
- project_context = BaseProjectContext(
- file_contents_by_path=FrozenDict(), cached_prompt_prefix="[ROLE=SYSTEM]\ntest"
- )
+ project_context = BaseProjectContext(file_contents_by_path=FrozenDict(), cached_prompt_prefix="[ROLE=SYSTEM]\ntest")
# Missing required field 'confidence'
missing_field_response = json.dumps(
@@ -211,9 +230,7 @@ def test_parse_issues_missing_required_fields() -> None:
def test_parse_issues_invalid_confidence() -> None:
- project_context = BaseProjectContext(
- file_contents_by_path=FrozenDict(), cached_prompt_prefix="[ROLE=SYSTEM]\ntest"
- )
+ project_context = BaseProjectContext(file_contents_by_path=FrozenDict(), cached_prompt_prefix="[ROLE=SYSTEM]\ntest")
invalid_confidence_response = json.dumps(
{
@@ -229,14 +246,19 @@ def test_parse_issues_invalid_confidence() -> None:
)
with expect_exact_logged_errors(["Response does not match the expected schema"]):
- issues = _parse_issues(invalid_confidence_response, project_context, ISSUE_CODES_FOR_CORRECTNESS_CHECK)
+ issues = _parse_issues(
+ invalid_confidence_response,
+ project_context,
+ ISSUE_CODES_FOR_CORRECTNESS_CHECK,
+ )
assert len(issues) == 0 # Should be skipped due to invalid confidence
def test_parse_issues_with_line_ranges() -> None:
code_content = "def hello():\n print('world')\n return True"
project_context = BaseProjectContext(
- file_contents_by_path=FrozenDict({"test.py": code_content}), cached_prompt_prefix="[ROLE=SYSTEM]\ntest"
+ file_contents_by_path=FrozenDict({"test.py": code_content}),
+ cached_prompt_prefix="[ROLE=SYSTEM]\ntest",
)
response_with_location = json.dumps(
@@ -261,9 +283,7 @@ def test_parse_issues_with_line_ranges() -> None:
def test_parse_issues_malformed_response_structure() -> None:
- project_context = BaseProjectContext(
- file_contents_by_path=FrozenDict(), cached_prompt_prefix="[ROLE=SYSTEM]\ntest"
- )
+ project_context = BaseProjectContext(file_contents_by_path=FrozenDict(), cached_prompt_prefix="[ROLE=SYSTEM]\ntest")
# Test with non-dict response
non_dict_response = json.dumps(["not", "a", "dict"])
diff --git a/imbue_verify/issue_identifiers/harnesses/agentic.py b/imbue_verify/issue_identifiers/harnesses/agentic.py
@@ -31,11 +31,15 @@ from imbue_verify.issue_identifiers.base import IssueIdentifier
from imbue_verify.issue_identifiers.common import GeneratedIssueSchema
from imbue_verify.issue_identifiers.common import GeneratedResponseSchema
from imbue_verify.issue_identifiers.common import extract_invocation_info_from_messages
-from imbue_verify.issue_identifiers.common import format_issue_identification_guide_for_llm
+from imbue_verify.issue_identifiers.common import (
+ format_issue_identification_guide_for_llm,
+)
from imbue_verify.issue_identifiers.common import generate_issues_from_response_texts
from imbue_verify.issue_identifiers.common import generate_response_from_claude_code
from imbue_verify.issue_identifiers.harnesses.base import IssueIdentifierHarness
-from imbue_verify.issue_identifiers.identification_guides import IssueIdentificationGuide
+from imbue_verify.issue_identifiers.identification_guides import (
+ IssueIdentificationGuide,
+)
PROMPT_TEMPLATE = """You are analyzing a code repository for potential issues. The repository files are available in {{ repo_path }}.
@@ -240,7 +244,10 @@ class _AgenticIssueIdentifier(IssueIdentifier[CommitInputs]):
return prompt
def identify_issues(
- self, identifier_inputs: CommitInputs, project_context: ProjectContext, config: ImbueVerifyConfig
+ self,
+ identifier_inputs: CommitInputs,
+ project_context: ProjectContext,
+ config: ImbueVerifyConfig,
) -> Generator[GeneratedIssueSchema, None, IssueIdentificationDebugInfo]:
assert project_context.repo_path is not None, "Project context must have a valid repo_path, got None"
diff --git a/imbue_verify/issue_identifiers/harnesses/base.py b/imbue_verify/issue_identifiers/harnesses/base.py
@@ -4,7 +4,9 @@ from typing import TypeVar
from imbue_tools.get_conversation_history.input_data_types import IdentifierInputs
from imbue_verify.issue_identifiers.base import IssueIdentifier
-from imbue_verify.issue_identifiers.identification_guides import IssueIdentificationGuide
+from imbue_verify.issue_identifiers.identification_guides import (
+ IssueIdentificationGuide,
+)
T = TypeVar("T", bound=IdentifierInputs)
diff --git a/imbue_verify/issue_identifiers/harnesses/conversation_single_prompt.py b/imbue_verify/issue_identifiers/harnesses/conversation_single_prompt.py
@@ -18,7 +18,9 @@ from imbue_core.data_types import IssueIdentificationDebugInfo
from imbue_core.data_types import IssueIdentificationLLMResponseMetadata
from imbue_core.data_types import LLMResponse
from imbue_core.itertools import only
-from imbue_tools.get_conversation_history.get_conversation_history import format_conversation_history_for_prompt
+from imbue_tools.get_conversation_history.get_conversation_history import (
+ format_conversation_history_for_prompt,
+)
from imbue_tools.get_conversation_history.input_data_types import ConversationInputs
from imbue_tools.repo_utils.project_context import ProjectContext
from imbue_tools.types.imbue_verify_config import ImbueVerifyConfig
@@ -26,11 +28,17 @@ from imbue_tools.util_prompts.conversation_prefix import CONVERSATION_PREFIX_TEM
from imbue_verify.issue_identifiers.base import IssueIdentifier
from imbue_verify.issue_identifiers.common import GeneratedIssueSchema
from imbue_verify.issue_identifiers.common import GeneratedResponseSchema
-from imbue_verify.issue_identifiers.common import extract_invocation_info_from_costed_response
-from imbue_verify.issue_identifiers.common import format_issue_identification_guide_for_llm
+from imbue_verify.issue_identifiers.common import (
+ extract_invocation_info_from_costed_response,
+)
+from imbue_verify.issue_identifiers.common import (
+ format_issue_identification_guide_for_llm,
+)
from imbue_verify.issue_identifiers.common import generate_issues_from_response_texts
from imbue_verify.issue_identifiers.harnesses.base import IssueIdentifierHarness
-from imbue_verify.issue_identifiers.identification_guides import IssueIdentificationGuide
+from imbue_verify.issue_identifiers.identification_guides import (
+ IssueIdentificationGuide,
+)
PROMPT_TEMPLATE = (
CONVERSATION_PREFIX_TEMPLATE
@@ -94,7 +102,10 @@ class _ConversationSinglePromptIssueIdentifier(IssueIdentifier[ConversationInput
)
def identify_issues(
- self, identifier_inputs: ConversationInputs, project_context: ProjectContext, config: ImbueVerifyConfig
+ self,
+ identifier_inputs: ConversationInputs,
+ project_context: ProjectContext,
+ config: ImbueVerifyConfig,
) -> Generator[GeneratedIssueSchema, None, IssueIdentificationDebugInfo]:
language_model = build_language_model_from_config(config.language_model_generation_config)
language_model_params = LanguageModelGenerationParams(
diff --git a/imbue_verify/issue_identifiers/harnesses/conversation_single_prompt_test.py b/imbue_verify/issue_identifiers/harnesses/conversation_single_prompt_test.py
@@ -9,9 +9,15 @@ from imbue_core.sculptor.state.messages import LLMModel
from imbue_core.sculptor.state.messages import ResponseBlockAgentMessage
from imbue_tools.get_conversation_history.input_data_types import ConversationInputs
from imbue_tools.get_conversation_history.input_data_types import IdentifierInputs
-from imbue_tools.get_conversation_history.input_data_types import IdentifierInputsMissingError
-from imbue_verify.issue_identifiers.harnesses.conversation_single_prompt import ConversationSinglePromptHarness
-from imbue_verify.issue_identifiers.identification_guides import ISSUE_IDENTIFICATION_GUIDES_BY_ISSUE_CODE
+from imbue_tools.get_conversation_history.input_data_types import (
+ IdentifierInputsMissingError,
+)
+from imbue_verify.issue_identifiers.harnesses.conversation_single_prompt import (
+ ConversationSinglePromptHarness,
+)
+from imbue_verify.issue_identifiers.identification_guides import (
+ ISSUE_IDENTIFICATION_GUIDES_BY_ISSUE_CODE,
+)
def test_to_required_inputs() -> None:
diff --git a/imbue_verify/issue_identifiers/harnesses/single_prompt.py b/imbue_verify/issue_identifiers/harnesses/single_prompt.py
@@ -23,11 +23,17 @@ from imbue_tools.types.imbue_verify_config import ImbueVerifyConfig
from imbue_verify.issue_identifiers.base import IssueIdentifier
from imbue_verify.issue_identifiers.common import GeneratedIssueSchema
from imbue_verify.issue_identifiers.common import GeneratedResponseSchema
-from imbue_verify.issue_identifiers.common import extract_invocation_info_from_costed_response
-from imbue_verify.issue_identifiers.common import format_issue_identification_guide_for_llm
+from imbue_verify.issue_identifiers.common import (
+ extract_invocation_info_from_costed_response,
+)
+from imbue_verify.issue_identifiers.common import (
+ format_issue_identification_guide_for_llm,
+)
from imbue_verify.issue_identifiers.common import generate_issues_from_response_texts
from imbue_verify.issue_identifiers.harnesses.base import IssueIdentifierHarness
-from imbue_verify.issue_identifiers.identification_guides import IssueIdentificationGuide
+from imbue_verify.issue_identifiers.identification_guides import (
+ IssueIdentificationGuide,
+)
USER_REQUEST_PREFIX_TEMPLATE = """{{cached_prompt_prefix}}
[ROLE=USER_CACHED]
@@ -134,7 +140,7 @@ class _SinglePromptIssueIdentifier(IssueIdentifier[CommitInputs]):
"include_request_and_diff": True,
"cached_prompt_prefix": project_context.cached_prompt_prefix,
"cache_full_prompt": config.cache_full_prompt,
- "extra_context": escape_prompt_markers(config.extra_context) if config.extra_context else None,
+ "extra_context": (escape_prompt_markers(config.extra_context) if config.extra_context else None),
"commit_message": escape_prompt_markers(identifier_inputs.goal),
"unified_diff": escape_prompt_markers(identifier_inputs.diff),
"guides": formatted_guides,
@@ -143,7 +149,10 @@ class _SinglePromptIssueIdentifier(IssueIdentifier[CommitInputs]):
)
def identify_issues(
- self, identifier_inputs: CommitInputs, project_context: ProjectContext, config: ImbueVerifyConfig
+ self,
+ identifier_inputs: CommitInputs,
+ project_context: ProjectContext,
+ config: ImbueVerifyConfig,
) -> Generator[GeneratedIssueSchema, None, IssueIdentificationDebugInfo]:
prompt = self._get_prompt(project_context, config, identifier_inputs)
language_model = build_language_model_from_config(config.language_model_generation_config)
diff --git a/imbue_verify/issue_identifiers/harnesses/single_prompt_test.py b/imbue_verify/issue_identifiers/harnesses/single_prompt_test.py
@@ -17,13 +17,19 @@ from imbue_core.data_types import IssueCode
from imbue_core.frozen_utils import FrozenDict
from imbue_tools.get_conversation_history.input_data_types import CommitInputs
from imbue_tools.get_conversation_history.input_data_types import IdentifierInputs
-from imbue_tools.get_conversation_history.input_data_types import IdentifierInputsMissingError
+from imbue_tools.get_conversation_history.input_data_types import (
+ IdentifierInputsMissingError,
+)
from imbue_tools.repo_utils.project_context import BaseProjectContext
from imbue_tools.types.imbue_verify_config import ImbueVerifyConfig
from imbue_verify.issue_identifiers.base import IssueIdentifier
from imbue_verify.issue_identifiers.harnesses.single_prompt import SinglePromptHarness
-from imbue_verify.issue_identifiers.identification_guides import ISSUE_CODES_FOR_CORRECTNESS_CHECK
-from imbue_verify.issue_identifiers.identification_guides import ISSUE_IDENTIFICATION_GUIDES_BY_ISSUE_CODE
+from imbue_verify.issue_identifiers.identification_guides import (
+ ISSUE_CODES_FOR_CORRECTNESS_CHECK,
+)
+from imbue_verify.issue_identifiers.identification_guides import (
+ ISSUE_IDENTIFICATION_GUIDES_BY_ISSUE_CODE,
+)
from imbue_verify.issue_identifiers.utils import ReturnCapturingGenerator
@@ -75,7 +81,10 @@ def test_to_required_inputs() -> None:
# Should support inputs where the commit message and diff are present
combined_inputs = IdentifierInputs(
- maybe_goal="test", maybe_diff="test", maybe_files=("test.py",), maybe_conversation_history=()
+ maybe_goal="test",
+ maybe_diff="test",
+ maybe_files=("test.py",),
+ maybe_conversation_history=(),
)
cmi = identifier.to_required_inputs(combined_inputs)
assert isinstance(cmi, CommitInputs)
@@ -104,7 +113,8 @@ def test_get_prompt_structure() -> None:
cached_prompt_prefix="[ROLE=SYSTEM]\nSystem context here",
)
commit_inputs = CommitInputs(
- maybe_goal="Add hello world function", maybe_diff="+def hello():\n+ print('hello')"
+ maybe_goal="Add hello world function",
+ maybe_diff="+def hello():\n+ print('hello')",
)
config = ImbueVerifyConfig()
diff --git a/imbue_verify/issue_identifiers/issue_deduplication.py b/imbue_verify/issue_identifiers/issue_deduplication.py
@@ -16,10 +16,16 @@ from imbue_tools.repo_utils.context_utils import escape_prompt_markers
from imbue_tools.types.imbue_verify_config import ImbueVerifyConfig
from imbue_verify.issue_identifiers.common import GeneratedIssueSchema
from imbue_verify.issue_identifiers.common import GeneratedResponseSchema
-from imbue_verify.issue_identifiers.common import extract_invocation_info_from_costed_response
-from imbue_verify.issue_identifiers.common import format_issue_identification_guide_for_llm
+from imbue_verify.issue_identifiers.common import (
+ extract_invocation_info_from_costed_response,
+)
+from imbue_verify.issue_identifiers.common import (
+ format_issue_identification_guide_for_llm,
+)
from imbue_verify.issue_identifiers.common import generate_issues_from_response_texts
-from imbue_verify.issue_identifiers.identification_guides import ISSUE_IDENTIFICATION_GUIDES_BY_ISSUE_CODE
+from imbue_verify.issue_identifiers.identification_guides import (
+ ISSUE_IDENTIFICATION_GUIDES_BY_ISSUE_CODE,
+)
from imbue_verify.issue_identifiers.utils import ReturnCapturingGenerator
DEDUPLICATION_PROMPT_TEMPLATE = """[ROLE=USER]
@@ -90,7 +96,9 @@ def _get_deduplication_prompt(
return prompt
-def _convert_parsed_issues_to_combined_string(all_parsed_issues: Iterable[GeneratedIssueSchema]) -> str:
+def _convert_parsed_issues_to_combined_string(
+ all_parsed_issues: Iterable[GeneratedIssueSchema],
+) -> str:
"""Convert all parsed issues from all issue types to a combined string for the deduplication prompt."""
combined_issues = []
diff --git a/imbue_verify/issue_identifiers/issue_evaluation.py b/imbue_verify/issue_identifiers/issue_evaluation.py
@@ -11,20 +11,34 @@ from imbue_core.data_types import IssueIdentificationLLMResponseMetadata
from imbue_core.data_types import LLMResponse
from imbue_core.itertools import only
from imbue_core.pydantic_serialization import SerializableModel
-from imbue_tools.get_conversation_history.get_conversation_history import format_conversation_history_for_prompt
+from imbue_tools.get_conversation_history.get_conversation_history import (
+ format_conversation_history_for_prompt,
+)
from imbue_tools.get_conversation_history.input_data_types import IdentifierInputs
-from imbue_tools.llm_output_parsing.parse_model_json_response import ResponseParsingError
-from imbue_tools.llm_output_parsing.parse_model_json_response import parse_model_json_response
+from imbue_tools.llm_output_parsing.parse_model_json_response import (
+ ResponseParsingError,
+)
+from imbue_tools.llm_output_parsing.parse_model_json_response import (
+ parse_model_json_response,
+)
from imbue_tools.repo_utils.context_utils import escape_prompt_markers
from imbue_tools.repo_utils.project_context import ProjectContext
from imbue_tools.types.imbue_verify_config import DEFAULT_CONFIDENCE_THRESHOLD
from imbue_tools.types.imbue_verify_config import ImbueVerifyConfig
from imbue_tools.util_prompts.conversation_prefix import CONVERSATION_PREFIX_TEMPLATE
from imbue_verify.issue_identifiers.common import GeneratedIssueSchema
-from imbue_verify.issue_identifiers.common import extract_invocation_info_from_costed_response
-from imbue_verify.issue_identifiers.common import format_issue_identification_guide_for_llm
-from imbue_verify.issue_identifiers.harnesses.single_prompt import USER_REQUEST_PREFIX_TEMPLATE
-from imbue_verify.issue_identifiers.identification_guides import ISSUE_IDENTIFICATION_GUIDES_BY_ISSUE_CODE
+from imbue_verify.issue_identifiers.common import (
+ extract_invocation_info_from_costed_response,
+)
+from imbue_verify.issue_identifiers.common import (
+ format_issue_identification_guide_for_llm,
+)
+from imbue_verify.issue_identifiers.harnesses.single_prompt import (
+ USER_REQUEST_PREFIX_TEMPLATE,
+)
+from imbue_verify.issue_identifiers.identification_guides import (
+ ISSUE_IDENTIFICATION_GUIDES_BY_ISSUE_CODE,
+)
from imbue_verify.issue_identifiers.utils import ReturnCapturingGenerator
CODE_BASED_CRITERIA = (
diff --git a/imbue_verify/issue_identifiers/registry.py b/imbue_verify/issue_identifiers/registry.py
@@ -18,22 +18,36 @@ from imbue_core.data_types import IssueIdentificationLLMResponseMetadata
from imbue_core.data_types import IssueIdentifierResult
from imbue_core.data_types import IssueIdentifierType
from imbue_tools.get_conversation_history.input_data_types import IdentifierInputs
-from imbue_tools.get_conversation_history.input_data_types import IdentifierInputsMissingError
+from imbue_tools.get_conversation_history.input_data_types import (
+ IdentifierInputsMissingError,
+)
from imbue_tools.repo_utils.project_context import ProjectContext
from imbue_tools.types.imbue_verify_config import ImbueVerifyConfig
from imbue_tools.types.imbue_verify_config import get_enabled_issue_codes
-from imbue_verify.issue_identifiers.agentic_issue_collation import collate_issues_with_agent
+from imbue_verify.issue_identifiers.agentic_issue_collation import (
+ collate_issues_with_agent,
+)
from imbue_verify.issue_identifiers.base import IssueIdentifier
from imbue_verify.issue_identifiers.common import GeneratedIssueSchema
from imbue_verify.issue_identifiers.common import convert_to_issue_identifier_result
from imbue_verify.issue_identifiers.harnesses.agentic import AgenticHarness
from imbue_verify.issue_identifiers.harnesses.base import IssueIdentifierHarness
-from imbue_verify.issue_identifiers.harnesses.conversation_single_prompt import ConversationSinglePromptHarness
+from imbue_verify.issue_identifiers.harnesses.conversation_single_prompt import (
+ ConversationSinglePromptHarness,
+)
from imbue_verify.issue_identifiers.harnesses.single_prompt import SinglePromptHarness
-from imbue_verify.issue_identifiers.identification_guides import ISSUE_CODES_FOR_BATCHED_COMMIT_CHECK
-from imbue_verify.issue_identifiers.identification_guides import ISSUE_CODES_FOR_CONVERSATION_HISTORY_CHECK
-from imbue_verify.issue_identifiers.identification_guides import ISSUE_CODES_FOR_CORRECTNESS_CHECK
-from imbue_verify.issue_identifiers.identification_guides import ISSUE_IDENTIFICATION_GUIDES_BY_ISSUE_CODE
+from imbue_verify.issue_identifiers.identification_guides import (
+ ISSUE_CODES_FOR_BATCHED_COMMIT_CHECK,
+)
+from imbue_verify.issue_identifiers.identification_guides import (
+ ISSUE_CODES_FOR_CONVERSATION_HISTORY_CHECK,
+)
+from imbue_verify.issue_identifiers.identification_guides import (
+ ISSUE_CODES_FOR_CORRECTNESS_CHECK,
+)
+from imbue_verify.issue_identifiers.identification_guides import (
+ ISSUE_IDENTIFICATION_GUIDES_BY_ISSUE_CODE,
+)
from imbue_verify.issue_identifiers.issue_deduplication import deduplicate_issues
from imbue_verify.issue_identifiers.issue_evaluation import filter_issues
from imbue_verify.issue_identifiers.utils import ReturnCapturingGenerator
@@ -89,7 +103,9 @@ def _convert_all_to_enum(
return tuple(results)
-def _get_enabled_identifier_names(config: ImbueVerifyConfig) -> set[IssueIdentifierType]:
+def _get_enabled_identifier_names(
+ config: ImbueVerifyConfig,
+) -> set[IssueIdentifierType]:
all_names = get_all_valid_identifier_names()
explicitly_enabled = _convert_all_to_enum(config.enabled_identifiers or tuple(), all_names, IssueIdentifierType)
explicitly_disabled = _convert_all_to_enum(config.disabled_identifiers or tuple(), all_names, IssueIdentifierType)
@@ -120,9 +136,7 @@ def _build_identifiers(
(
combined_name,
harness.make_issue_identifier(
- identification_guides=tuple(
- ISSUE_IDENTIFICATION_GUIDES_BY_ISSUE_CODE[code] for code in issue_codes
- )
+ identification_guides=tuple(ISSUE_IDENTIFICATION_GUIDES_BY_ISSUE_CODE[code] for code in issue_codes)
),
)
)
@@ -131,7 +145,8 @@ def _build_identifiers(
def _generate_with_name_in_debug_info(
- name: str, generator: Generator[GeneratedIssueSchema, None, IssueIdentificationDebugInfo]
+ name: str,
+ generator: Generator[GeneratedIssueSchema, None, IssueIdentificationDebugInfo],
) -> Generator[GeneratedIssueSchema, None, tuple[str, IssueIdentificationDebugInfo]]:
generator_with_capture = ReturnCapturingGenerator(generator)
for result in generator_with_capture:
@@ -142,7 +157,7 @@ def _generate_with_name_in_debug_info(
def _combine_issue_generator_debug_info(
generator: Generator[GeneratedIssueSchema, None, tuple[tuple[str, IssueIdentificationDebugInfo], ...]],
) -> Generator[GeneratedIssueSchema, None, IssueIdentificationDebugInfo]:
- collected_debug_info: tuple[tuple[str, IssueIdentificationDebugInfo], ...] = yield from generator
+ collected_debug_info: tuple[tuple[str, IssueIdentificationDebugInfo], ...] = (yield from generator)
updated_llm_responses = []
for identifier_name, debug_info in collected_debug_info:
@@ -178,7 +193,11 @@ def run(
compatible_enabled_identifier_names.append(identifier_name)
detectable_issue_codes.update(identifier.enabled_issue_codes)
except IdentifierInputsMissingError as e:
- logger.debug("skipping identifier {} because of missing inputs: {}", identifier_name, e)
+ logger.debug(
+ "skipping identifier {} because of missing inputs: {}",
+ identifier_name,
+ e,
+ )
continue
# 2. Collation for agentic identifiers
@@ -192,7 +211,10 @@ def run(
identifier.enabled_issue_codes,
)
except IdentifierInputsMissingError as e:
- logger.warning("collate_issues_with_agent requires commit message and diff, skipping: {}", e)
+ logger.warning(
+ "collate_issues_with_agent requires commit message and diff, skipping: {}",
+ e,
+ )
continue
else:
collated_issues_generator = identified_issues_generator
@@ -222,7 +244,9 @@ def run(
# 4. Deduplicate issues across all identifiers
if config.enable_deduplication:
deduplicated_generator = deduplicate_issues(
- multiplexed_generators_with_combined_debug_info, config, tuple(detectable_issue_codes)
+ multiplexed_generators_with_combined_debug_info,
+ config,
+ tuple(detectable_issue_codes),
)
else:
deduplicated_generator = multiplexed_generators_with_combined_debug_info
diff --git a/imbue_verify/issue_identifiers/test_prompt_lengths.py b/imbue_verify/issue_identifiers/test_prompt_lengths.py
@@ -5,7 +5,9 @@ from imbue_tools.get_conversation_history.input_data_types import CommitInputs
from imbue_tools.repo_utils.project_context import BaseProjectContext
from imbue_tools.types.imbue_verify_config import ImbueVerifyConfig
from imbue_verify.issue_identifiers import registry
-from imbue_verify.issue_identifiers.identification_guides import ISSUE_IDENTIFICATION_GUIDES_BY_ISSUE_CODE
+from imbue_verify.issue_identifiers.identification_guides import (
+ ISSUE_IDENTIFICATION_GUIDES_BY_ISSUE_CODE,
+)
from imbue_verify.repo_utils import IMBUE_VERIFY_MAX_PROMPT_TOKENS
EMPTY_PROJECT_CONTEXT = BaseProjectContext(file_contents_by_path=FrozenDict(), cached_prompt_prefix="")
@@ -53,6 +55,6 @@ def test_prompt_lengths() -> None:
)
prompt = extract_prompt(identifier)
num_tokens = _estimate_tokens(prompt)
- assert num_tokens <= IMBUE_VERIFY_MAX_PROMPT_TOKENS, (
- f"Prompt for {identifier_name} exceeds IMBUE_VERIFY_MAX_PROMPT_TOKENS. Consider increasing IMBUE_VERIFY_MAX_PROMPT_TOKENS or shortening the prompt. "
- )
+ assert (
+ num_tokens <= IMBUE_VERIFY_MAX_PROMPT_TOKENS
+ ), f"Prompt for {identifier_name} exceeds IMBUE_VERIFY_MAX_PROMPT_TOKENS. Consider increasing IMBUE_VERIFY_MAX_PROMPT_TOKENS or shortening the prompt. "
diff --git a/imbue_verify/issue_identifiers/utils.py b/imbue_verify/issue_identifiers/utils.py
@@ -20,7 +20,10 @@ def xml_post_escape(input_string: str, element_to_post_escape: str) -> str:
element_to_post_escape -- the element to post-escape (e.g. "code_part")
"""
- pattern = re.compile(f"<({element_to_post_escape})>(.*?)</({element_to_post_escape})>", re.IGNORECASE | re.DOTALL)
+ pattern = re.compile(
+ f"<({element_to_post_escape})>(.*?)</({element_to_post_escape})>",
+ re.IGNORECASE | re.DOTALL,
+ )
return re.sub(
pattern,
lambda x: f"<{x.group(1)}>{escape(x.group(2))}</{x.group(3)}>",
@@ -77,7 +80,8 @@ class GeneratorDoneSentinel:
def _run_and_queue_generator(
- generator: Generator[IterT, None, ReturnT], output_queue: queue.Queue[IterT | GeneratorDoneSentinel]
+ generator: Generator[IterT, None, ReturnT],
+ output_queue: queue.Queue[IterT | GeneratorDoneSentinel],
) -> ReturnT:
try:
generator_with_capture = ReturnCapturingGenerator(generator)
@@ -101,7 +105,12 @@ def multiplex_generators(
with ThreadPoolExecutor(max_workers=max_workers) as executor:
output_queue: queue.Queue[IterT | GeneratorDoneSentinel] = queue.Queue()
futures = [
- executor.submit(contextvars.copy_context().run, _run_and_queue_generator, gen, output_queue=output_queue)
+ executor.submit(
+ contextvars.copy_context().run,
+ _run_and_queue_generator,
+ gen,
+ output_queue=output_queue,
+ )
for gen in generators
]
diff --git a/imbue_verify/repo_utils_test.py b/imbue_verify/repo_utils_test.py
@@ -15,7 +15,11 @@ def test_get_code_to_check(simple_test_git_repo: Path) -> None:
"""Test that get_code_to_check correctly handles staged, unstaged, and untracked files"""
repo_path = simple_test_git_repo
first_commit = subprocess.run(
- ["git", "rev-parse", "HEAD"], cwd=repo_path, capture_output=True, text=True, check=True
+ ["git", "rev-parse", "HEAD"],
+ cwd=repo_path,
+ capture_output=True,
+ text=True,
+ check=True,
).stdout.strip()
# Create an untracked file
@@ -67,7 +71,11 @@ def test_get_code_to_check(simple_test_git_repo: Path) -> None:
def test_build_context(simple_test_git_repo: Path, snapshot: SnapshotAssertion) -> None:
first_commit = subprocess.run(
- ["git", "rev-parse", "HEAD"], cwd=simple_test_git_repo, capture_output=True, text=True, check=True
+ ["git", "rev-parse", "HEAD"],
+ cwd=simple_test_git_repo,
+ capture_output=True,
+ text=True,
+ check=True,
).stdout.strip()
git_hash, diff, _diff_no_binary = get_code_to_check(first_commit, repo_path=simple_test_git_repo)
project_context = LazyProjectContext.build(
diff --git a/imbue_verify/telemetry.py b/imbue_verify/telemetry.py
@@ -1,96 +0,0 @@
-from datetime import datetime
-
-from imbue_core.agents.configs import LanguageModelGenerationConfig
-from imbue_core.sculptor.state.messages import ConversationMessageUnion
-from imbue_tools.capabilities_data_logging.common import get_current_user_name
-from imbue_tools.capabilities_data_logging.data_types import CommandType
-from imbue_tools.capabilities_data_logging.data_types import ImbueVerifyEvent
-from imbue_tools.capabilities_data_logging.data_types import LoggedFeatureType
-from imbue_tools.repo_utils.context_prefix import SubrepoContext
-from imbue_tools.repo_utils.context_prefix import SubrepoContextWithFormattedContext
-from imbue_tools.types.imbue_verify_config import ImbueVerifyConfig
-
-
-def prune_context(context: SubrepoContext | None) -> SubrepoContext | None:
- if isinstance(context, SubrepoContextWithFormattedContext):
- return SubrepoContext(
- repo_context_files=context.repo_context_files,
- subrepo_context_strategy_label=context.subrepo_context_strategy_label,
- )
- return context
-
-
-# TODO quick and dirty "new" event distinguished only by feature_name. should add more actual event types
-async def create_imbue_verify_exception_event(
- base_commit: str,
- diff: str,
- goal: str,
- config: ImbueVerifyConfig,
- exception_name: str | None = None,
- created_at: datetime | None = None,
-) -> ImbueVerifyEvent:
- """
- Log the repo state and goal to a local file.
- """
- # TODO: we should really be passing in the generation config not relying on these defaults
- generation_config = LanguageModelGenerationConfig(model_name=config.language_model_generation_config.model_name)
- event = ImbueVerifyEvent(
- task_description=goal,
- feature_name=LoggedFeatureType.VERIFY_EXCEPTION,
- generation_config=generation_config,
- user_id=get_current_user_name(),
- organization_id="Imbue",
- git_hash=base_commit,
- diff=diff,
- command_type=CommandType.IMBUE_VERIFY,
- imbue_verify_config=config,
- exception_name=exception_name,
- # If created_at is provided, use it, otherwise use the current time as measured by the event's constructor.
- # pyre-fixme[6]: pyre can't check unpacking untyped dict
- **(dict(created_at=created_at) if created_at else {}),
- )
- return event
-
-
-async def create_imbue_verify_issues_found_event(
- base_commit: str,
- diff: str,
- goal: str,
- config: ImbueVerifyConfig,
- created_at: datetime | None = None,
- git_url: str | None = None,
- subrepo_context: SubrepoContext | None = None,
- instruction_context: SubrepoContext | None = None,
- conversation_history: tuple[ConversationMessageUnion, ...] | None = None,
-) -> ImbueVerifyEvent:
- """
- Log the repo state and goal to a local file.
- """
- # TODO: this is kind of hacky but we don't want to log the formatted context as well since it's a lot to log
- pruned_subrepo_context = prune_context(subrepo_context)
- pruned_instruction_context = prune_context(instruction_context)
- # TODO: we should really be passing in the generation config not relying on these defaults
- generation_config = LanguageModelGenerationConfig(model_name=config.language_model_generation_config.model_name)
- if git_url:
- assert "https://oauth2:" not in git_url, f"Expected no oauth2 in url, {git_url=}"
-
- event = ImbueVerifyEvent(
- task_description=goal,
- feature_name=LoggedFeatureType.COMMAND_RUN,
- # TODO: maybe this should actually become something like a CodeGenerationConfig since that also has info about the context
- generation_config=generation_config,
- user_id=get_current_user_name(),
- organization_id="Imbue",
- git_hash=base_commit,
- diff=diff,
- command_type=CommandType.IMBUE_VERIFY,
- git_url=git_url,
- subrepo_context=pruned_subrepo_context,
- instruction_context=pruned_instruction_context,
- conversation_history=conversation_history,
- imbue_verify_config=config,
- # If created_at is provided, use it, otherwise use the current time as measured by the event's constructor.
- # pyre-fixme[6]: pyre can't check unpacking untyped dict
- **(dict(created_at=created_at) if created_at else {}),
- )
- return event
diff --git a/imbue_verify/telemetry_test.py b/imbue_verify/telemetry_test.py
@@ -1,36 +0,0 @@
-from datetime import datetime
-from datetime import timezone
-
-from imbue_tools.repo_utils.context_prefix import SubrepoContext
-from imbue_tools.types.imbue_verify_config import ImbueVerifyConfig
-from imbue_verify.telemetry import create_imbue_verify_issues_found_event
-
-
-async def test_create_imbue_verify_issues_found_event_with_created_at() -> None:
- now = datetime.now(timezone.utc) # noqa: F821
- event = await create_imbue_verify_issues_found_event(
- config=ImbueVerifyConfig(),
- base_commit="",
- diff="",
- goal="",
- subrepo_context=SubrepoContext(
- subrepo_context_strategy_label="",
- repo_context_files=tuple(),
- ),
- created_at=now,
- )
- assert event.created_at == now
-
-
-async def test_without_created_at() -> None:
- event = await create_imbue_verify_issues_found_event(
- config=ImbueVerifyConfig(),
- base_commit="",
- diff="",
- goal="",
- subrepo_context=SubrepoContext(
- subrepo_context_strategy_label="",
- repo_context_files=tuple(),
- ),
- )
- assert isinstance(event.created_at, datetime)
diff --git a/pyproject.toml b/pyproject.toml
@@ -31,5 +31,14 @@ package-data.imbue_verify = ["py.typed"]
include = ["imbue_verify*"]
[tool.uv.sources]
-imbue_core = { path = "../generally_intelligent/imbue_core", editable = true }
-imbue_tools = { path = "../generally_intelligent/imbue_tools", editable = true }
+imbue_core = { path = "./imbue_core", editable = true }
+imbue_tools = { path = "./imbue_tools", editable = true }
+
+[dependency-groups]
+dev = [
+ "black",
+]
+
+[tool.black]
+line-length = 120
+target-version = ['py311']
diff --git a/uv.lock b/uv.lock
@@ -2,7 +2,9 @@ version = 1
revision = 3
requires-python = ">=3.11"
resolution-markers = [
- "python_full_version >= '3.12'",
+ "python_full_version >= '3.14'",
+ "python_full_version == '3.13.*'",
+ "python_full_version == '3.12.*'",
"python_full_version < '3.12'",
]
@@ -208,15 +210,6 @@ wheels = [
]
[[package]]
-name = "backoff"
-version = "2.2.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" },
-]
-
-[[package]]
name = "black"
version = "25.12.0"
source = { registry = "https://pypi.org/simple" }
@@ -704,15 +697,6 @@ wheels = [
]
[[package]]
-name = "fsspec"
-version = "2026.1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d5/7d/5df2650c57d47c57232af5ef4b4fdbff182070421e405e0d62c6cdbfaa87/fsspec-2026.1.0.tar.gz", hash = "sha256:e987cb0496a0d81bba3a9d1cee62922fb395e7d4c3b575e57f547953334fe07b", size = 310496, upload-time = "2026-01-09T15:21:35.562Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838, upload-time = "2026-01-09T15:21:34.041Z" },
-]
-
-[[package]]
name = "google-auth"
version = "2.48.0"
source = { registry = "https://pypi.org/simple" }
@@ -805,35 +789,6 @@ wheels = [
]
[[package]]
-name = "hf-xet"
-version = "1.2.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5e/6e/0f11bacf08a67f7fb5ee09740f2ca54163863b07b70d579356e9222ce5d8/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f", size = 506020, upload-time = "2025-10-24T19:04:32.129Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/9e/a5/85ef910a0aa034a2abcfadc360ab5ac6f6bc4e9112349bd40ca97551cff0/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649", size = 2861870, upload-time = "2025-10-24T19:04:11.422Z" },
- { url = "https://files.pythonhosted.org/packages/ea/40/e2e0a7eb9a51fe8828ba2d47fe22a7e74914ea8a0db68a18c3aa7449c767/hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813", size = 2717584, upload-time = "2025-10-24T19:04:09.586Z" },
- { url = "https://files.pythonhosted.org/packages/a5/7d/daf7f8bc4594fdd59a8a596f9e3886133fdc68e675292218a5e4c1b7e834/hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc", size = 3315004, upload-time = "2025-10-24T19:04:00.314Z" },
- { url = "https://files.pythonhosted.org/packages/b1/ba/45ea2f605fbf6d81c8b21e4d970b168b18a53515923010c312c06cd83164/hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5", size = 3222636, upload-time = "2025-10-24T19:03:58.111Z" },
- { url = "https://files.pythonhosted.org/packages/4a/1d/04513e3cab8f29ab8c109d309ddd21a2705afab9d52f2ba1151e0c14f086/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f", size = 3408448, upload-time = "2025-10-24T19:04:20.951Z" },
- { url = "https://files.pythonhosted.org/packages/f0/7c/60a2756d7feec7387db3a1176c632357632fbe7849fce576c5559d4520c7/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832", size = 3503401, upload-time = "2025-10-24T19:04:22.549Z" },
- { url = "https://files.pythonhosted.org/packages/4e/64/48fffbd67fb418ab07451e4ce641a70de1c40c10a13e25325e24858ebe5a/hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382", size = 2900866, upload-time = "2025-10-24T19:04:33.461Z" },
- { url = "https://files.pythonhosted.org/packages/e2/51/f7e2caae42f80af886db414d4e9885fac959330509089f97cccb339c6b87/hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e", size = 2861861, upload-time = "2025-10-24T19:04:19.01Z" },
- { url = "https://files.pythonhosted.org/packages/6e/1d/a641a88b69994f9371bd347f1dd35e5d1e2e2460a2e350c8d5165fc62005/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8", size = 2717699, upload-time = "2025-10-24T19:04:17.306Z" },
- { url = "https://files.pythonhosted.org/packages/df/e0/e5e9bba7d15f0318955f7ec3f4af13f92e773fbb368c0b8008a5acbcb12f/hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0", size = 3314885, upload-time = "2025-10-24T19:04:07.642Z" },
- { url = "https://files.pythonhosted.org/packages/21/90/b7fe5ff6f2b7b8cbdf1bd56145f863c90a5807d9758a549bf3d916aa4dec/hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090", size = 3221550, upload-time = "2025-10-24T19:04:05.55Z" },
- { url = "https://files.pythonhosted.org/packages/6f/cb/73f276f0a7ce46cc6a6ec7d6c7d61cbfe5f2e107123d9bbd0193c355f106/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a", size = 3408010, upload-time = "2025-10-24T19:04:28.598Z" },
- { url = "https://files.pythonhosted.org/packages/b8/1e/d642a12caa78171f4be64f7cd9c40e3ca5279d055d0873188a58c0f5fbb9/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f", size = 3503264, upload-time = "2025-10-24T19:04:30.397Z" },
- { url = "https://files.pythonhosted.org/packages/17/b5/33764714923fa1ff922770f7ed18c2daae034d21ae6e10dbf4347c854154/hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc", size = 2901071, upload-time = "2025-10-24T19:04:37.463Z" },
- { url = "https://files.pythonhosted.org/packages/96/2d/22338486473df5923a9ab7107d375dbef9173c338ebef5098ef593d2b560/hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848", size = 2866099, upload-time = "2025-10-24T19:04:15.366Z" },
- { url = "https://files.pythonhosted.org/packages/7f/8c/c5becfa53234299bc2210ba314eaaae36c2875e0045809b82e40a9544f0c/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4", size = 2722178, upload-time = "2025-10-24T19:04:13.695Z" },
- { url = "https://files.pythonhosted.org/packages/9a/92/cf3ab0b652b082e66876d08da57fcc6fa2f0e6c70dfbbafbd470bb73eb47/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd", size = 3320214, upload-time = "2025-10-24T19:04:03.596Z" },
- { url = "https://files.pythonhosted.org/packages/46/92/3f7ec4a1b6a65bf45b059b6d4a5d38988f63e193056de2f420137e3c3244/hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c", size = 3229054, upload-time = "2025-10-24T19:04:01.949Z" },
- { url = "https://files.pythonhosted.org/packages/0b/dd/7ac658d54b9fb7999a0ccb07ad863b413cbaf5cf172f48ebcd9497ec7263/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737", size = 3413812, upload-time = "2025-10-24T19:04:24.585Z" },
- { url = "https://files.pythonhosted.org/packages/92/68/89ac4e5b12a9ff6286a12174c8538a5930e2ed662091dd2572bbe0a18c8a/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865", size = 3508920, upload-time = "2025-10-24T19:04:26.927Z" },
- { url = "https://files.pythonhosted.org/packages/cb/44/870d44b30e1dcfb6a65932e3e1506c103a8a5aea9103c337e7a53180322c/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69", size = 2905735, upload-time = "2025-10-24T19:04:35.928Z" },
-]
-
-[[package]]
name = "hpack"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
@@ -871,27 +826,6 @@ wheels = [
]
[[package]]
-name = "huggingface-hub"
-version = "1.3.4"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "filelock" },
- { name = "fsspec" },
- { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
- { name = "httpx" },
- { name = "packaging" },
- { name = "pyyaml" },
- { name = "shellingham" },
- { name = "tqdm" },
- { name = "typer-slim" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/af/25/74af9d16cd59ae15b12467a79a84aa0fe24be4aba68fc4da0c1864d49c17/huggingface_hub-1.3.4.tar.gz", hash = "sha256:c20d5484a611b7b7891d272e8fc9f77d5de025b0480bdacfa858efb3780b455f", size = 627683, upload-time = "2026-01-26T14:05:10.656Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/55/07/3d0c34c345043c6a398a5882e196b2220dc5861adfa18322448b90908f26/huggingface_hub-1.3.4-py3-none-any.whl", hash = "sha256:a0c526e76eb316e96a91e8a1a7a93cf66b0dd210be1a17bd5fc5ae53cba76bfd", size = 536611, upload-time = "2026-01-26T14:05:08.549Z" },
-]
-
-[[package]]
name = "hyperframe"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
@@ -912,7 +846,7 @@ wheels = [
[[package]]
name = "imbue-core"
version = "0.0.9"
-source = { editable = "../generally_intelligent/imbue_core" }
+source = { editable = "imbue_core" }
dependencies = [
{ name = "anthropic" },
{ name = "anyio" },
@@ -929,7 +863,6 @@ dependencies = [
{ name = "loguru" },
{ name = "openai" },
{ name = "pathspec" },
- { name = "posthog" },
{ name = "prometheus-client" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
@@ -941,13 +874,11 @@ dependencies = [
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
{ name = "python-gitlab" },
- { name = "sentry-sdk" },
{ name = "syrupy" },
{ name = "tblib" },
{ name = "tenacity" },
{ name = "tiktoken" },
{ name = "together" },
- { name = "tokenizers" },
{ name = "toml" },
{ name = "traceback-with-variables" },
{ name = "typeid-python" },
@@ -971,7 +902,6 @@ requires-dist = [
{ name = "loguru" },
{ name = "openai", specifier = ">=1.79.0" },
{ name = "pathspec" },
- { name = "posthog", specifier = "==5.4.0" },
{ name = "prometheus-client", specifier = ">=0.20.0" },
{ name = "pydantic", specifier = ">=2.11.4" },
{ name = "pydantic-settings" },
@@ -983,13 +913,11 @@ requires-dist = [
{ name = "pytest-asyncio" },
{ name = "pytest-mock" },
{ name = "python-gitlab", specifier = ">=4.5.0" },
- { name = "sentry-sdk" },
{ name = "syrupy" },
{ name = "tblib", specifier = "==2.0.0" },
{ name = "tenacity", specifier = ">=8.2.2" },
{ name = "tiktoken" },
{ name = "together" },
- { name = "tokenizers" },
{ name = "toml" },
{ name = "traceback-with-variables", specifier = ">=2.2.0" },
{ name = "typeid-python" },
@@ -1002,13 +930,14 @@ dev = [{ name = "moto", specifier = ">=4.1.12" }]
[[package]]
name = "imbue-tools"
version = "0.1.0"
-source = { editable = "../generally_intelligent/imbue_tools" }
+source = { editable = "imbue_tools" }
dependencies = [
{ name = "anyio" },
{ name = "async-lru" },
{ name = "attrs" },
{ name = "imbue-core" },
{ name = "jinja2" },
+ { name = "libcst" },
{ name = "loguru" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic" },
@@ -1026,9 +955,10 @@ requires-dist = [
{ name = "anyio" },
{ name = "async-lru" },
{ name = "attrs" },
- { name = "imbue-core", editable = "../generally_intelligent/imbue_core" },
- { name = "imbue-verify", marker = "extra == 'test'", editable = "../generally_intelligent/imbue_verify" },
+ { name = "imbue-core", editable = "imbue_core" },
+ { name = "imbue-verify", marker = "extra == 'test'", editable = "." },
{ name = "jinja2" },
+ { name = "libcst" },
{ name = "loguru" },
{ name = "psycopg", extras = ["binary"] },
{ name = "pydantic" },
@@ -1060,12 +990,17 @@ dependencies = [
{ name = "together" },
]
+[package.dev-dependencies]
+dev = [
+ { name = "black" },
+]
+
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = ">=3.8.0" },
{ name = "click" },
- { name = "imbue-core", editable = "../generally_intelligent/imbue_core" },
- { name = "imbue-tools", editable = "../generally_intelligent/imbue_tools" },
+ { name = "imbue-core", editable = "imbue_core" },
+ { name = "imbue-tools", editable = "imbue_tools" },
{ name = "jinja2" },
{ name = "loguru" },
{ name = "pydantic" },
@@ -1075,6 +1010,9 @@ requires-dist = [
{ name = "together", specifier = ">=1.5.35" },
]
+[package.metadata.requires-dev]
+dev = [{ name = "black" }]
+
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -1215,6 +1153,66 @@ wheels = [
]
[[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/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 = "loguru"
version = "0.7.3"
source = { registry = "https://pypi.org/simple" }
@@ -1685,22 +1683,6 @@ wheels = [
]
[[package]]
-name = "posthog"
-version = "5.4.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "backoff" },
- { name = "distro" },
- { name = "python-dateutil" },
- { name = "requests" },
- { name = "six" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/48/20/60ae67bb9d82f00427946218d49e2e7e80fb41c15dc5019482289ec9ce8d/posthog-5.4.0.tar.gz", hash = "sha256:701669261b8d07cdde0276e5bc096b87f9e200e3b9589c5ebff14df658c5893c", size = 88076, upload-time = "2025-06-20T23:19:23.485Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/4f/98/e480cab9a08d1c09b1c59a93dade92c1bb7544826684ff2acbfd10fcfbd4/posthog-5.4.0-py3-none-any.whl", hash = "sha256:284dfa302f64353484420b52d4ad81ff5c2c2d1d607c4e2db602ac72761831bd", size = 105364, upload-time = "2025-06-20T23:19:22.001Z" },
-]
-
-[[package]]
name = "prometheus-client"
version = "0.24.1"
source = { registry = "https://pypi.org/simple" }
@@ -2300,6 +2282,30 @@ wheels = [
]
[[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 = "regex"
version = "2026.1.15"
source = { registry = "https://pypi.org/simple" }
@@ -2468,19 +2474,6 @@ wheels = [
]
[[package]]
-name = "sentry-sdk"
-version = "2.51.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "certifi" },
- { name = "urllib3" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/6f/9f/094bbb6be5cf218ab6712c6528310687f3d3fe8818249fcfe1d74192f7c5/sentry_sdk-2.51.0.tar.gz", hash = "sha256:b89d64577075fd8c13088bc3609a2ce77a154e5beb8cba7cc16560b0539df4f7", size = 407447, upload-time = "2026-01-28T10:29:50.962Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a0/da/df379404d484ca9dede4ad8abead5de828cdcff35623cd44f0351cf6869c/sentry_sdk-2.51.0-py2.py3-none-any.whl", hash = "sha256:e21016d318a097c2b617bb980afd9fc737e1efc55f9b4f0cdc819982c9717d5f", size = 431426, upload-time = "2026-01-28T10:29:48.868Z" },
-]
-
-[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
@@ -2625,32 +2618,6 @@ wheels = [
]
[[package]]
-name = "tokenizers"
-version = "0.22.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "huggingface-hub" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" },
- { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" },
- { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" },
- { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" },
- { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" },
- { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" },
- { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" },
- { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" },
- { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" },
- { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" },
- { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" },
- { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" },
- { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" },
- { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" },
- { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" },
-]
-
-[[package]]
name = "toml"
version = "0.10.2"
source = { registry = "https://pypi.org/simple" }
@@ -2732,19 +2699,6 @@ wheels = [
]
[[package]]
-name = "typer-slim"
-version = "0.21.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "click" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/17/d4/064570dec6358aa9049d4708e4a10407d74c99258f8b2136bb8702303f1a/typer_slim-0.21.1.tar.gz", hash = "sha256:73495dd08c2d0940d611c5a8c04e91c2a0a98600cbd4ee19192255a233b6dbfd", size = 110478, upload-time = "2026-01-06T11:21:11.176Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c8/0a/4aca634faf693e33004796b6cee0ae2e1dba375a800c16ab8d3eff4bb800/typer_slim-0.21.1-py3-none-any.whl", hash = "sha256:6e6c31047f171ac93cc5a973c9e617dbc5ab2bddc4d0a3135dc161b4e2020e0d", size = 47444, upload-time = "2026-01-06T11:21:12.441Z" },
-]
-
-[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }