vet

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

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:
Aimbue_core/README.md | 3+++
Aimbue_core/imbue_core/__init__.py | 0
Aimbue_core/imbue_core/agents/__init__.py | 0
Aimbue_core/imbue_core/agents/agent_api/__init__.py | 0
Aimbue_core/imbue_core/agents/agent_api/api.py | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/agent_api/cache_utils.py | 36++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/agent_api/claude/__init__.py | 0
Aimbue_core/imbue_core/agents/agent_api/claude/client.py | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/agent_api/claude/data_types.py | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/agent_api/claude/message_parser.py | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/agent_api/client.py | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/agent_api/codex/__init__.py | 0
Aimbue_core/imbue_core/agents/agent_api/codex/client.py | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/agent_api/codex/data_types.py | 241+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/agent_api/codex/message_parser.py | 218+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/agent_api/data_types.py | 252+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/agent_api/errors.py | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/agent_api/interaction.py | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/agent_api/transport.py | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/agent_api/union_types.py | 12++++++++++++
Aimbue_core/imbue_core/agents/configs.py | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/data_types/__init__.py | 0
Aimbue_core/imbue_core/agents/data_types/ids.py | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/llm_apis/__init__.py | 0
Aimbue_core/imbue_core/agents/llm_apis/anthropic_api.py | 822+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/llm_apis/anthropic_data_types.py | 15+++++++++++++++
Aimbue_core/imbue_core/agents/llm_apis/api_utils.py | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/llm_apis/build_apis.py | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/llm_apis/common.py | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/llm_apis/constants.py | 16++++++++++++++++
Aimbue_core/imbue_core/agents/llm_apis/data_types.py | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/llm_apis/errors.py | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/llm_apis/gemini_api.py | 526+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/llm_apis/groq_api.py | 357+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/llm_apis/language_model_api.py | 547+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/llm_apis/llm_testing_utils.py | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/llm_apis/mock_api.py | 193+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/llm_apis/models.py | 17+++++++++++++++++
Aimbue_core/imbue_core/agents/llm_apis/openai_api.py | 654+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/llm_apis/openai_compatible_api.py | 286+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/llm_apis/openai_data_types.py | 13+++++++++++++
Aimbue_core/imbue_core/agents/llm_apis/stream.py | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/llm_apis/together_api.py | 611+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/agents/llm_apis/union_data_types.py | 20++++++++++++++++++++
Aimbue_core/imbue_core/agents/primitives/errors.py | 14++++++++++++++
Aimbue_core/imbue_core/agents/primitives/resource_limits.py | 485+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/async_monkey_patches.py | 441+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/async_monkey_patches_test.py | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/async_utils.py | 526+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/caching.py | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/cattrs_serialization.py | 1013+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/common.py | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/computing_environment/__init__.py | 0
Aimbue_core/imbue_core/computing_environment/computing_environment.py | 1080+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/computing_environment/data_types.py | 31+++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/conftest.py | 41+++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/constants.py | 21+++++++++++++++++++++
Aimbue_core/imbue_core/data_types.py | 317+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/error_utils.py | 570+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/errors.py | 36++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/fixed_traceback.py | 35+++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/frozen_utils.py | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/git.py | 587+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/git_data_types.py | 35+++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/ids.py | 44++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/imbue_cli/__init__.py | 0
Aimbue_core/imbue_core/imbue_cli/action.py | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/imbue_cli/scout_data_types.py | 40++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/imbue_cli/scout_message_types.py | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/issues.py | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/itertools.py | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/language_model_mode.py | 8++++++++
Aimbue_core/imbue_core/llm_testing_utils.py | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/log_utils.py | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/nested_evolver.py | 251+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/py.typed | 0
Aimbue_core/imbue_core/pydantic_serialization.py | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/pydantic_utils.py | 39+++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/repo_state.py | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/retry_utils.py | 30++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/s3_uploader.py | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/sculptor/__init__.py | 4++++
Aimbue_core/imbue_core/sculptor/state/chat_state.py | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/sculptor/state/messages.py | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/sculptor/telemetry.py | 809+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/sculptor/telemetry_constants.py | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/sculptor/telemetry_utils.py | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/sculptor/user_config.py | 252+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/secrets_utils.py | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/section.py | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/sentry_loguru_handler.py | 367+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/serialization.py | 471+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/serialization_types.py | 3+++
Aimbue_core/imbue_core/simple_git.py | 215+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/subprocess_utils.py | 798+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/suggestions.py | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/test_repo_utils.py | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/test_utils.py | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/testing_utils.py | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/imbue_core/time_utils.py | 5+++++
Aimbue_core/pyproject.toml | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_core/uv.lock | 1981+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_tools/README.md | 9+++++++++
Aimbue_tools/imbue_tools/__init__.py | 0
Aimbue_tools/imbue_tools/capabilities_data_logging/common.py | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_tools/imbue_tools/capabilities_data_logging/data_types.py | 250+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_tools/imbue_tools/conftest.py | 44++++++++++++++++++++++++++++++++++++++++++++
Aimbue_tools/imbue_tools/get_conversation_history/get_conversation_history.py | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_tools/imbue_tools/get_conversation_history/input_data_types.py | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_tools/imbue_tools/llm_output_parsing/parse_model_json_response.py | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_tools/imbue_tools/py.typed | 0
Aimbue_tools/imbue_tools/repo_utils/__init__.py | 0
Aimbue_tools/imbue_tools/repo_utils/context_prefix.py | 613+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_tools/imbue_tools/repo_utils/context_retrieval.py | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_tools/imbue_tools/repo_utils/context_utils.py | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_tools/imbue_tools/repo_utils/data_types.py | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_tools/imbue_tools/repo_utils/diff_utils.py | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_tools/imbue_tools/repo_utils/errors.py | 25+++++++++++++++++++++++++
Aimbue_tools/imbue_tools/repo_utils/file_system.py | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_tools/imbue_tools/repo_utils/file_system_utils.py | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_tools/imbue_tools/repo_utils/find_relative_to.py | 30++++++++++++++++++++++++++++++
Aimbue_tools/imbue_tools/repo_utils/project_context.py | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_tools/imbue_tools/repo_utils/python_imports.py | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_tools/imbue_tools/repo_utils/stubify_file.py | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_tools/imbue_tools/repo_utils/subrepo_formatting.py | 368+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_tools/imbue_tools/types/imbue_verify_config.py | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_tools/imbue_tools/util_prompts/conversation_prefix.py | 9+++++++++
Aimbue_tools/imbue_tools/util_prompts/goal_from_conversation.py | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aimbue_tools/pyproject.toml | 43+++++++++++++++++++++++++++++++++++++++++++
Aimbue_tools/uv.lock | 2980+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mimbue_verify/api.py | 4+++-
Mimbue_verify/cli/config/cli_config_test.py | 36++++++++++++++++++++++++------------
Mimbue_verify/cli/config/loader.py | 13++++++++-----
Mimbue_verify/cli/config/loader_test.py | 27+++++++++++++++++++++++----
Mimbue_verify/cli/main.py | 8+++++---
Mimbue_verify/cli/models.py | 4+++-
Mimbue_verify/formatters.py | 2+-
Mimbue_verify/issue_identifiers/agentic_issue_collation.py | 19++++++++++++++-----
Mimbue_verify/issue_identifiers/base.py | 9+++++++--
Mimbue_verify/issue_identifiers/common.py | 48+++++++++++++++++++++++-------------------------
Mimbue_verify/issue_identifiers/common_test.py | 66+++++++++++++++++++++++++++++++++++++++++++-----------------------
Mimbue_verify/issue_identifiers/harnesses/agentic.py | 13++++++++++---
Mimbue_verify/issue_identifiers/harnesses/base.py | 4+++-
Mimbue_verify/issue_identifiers/harnesses/conversation_single_prompt.py | 21++++++++++++++++-----
Mimbue_verify/issue_identifiers/harnesses/conversation_single_prompt_test.py | 12+++++++++---
Mimbue_verify/issue_identifiers/harnesses/single_prompt.py | 19++++++++++++++-----
Mimbue_verify/issue_identifiers/harnesses/single_prompt_test.py | 20+++++++++++++++-----
Mimbue_verify/issue_identifiers/issue_deduplication.py | 16++++++++++++----
Mimbue_verify/issue_identifiers/issue_evaluation.py | 28+++++++++++++++++++++-------
Mimbue_verify/issue_identifiers/registry.py | 56++++++++++++++++++++++++++++++++++++++++----------------
Mimbue_verify/issue_identifiers/test_prompt_lengths.py | 10++++++----
Mimbue_verify/issue_identifiers/utils.py | 15++++++++++++---
Mimbue_verify/repo_utils_test.py | 12++++++++++--
Dimbue_verify/telemetry.py | 96-------------------------------------------------------------------------------
Dimbue_verify/telemetry_test.py | 36------------------------------------
Mpyproject.toml | 13+++++++++++--
Muv.lock | 252++++++++++++++++++++++++++++++++-----------------------------------------------
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" }