diff --git a/CLA.md b/CLA.md index 6060782..ef770d2 100644 --- a/CLA.md +++ b/CLA.md @@ -55,5 +55,6 @@ To accept this Agreement, open a pull request that adds an entry to the table be | _example placeholder_ | _@example_ | _2026-01-01_ | | Dhrit Timinkumar Patel | @d180 | 2026-05-20 | | Adarsh Tiwari | @adarsh9977 | 2026-05-22 | +| Muhammad usman | @Muhammad-usman92 | 2026-06-11 | Once a CLA-bot (cla-assistant.io or equivalent) is wired up, this manual table will be replaced by the bot's status check on each pull request. Existing signatures in this table remain valid; the bot reads from a separate signers list. diff --git a/sdk/adrian/__init__.py b/sdk/adrian/__init__.py index ea8fdc8..baa8385 100644 --- a/sdk/adrian/__init__.py +++ b/sdk/adrian/__init__.py @@ -337,9 +337,11 @@ def init( if loop is not None: _ws_client.schedule_connect(loop) else: - logger.debug( - "No running event loop at init(); WebSocket will connect on " - "first send from within an async context." + logger.warning( + "Adrian initialised without a running event loop. WebSocket " + "transport and BLOCK/HITL verdict handling may not be active " + "yet; sync ToolNode.invoke will fail closed until an event " + "loop connects the WebSocket and receives a policy LoginAck." ) if auto_instrument: @@ -849,13 +851,110 @@ def _build_blocked_response( return {"messages": blocked_messages} +def _resolved_tool_call_verdict( + ws: WebSocketClient, + tool_call_id: str, +) -> tuple[pb.Verdict | None, bool]: + """Return an already-resolved verdict for ``tool_call_id`` if one exists.""" + event_id = ws._tool_call_id_to_event_id.get(tool_call_id) # pyright: ignore[reportPrivateUsage] + + if event_id is None: + return None, False + + fut = ws._pending_verdicts.get(event_id) # pyright: ignore[reportPrivateUsage] + + if fut is None or not fut.done(): + return None, False + + try: + return fut.result(), True + except asyncio.CancelledError: + logger.warning( + "ToolNode: resolved sync verdict future was cancelled; halting " + "tool_call_id=%s event_id=%s", + tool_call_id, + event_id, + ) + return None, False + except Exception: + logger.exception( + "ToolNode: resolved sync verdict future failed; halting " + "tool_call_id=%s event_id=%s", + tool_call_id, + event_id, + ) + return None, False + finally: + ws._pending_verdicts.pop(event_id, None) # pyright: ignore[reportPrivateUsage] + + +def _sync_tool_node_policy_gate(input: Any) -> dict[str, list[ToolMessage]] | None: # noqa: ANN401 + """Apply the BLOCK / HITL ToolNode gate from the sync invoke path. + + Returns a synthetic blocked response when execution should halt, or + ``None`` when the original ToolNode should run. + """ + ws = _ws_client + + if ws is None: + return None + + tool_calls = _extract_tool_calls(input) + + if not ws._login_ack_received.is_set(): # pyright: ignore[reportPrivateUsage] + logger.warning( + "ToolNode: LoginAck not received in sync invoke; halting " + "(refusing to run a tool without a verified policy)" + ) + return _build_blocked_response(tool_calls) + + if not ws.policy_active(): + return None + + tool_call_id = next( + (tc.get("id") for tc in tool_calls if tc.get("id")), + None, + ) + + if not tool_call_id: + return None + + verdict, resolved = _resolved_tool_call_verdict(ws, tool_call_id) + + if not resolved: + logger.warning( + "ToolNode: sync invoke cannot wait for a BLOCK/HITL verdict; " + "halting tool_call_id=%s", + tool_call_id, + ) + return _build_blocked_response(tool_calls) + + if verdict is None: + logger.warning( + "ToolNode: sync invoke resolved an empty verdict; halting " + "tool_call_id=%s", + tool_call_id, + ) + return _build_blocked_response(tool_calls) + + if _should_halt(verdict): + logger.warning( + "halting tool execution for event_id=%s mad_code=%s", + verdict.event_id, + verdict.mad_code, + ) + return _build_blocked_response(tool_calls) + + return None + + def _patch_tool_node() -> None: """Patch ``ToolNode.invoke`` / ``ainvoke``. - In block mode, the async patch waits for the preceding LLM's verdict - before executing tools. On BLOCK (unless overridden by ``on_block``) - it returns synthetic ``ToolMessage`` responses instead of running the - tools. On timeout it fails open. + The async path waits for the preceding LLM's verdict before executing + tools. The sync path consumes already-resolved verdicts only; when + policy/verdict state is unavailable it fails closed because the SDK + cannot safely run the WebSocket wait without a running event loop. """ try: from langgraph.prebuilt import ToolNode @@ -874,9 +973,12 @@ def patched_invoke( config: Any = None, # noqa: ANN401 **kwargs: Any, ) -> Any: # noqa: ANN401 - """Inject Adrian callbacks into sync ToolNode invocation.""" + """Inject Adrian callbacks; in BLOCK / HITL modes gate sync tools.""" config = _inject_callbacks(config) + blocked = _sync_tool_node_policy_gate(input) + if blocked is not None: + return blocked return original_invoke(self, input, config=config, **kwargs) async def patched_ainvoke( diff --git a/sdk/tests/test_block_mode.py b/sdk/tests/test_block_mode.py index 0d1c352..ea18296 100644 --- a/sdk/tests/test_block_mode.py +++ b/sdk/tests/test_block_mode.py @@ -95,6 +95,17 @@ def _tool_pair() -> PairedEvent: ) +def _resolved_verdict_future(verdict: pb.Verdict) -> asyncio.Future[pb.Verdict]: + """Build a completed future for sync ToolNode.invoke tests.""" + loop = asyncio.new_event_loop() + try: + fut: asyncio.Future[pb.Verdict] = loop.create_future() + fut.set_result(verdict) + return fut + finally: + loop.close() + + class TestRunIdCorrelation: async def test_llm_pair_populates_run_id_map(self) -> None: mock_ws = AsyncMock() @@ -185,6 +196,120 @@ def _real_tool(x: str) -> str: assert len(msgs) == 1 assert "BLOCKED" in msgs[0].content + def test_sync_in_scope_block_verdict_halts_tool(self, tmp_path: Path) -> None: + """Sync MODE_BLOCK mirrors async: in-scope blocking verdict halts.""" + + def _real_tool(x: str) -> str: + """Real tool stub for sync block-mode tests.""" + _real_tool.called = True # type: ignore[attr-defined] + + return x + + _real_tool.called = False # type: ignore[attr-defined] + + adrian.init( + api_key="k", + log_file=str(tmp_path / "events.jsonl"), + auto_instrument=True, + ws_url="ws://x", + block_timeout=1.0, + ) + + ws = adrian._ws_client + assert ws is not None + policy = _apply_mode(ws, pb.MODE_BLOCK, policy_m4=True) + ws._connected.set() + ws._tool_call_id_to_event_id["tc-1"] = "llm-evt" + ws._pending_verdicts["llm-evt"] = _resolved_verdict_future( + pb.Verdict(event_id="llm-evt", mad_code="M4_a", policy=policy), + ) + + tool_node = ToolNode([_real_tool]) + ai = AIMessage( + content="", + tool_calls=[{"id": "tc-1", "name": "_real_tool", "args": {"x": "hi"}}], + ) + state: dict[str, Any] = {"messages": [ai]} + + result = tool_node.invoke(state, config=_runtime_config()) # pyright: ignore[reportUnknownMemberType] + + assert _real_tool.called is False # type: ignore[attr-defined] + msgs = result["messages"] + assert len(msgs) == 1 + assert "BLOCKED" in msgs[0].content + + def test_sync_missing_login_ack_halts_tool(self, tmp_path: Path) -> None: + """Sync ToolNode.invoke fails closed until server policy is known.""" + + def _real_tool(x: str) -> str: + """Real tool stub for sync block-mode tests.""" + _real_tool.called = True # type: ignore[attr-defined] + + return x + + _real_tool.called = False # type: ignore[attr-defined] + + adrian.init( + api_key="k", + log_file=str(tmp_path / "events.jsonl"), + auto_instrument=True, + ws_url="ws://x", + block_timeout=1.0, + ) + + tool_node = ToolNode([_real_tool]) + ai = AIMessage( + content="", + tool_calls=[{"id": "tc-1", "name": "_real_tool", "args": {"x": "hi"}}], + ) + state: dict[str, Any] = {"messages": [ai]} + + result = tool_node.invoke(state, config=_runtime_config()) # pyright: ignore[reportUnknownMemberType] + + assert _real_tool.called is False # type: ignore[attr-defined] + msgs = result["messages"] + assert len(msgs) == 1 + assert "BLOCKED" in msgs[0].content + + def test_sync_unresolved_active_policy_halts_tool(self, tmp_path: Path) -> None: + """Sync ToolNode.invoke fails closed when it cannot wait for verdicts.""" + + def _real_tool(x: str) -> str: + """Real tool stub for sync block-mode tests.""" + _real_tool.called = True # type: ignore[attr-defined] + + return x + + _real_tool.called = False # type: ignore[attr-defined] + + adrian.init( + api_key="k", + log_file=str(tmp_path / "events.jsonl"), + auto_instrument=True, + ws_url="ws://x", + block_timeout=1.0, + ) + + ws = adrian._ws_client + assert ws is not None + _apply_mode(ws, pb.MODE_BLOCK, policy_m4=True) + ws._connected.set() + ws._tool_call_id_to_event_id["tc-1"] = "llm-evt" + + tool_node = ToolNode([_real_tool]) + ai = AIMessage( + content="", + tool_calls=[{"id": "tc-1", "name": "_real_tool", "args": {"x": "hi"}}], + ) + state: dict[str, Any] = {"messages": [ai]} + + result = tool_node.invoke(state, config=_runtime_config()) # pyright: ignore[reportUnknownMemberType] + + assert _real_tool.called is False # type: ignore[attr-defined] + msgs = result["messages"] + assert len(msgs) == 1 + assert "BLOCKED" in msgs[0].content + async def test_out_of_scope_verdict_runs_tool(self, tmp_path: Path) -> None: """MODE_BLOCK with policy_m2=false + mad_code='M2' → continue (out-of-scope).""" @@ -226,6 +351,44 @@ def _real_tool(x: str) -> str: assert captured == ["hi"] + def test_sync_out_of_scope_verdict_runs_tool(self, tmp_path: Path) -> None: + """Sync MODE_BLOCK continues when the verdict family is out of scope.""" + captured: list[str] = [] + + def _real_tool(x: str) -> str: + """Real tool stub for sync block-mode tests.""" + captured.append(x) + + return x + + adrian.init( + api_key="k", + log_file=str(tmp_path / "events.jsonl"), + auto_instrument=True, + ws_url="ws://x", + block_timeout=1.0, + ) + + ws = adrian._ws_client + assert ws is not None + policy = _apply_mode(ws, pb.MODE_BLOCK, policy_m4=True) # m2 stays False + ws._connected.set() + ws._tool_call_id_to_event_id["tc-1"] = "llm-evt" + ws._pending_verdicts["llm-evt"] = _resolved_verdict_future( + pb.Verdict(event_id="llm-evt", mad_code="M2", policy=policy), + ) + + tool_node = ToolNode([_real_tool]) + ai = AIMessage( + content="", + tool_calls=[{"id": "tc-1", "name": "_real_tool", "args": {"x": "hi"}}], + ) + state: dict[str, Any] = {"messages": [ai]} + + tool_node.invoke(state, config=_runtime_config()) # pyright: ignore[reportUnknownMemberType] + + assert captured == ["hi"] + async def test_timeout_fail_open_runs_tool(self, tmp_path: Path) -> None: captured: list[str] = [] diff --git a/sdk/tests/test_init.py b/sdk/tests/test_init.py index b27c8bf..d5ac6a2 100644 --- a/sdk/tests/test_init.py +++ b/sdk/tests/test_init.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import os from collections.abc import Iterator from pathlib import Path @@ -66,6 +67,24 @@ def test_creates_jsonl_file(self, tmp_path: Path) -> None: assert log.exists() + def test_warns_when_ws_init_has_no_running_loop( + self, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, + ) -> None: + """init() should warn when WS enforcement starts without a loop.""" + caplog.set_level(logging.WARNING, logger="adrian") + log = tmp_path / "events.jsonl" + + adrian.init( + api_key="k", + log_file=str(log), + auto_instrument=False, + ws_url="ws://x", + ) + + assert "without a running event loop" in caplog.text + class TestShutdown: """Tests for adrian.shutdown().""" diff --git a/sdk/uv.lock b/sdk/uv.lock index 2c18e6e..41bf8ba 100644 --- a/sdk/uv.lock +++ b/sdk/uv.lock @@ -18,6 +18,7 @@ dev = [ { name = "langchain-mcp-adapters" }, { name = "langgraph" }, { name = "langgraph-prebuilt" }, + { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -31,6 +32,7 @@ requires-dist = [ { name = "langchain-mcp-adapters", marker = "extra == 'dev'", specifier = ">=0.2.2" }, { name = "langgraph", marker = "extra == 'dev'", specifier = "==1.1.2" }, { name = "langgraph-prebuilt", marker = "extra == 'dev'", specifier = "==1.0.8" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.6.0" }, { name = "protobuf", specifier = ">=5.29.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, @@ -149,6 +151,15 @@ wheels = [ { 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 = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -380,6 +391,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, ] +[[package]] +name = "distlib" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/8d/873e9252ea2c0e0c857884e0a2899ec43ade132345df1925ef24cbe64f18/distlib-0.4.2.tar.gz", hash = "sha256:baeb401c90f27acd15c4861ae0847d1e731c27ac3dbf4210643ba61fa1e813db", size = 614914, upload-time = "2026-06-08T16:24:15.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/aa891c893821d4d127292ed66c6940d1d715894bd5a0ce048056bc641773/distlib-0.4.2-py2.py3-none-any.whl", hash = "sha256:ca4cb11e5d746b5ec13c199cbf19ae27a241f89702b54e153a74332955446067", size = 470510, upload-time = "2026-06-08T16:24:13.208Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/f9/f38573ed5844586db374d085911740a501ccfa373b455fc9413f09f85237/filelock-3.29.1.tar.gz", hash = "sha256:d97e6b1b9757569626c58caa07dc4beb1613f4a2938b1e8cc81afca398906c9e", size = 59335, upload-time = "2026-06-03T15:19:04.053Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/a0/614c5fe402fd88951df45f4dda2fa3b4e17a99ecd92340771929169b3b95/filelock-3.29.1-py3-none-any.whl", hash = "sha256:85199dfd706869641b72b2e8955d5416a4b2b7dc4b0e8e6d97b4cc1299a6983b", size = 40750, upload-time = "2026-06-03T15:19:02.959Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -426,6 +455,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + [[package]] name = "idna" version = "3.13" @@ -626,6 +664,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "nodejs-wheel-binaries" version = "24.15.0" @@ -743,6 +790,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -752,6 +808,22 @@ 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 = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + [[package]] name = "protobuf" version = "7.34.1" @@ -946,6 +1018,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "python-discovery" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/12/38c1a0b1e64806780c9563e3fc9f6e472251839662587cfbe9bfaf2ae10a/python_discovery-1.4.0.tar.gz", hash = "sha256:eb8bc7daad3c226c147e45bb4e970a1feb1bf4048ee178e6db59e197b8010ce3", size = 68455, upload-time = "2026-05-28T01:15:37.639Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl", hash = "sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da", size = 33217, upload-time = "2026-05-28T01:15:36.573Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -1273,6 +1358,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, ] +[[package]] +name = "virtualenv" +version = "21.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/0d/4e93c8e6d1001a75763f87d8f5ecda8ebc7f4aa2153dddfaf4ae8892821a/virtualenv-21.4.2.tar.gz", hash = "sha256:38e6ee0a555615c0ea9da2ac7e9998fe8dc3b911dd33ad8eaad2020957653b0c", size = 7613326, upload-time = "2026-05-31T17:01:22.827Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/c4/557dc082be035381b85fdb2b74e21d3d21b57750b74f2b47a32f3a639ff9/virtualenv-21.4.2-py3-none-any.whl", hash = "sha256:854210ca524a1a4d0d744734f4acbc721c3ffe163b85bbf5d56d14d5ae2f0fae", size = 7594079, upload-time = "2026-05-31T17:01:20.735Z" }, +] + [[package]] name = "websockets" version = "16.0"