From 5b361f1268b40f5e2aa499889fa739bf0a649b91 Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Sat, 8 Aug 2026 11:46:11 -0500 Subject: [PATCH 1/2] feat: add SessionDB state helpers --- .github/workflows/test.yml | 4 +- README.md | 32 ++++++ hermes_plugin_kit/__init__.py | 187 +++++++++++++++++++++++++++++++++- tests/test_hermes_contract.py | 170 +++++++++++++++++++++++++++++++ tests/test_kit.py | 75 ++++++++++++++ 5 files changed, 466 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f520d51..754f85c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,4 +35,6 @@ jobs: - name: Run hermes contract tests against upstream main env: HERMES_AGENT_PATH: ${{ github.workspace }}/.hermes-agent - run: uv run python -m unittest tests.test_hermes_contract -v + run: | + git -C "$HERMES_AGENT_PATH" rev-parse HEAD + uv run python -m unittest tests.test_hermes_contract -v diff --git a/README.md b/README.md index 079a942..8d886bc 100644 --- a/README.md +++ b/README.md @@ -379,6 +379,38 @@ A handler returns a `dict` (becomes the success `data`), or raises (becomes a to error), or returns a `str` as an escape hatch (treated as already-encoded JSON). It must accept `(args, **kwargs)` — runtime keys like `task_id`/`session_id` arrive as kwargs. +## Session state helpers + +The kit can read sessions and messages and append transcript rows through +Hermes' public `SessionDB` API. It imports Hermes only when a database is +opened, so the package keeps its zero-dependency runtime contract: + +```python +from hermes_plugin_kit import ( + append_session_message, + open_session_db, + read_session, + read_session_messages, +) + +with open_session_db() as db: # current Hermes profile's state.db + session = read_session(db, session_id) + messages = read_session_messages(db, session_id, limit=50, latest=True) + row_id = append_session_message(db, session_id, "user", "Remember this") +``` + +`open_session_db(db_path)` constructs a Hermes `SessionDB` for that path and +closes it on exit. `open_session_db(db=existing_db)` borrows a caller-owned +handle and leaves it open. Supplying both is an error. Prefer the injected form +inside a running plugin when Hermes already owns the profile-scoped handle. + +Opening a writable `SessionDB` can migrate its schema. Tests must therefore use +a generated database or a copy under temporary storage; never a developer's +live `~/.hermes/state.db`. Production helpers issue no raw SQL and delegate +ordering, pagination, structured message encoding, locking, and migration to +Hermes itself. An incompatible Hermes build raises +`SessionDBCompatibilityError` naming the missing contract. + ## Calling host-managed capabilities Not every Hermes capability lives in `tools.registry`. In particular, diff --git a/hermes_plugin_kit/__init__.py b/hermes_plugin_kit/__init__.py index e70bea7..af640e4 100644 --- a/hermes_plugin_kit/__init__.py +++ b/hermes_plugin_kit/__init__.py @@ -61,10 +61,11 @@ def register(ctx): import sys import threading import time +from contextlib import contextmanager from dataclasses import dataclass from enum import Enum from pathlib import Path -from typing import Any, Callable, Iterable +from typing import Any, Callable, Iterable, Iterator, Protocol __all__ = [ "tool", @@ -75,6 +76,13 @@ def register(ctx): "register_plugin", "log_registration_summary", "invoke_host_tool", + "open_session_db", + "read_session", + "list_sessions", + "read_session_messages", + "append_session_message", + "SessionDBLike", + "SessionDBCompatibilityError", "deliver_media", "resolve_delivery_target", "transform_media_delivery_output", @@ -126,6 +134,183 @@ def register(ctx): _TELEGRAM_SPOILER_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} +class SessionDBCompatibilityError(RuntimeError): + """Hermes does not expose the SessionDB contract required by the kit.""" + + +class SessionDBLike(Protocol): + """Public SessionDB methods used by the kit's state helpers.""" + + def get_session(self, session_id: str) -> dict[str, Any] | None: ... + + def list_sessions_rich(self, **kwargs: Any) -> list[dict[str, Any]]: ... + + def get_messages(self, session_id: str, **kwargs: Any) -> list[dict[str, Any]]: ... + + def append_message( + self, session_id: str, role: str, content: Any = None, **kwargs: Any + ) -> int: ... + + def close(self) -> None: ... + + +def _session_db_method(db: Any, method_name: str) -> Callable[..., Any]: + method = getattr(db, method_name, None) + if not callable(method): + raise SessionDBCompatibilityError( + "hermes-agent SessionDB is incompatible: required public method " + f"{method_name}() is unavailable" + ) + return method + + +def _call_session_db( + db: Any, method_name: str, *args: Any, **kwargs: Any +) -> Any: + method = _session_db_method(db, method_name) + try: + inspect.signature(method).bind(*args, **kwargs) + except (TypeError, ValueError) as exc: + raise SessionDBCompatibilityError( + "hermes-agent SessionDB is incompatible: public method " + f"{method_name}() does not accept the required arguments: {exc}" + ) from exc + return method(*args, **kwargs) + + +@contextmanager +def open_session_db( + db_path: str | Path | None = None, + *, + db: SessionDBLike | None = None, +) -> Iterator[SessionDBLike]: + """Yield an injected or lazily opened Hermes ``SessionDB``. + + An injected handle remains caller-owned and is never closed here. When the + kit constructs the handle, it closes it on context exit. Constructing a + real ``SessionDB`` may migrate the selected database, so tests should + always provide a path in temporary storage. + """ + if db is not None and db_path is not None: + raise ValueError("db and db_path are mutually exclusive") + if db is not None: + yield db + return + try: + from hermes_state import SessionDB + except (ImportError, AttributeError) as exc: + raise SessionDBCompatibilityError( + "hermes-agent SessionDB is unavailable; run inside a compatible " + "Hermes runtime or inject a SessionDB-compatible handle" + ) from exc + + if db_path is None: + owned_db = SessionDB() + else: + try: + inspect.signature(SessionDB).bind(db_path=Path(db_path)) + except (TypeError, ValueError) as exc: + raise SessionDBCompatibilityError( + "hermes-agent SessionDB does not support the required db_path contract" + ) from exc + owned_db = SessionDB(db_path=Path(db_path)) + try: + yield owned_db + finally: + _session_db_method(owned_db, "close")() + + +def read_session(db: SessionDBLike, session_id: str) -> dict[str, Any] | None: + """Read one session through Hermes' public SessionDB API.""" + return _call_session_db(db, "get_session", session_id) + + +def list_sessions( + db: SessionDBLike, + *, + source: str | None = None, + sources: list[str] | None = None, + exclude_sources: list[str] | None = None, + cwd_prefix: str | None = None, + limit: int = 20, + offset: int = 0, + include_children: bool = False, + min_message_count: int = 0, + project_compression_tips: bool = True, + order_by_last_active: bool = False, + include_archived: bool = False, + archived_only: bool = False, + id_query: str | None = None, + search_query: str | None = None, + compact_rows: bool = False, + include_pinned: bool = False, + session_key: str | None = None, +) -> list[dict[str, Any]]: + """List rich session rows using Hermes-supported filters and pagination.""" + return _call_session_db( + db, + "list_sessions_rich", + source=source, + sources=sources, + exclude_sources=exclude_sources, + cwd_prefix=cwd_prefix, + limit=limit, + offset=offset, + include_children=include_children, + min_message_count=min_message_count, + project_compression_tips=project_compression_tips, + order_by_last_active=order_by_last_active, + include_archived=include_archived, + archived_only=archived_only, + id_query=id_query, + search_query=search_query, + compact_rows=compact_rows, + include_pinned=include_pinned, + session_key=session_key, + ) + + +def read_session_messages( + db: SessionDBLike, + session_id: str, + *, + include_inactive: bool = False, + limit: int | None = None, + offset: int = 0, + latest: bool = False, + after_id: int | None = None, +) -> list[dict[str, Any]]: + """Read a session transcript using Hermes' ordering and paging rules.""" + return _call_session_db( + db, + "get_messages", + session_id, + include_inactive=include_inactive, + limit=limit, + offset=offset, + latest=latest, + after_id=after_id, + ) + + +def append_session_message( + db: SessionDBLike, + session_id: str, + role: str, + content: Any = None, + **message_fields: Any, +) -> int: + """Append a message, forwarding structured fields to Hermes unchanged.""" + return _call_session_db( + db, + "append_message", + session_id, + role, + content, + **message_fields, + ) + + @dataclass(frozen=True) class PluginSkill: """Validated declaration for a plugin-owned, read-only Hermes skill.""" diff --git a/tests/test_hermes_contract.py b/tests/test_hermes_contract.py index a456f99..c3bc97c 100644 --- a/tests/test_hermes_contract.py +++ b/tests/test_hermes_contract.py @@ -21,7 +21,10 @@ import inspect import json import os +import sqlite3 +import subprocess import sys +import tempfile import types import unittest from pathlib import Path @@ -60,6 +63,7 @@ def _try(): run_tool_execution_middleware, ) from tools.registry import registry # type: ignore + from hermes_state import SCHEMA_VERSION, SessionDB # type: ignore # A stale checkout that predates plugin-owned skills is not the # lifecycle contract this suite is intended to certify. @@ -84,6 +88,8 @@ def _try(): run_llm_execution_middleware=run_llm_execution_middleware, run_tool_execution_middleware=run_tool_execution_middleware, registry=registry, + SCHEMA_VERSION=SCHEMA_VERSION, + SessionDB=SessionDB, ) try: @@ -108,6 +114,72 @@ def _try(): _REAL = _import_real_hermes() +def _write_legacy_state_db(db_path: Path) -> None: + """Create a tiny pre-v16 database without borrowing a user's state.""" + conn = sqlite3.connect(db_path) + try: + conn.executescript( + """ + CREATE TABLE schema_version (version INTEGER NOT NULL); + INSERT INTO schema_version VALUES (15); + + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + user_id TEXT, + session_key TEXT, + chat_id TEXT, + chat_type TEXT, + thread_id TEXT, + model TEXT, + model_config TEXT, + system_prompt TEXT, + parent_session_id TEXT, + started_at REAL NOT NULL, + ended_at REAL, + end_reason TEXT, + message_count INTEGER DEFAULT 0, + tool_call_count INTEGER DEFAULT 0, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + cache_read_tokens INTEGER DEFAULT 0, + cache_write_tokens INTEGER DEFAULT 0, + reasoning_tokens INTEGER DEFAULT 0, + cwd TEXT, + git_branch TEXT, + git_repo_root TEXT, + title TEXT + ); + + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id), + role TEXT NOT NULL, + content TEXT, + tool_call_id TEXT, + tool_calls TEXT, + tool_name TEXT, + timestamp REAL NOT NULL, + token_count INTEGER, + finish_reason TEXT, + reasoning TEXT, + active INTEGER NOT NULL DEFAULT 1 + ); + + INSERT INTO sessions ( + id, source, model, started_at, message_count, title + ) VALUES ('legacy-session', 'cli', 'legacy-model', 100, 2, 'Legacy'); + INSERT INTO messages (session_id, role, content, timestamp) + VALUES ('legacy-session', 'user', 'legacy question', 101); + INSERT INTO messages (session_id, role, content, timestamp) + VALUES ('legacy-session', 'assistant', 'legacy answer', 102); + """ + ) + conn.commit() + finally: + conn.close() + + class _RecordingCtx: def __init__(self) -> None: self.calls: list[dict] = [] @@ -147,6 +219,21 @@ async def hpk_command_contract_probe(raw_args): class HermesContractTests(unittest.TestCase): """Validate the kit's output against genuine hermes-agent runtime APIs.""" + @classmethod + def setUpClass(cls) -> None: + root = os.environ.get("HERMES_AGENT_PATH") + if root: + try: + commit = subprocess.run( + ["git", "-C", root, "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + except (OSError, subprocess.CalledProcessError): + commit = "unknown" + print(f"hermes-agent contract commit: {commit}") + def _register_probe(self): reg = _REAL.registry reg.register( @@ -161,6 +248,89 @@ def _register_probe(self): self.addCleanup(reg.deregister, _SPEC["name"]) return reg + def test_state_helpers_migrate_and_operate_on_temporary_legacy_db(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + db_path = Path(tmp) / "state.db" + _write_legacy_state_db(db_path) + + with hpk.open_session_db(db_path) as owned: + legacy = hpk.read_session(owned, "legacy-session") + self.assertIsNotNone(legacy) + self.assertEqual(legacy["title"], "Legacy") + self.assertEqual( + [m["content"] for m in hpk.read_session_messages(owned, "legacy-session")], + ["legacy question", "legacy answer"], + ) + self.assertIsNone(hpk.read_session(owned, "missing-session")) + + conn = sqlite3.connect(db_path) + try: + version = conn.execute( + "SELECT version FROM schema_version LIMIT 1" + ).fetchone()[0] + finally: + conn.close() + self.assertEqual(version, _REAL.SCHEMA_VERSION) + + injected = _REAL.SessionDB(db_path=db_path) + try: + with hpk.open_session_db(db=injected) as borrowed: + self.assertIs(borrowed, injected) + injected.create_session("current-session", "cli") + first_id = hpk.append_session_message( + borrowed, "current-session", "user", "current question" + ) + structured_id = hpk.append_session_message( + borrowed, + "current-session", + "assistant", + "current answer", + tool_calls=[ + { + "id": "call-1", + "type": "function", + "function": {"name": "probe", "arguments": "{}"}, + } + ], + reasoning_details=[{"type": "summary", "text": "checked"}], + display_kind="status", + display_metadata={"label": "complete"}, + ) + self.assertLess(first_id, structured_id) + self.assertEqual( + hpk.read_session(injected, "legacy-session")["id"], + "legacy-session", + "the injected handle must remain open", + ) + + page_one = hpk.list_sessions(injected, limit=1, offset=0) + page_two = hpk.list_sessions(injected, limit=1, offset=1) + self.assertEqual(page_one[0]["id"], "current-session") + self.assertEqual(page_two[0]["id"], "legacy-session") + self.assertEqual( + [m["content"] for m in hpk.read_session_messages( + injected, "current-session", limit=1, offset=1 + )], + ["current answer"], + ) + with self.assertRaisesRegex(ValueError, "incompatible"): + hpk.read_session_messages( + injected, "current-session", offset=1, after_id=first_id + ) + finally: + injected.close() + + with hpk.open_session_db(db_path) as reopened: + messages = hpk.read_session_messages(reopened, "current-session") + self.assertEqual([m["content"] for m in messages], [ + "current question", + "current answer", + ]) + self.assertEqual(messages[1]["tool_calls"][0]["id"], "call-1") + reasoning_details = json.loads(messages[1]["reasoning_details"]) + self.assertEqual(reasoning_details[0]["text"], "checked") + self.assertEqual(messages[1]["display_metadata"], {"label": "complete"}) + def test_kit_schema_survives_real_registry_conversion(self) -> None: # registry.get_definitions does the exact {**schema, "name": ...} spread the # model receives. The kit's parameters wrapper must survive it populated. diff --git a/tests/test_kit.py b/tests/test_kit.py index bd8a192..bcda5ee 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -48,6 +48,81 @@ def register_skill(self, **kwargs) -> None: self.skills.append(kwargs) +class SessionDBHelperTests(unittest.TestCase): + def test_injected_db_is_delegated_to_and_remains_open(self) -> None: + db = Mock() + db.get_session.return_value = {"id": "s1"} + db.list_sessions_rich.return_value = [{"id": "s1"}] + db.get_messages.return_value = [{"id": 7, "content": "hello"}] + db.append_message.return_value = 8 + + with hpk.open_session_db(db=db) as opened: + self.assertIs(opened, db) + self.assertEqual(hpk.read_session(opened, "s1"), {"id": "s1"}) + self.assertEqual(hpk.list_sessions(opened, limit=1), [{"id": "s1"}]) + self.assertEqual( + hpk.read_session_messages(opened, "s1", limit=1), + [{"id": 7, "content": "hello"}], + ) + self.assertEqual( + hpk.append_session_message( + opened, + "s1", + "assistant", + "done", + tool_calls=[{"id": "call-1"}], + ), + 8, + ) + + db.close.assert_not_called() + db.get_messages.assert_called_once_with( + "s1", + include_inactive=False, + limit=1, + offset=0, + latest=False, + after_id=None, + ) + db.append_message.assert_called_once_with( + "s1", "assistant", "done", tool_calls=[{"id": "call-1"}] + ) + + def test_db_and_path_are_exclusive(self) -> None: + with self.assertRaisesRegex(ValueError, "mutually exclusive"): + with hpk.open_session_db("state.db", db=Mock()): + pass + + def test_missing_public_method_has_instructive_error(self) -> None: + with self.assertRaisesRegex( + hpk.SessionDBCompatibilityError, "required public method get_session" + ): + hpk.read_session(object(), "s1") + + def test_incompatible_public_signature_has_instructive_error(self) -> None: + db = types.SimpleNamespace(get_messages=lambda session_id: []) + with self.assertRaisesRegex( + hpk.SessionDBCompatibilityError, + r"get_messages\(\) does not accept the required arguments", + ): + hpk.read_session_messages(db, "s1", limit=1) + + def test_missing_hermes_import_has_instructive_error(self) -> None: + real_import = __import__ + + def guarded_import(name, *args, **kwargs): + if name == "hermes_state": + raise ImportError("not installed") + return real_import(name, *args, **kwargs) + + with patch("builtins.__import__", side_effect=guarded_import): + with self.assertRaisesRegex( + hpk.SessionDBCompatibilityError, "SessionDB is unavailable" + ): + with hpk.open_session_db("state.db"): + pass + + class RuntimeCompatibilityTests(unittest.TestCase): def test_manifest_config_remains_compatible_without_loading_host_config(self) -> None: ctx = types.SimpleNamespace( From 1e39c338ac12d6415781ddb4a47f816a1bea342c Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Sat, 8 Aug 2026 11:48:40 -0500 Subject: [PATCH 2/2] test: install upstream contract dependency --- pyproject.toml | 2 +- uv.lock | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6fb58b5..676b073 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ Repository = "https://github.com/offendingcommit/hermes-plugin-kit" # Runtime stays dependency-free. The hermes contract tests import current # upstream source, whose plugin and gateway seams transitively need these. [dependency-groups] -dev = ["pyyaml", "requests==2.33.0"] +dev = ["httpx[socks]==0.28.1", "pyyaml", "requests==2.33.0"] [tool.setuptools] packages = ["hermes_plugin_kit"] diff --git a/uv.lock b/uv.lock index 6b7bed7..bd5d6d1 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,19 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "anyio" +version = "4.14.2" +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/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -85,6 +98,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, ] +[[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 = "hermes-plugin-kit" version = "0.7.0" @@ -92,6 +114,7 @@ source = { editable = "." } [package.dev-dependencies] dev = [ + { name = "httpx", extra = ["socks"] }, { name = "pyyaml" }, { name = "requests" }, ] @@ -100,10 +123,44 @@ dev = [ [package.metadata.requires-dev] dev = [ + { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, { name = "pyyaml" }, { name = "requests", specifier = "==2.33.0" }, ] +[[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.optional-dependencies] +socks = [ + { name = "socksio" }, +] + [[package]] name = "idna" version = "3.18" @@ -183,6 +240,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, ] +[[package]] +name = "socksio" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + [[package]] name = "urllib3" version = "2.7.0"