From 12a1789e14eadf2b7eb149ed5745db1b43d756b4 Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Thu, 27 Aug 2026 17:55:13 +0100 Subject: [PATCH] Additional tweaks --- README.md | 2 + src/rex/client/__init__.py | 91 ++++++++++++++++++++++------ src/rex/client/config.py | 24 +++++++- src/rex/server/__init__.py | 34 ++++++++--- src/rex/server/config.py | 24 +++++++- src/rex/server/connection_manager.py | 10 +++ tests/test_config.py | 39 +++++++++++- uv.lock | 66 -------------------- 8 files changed, 196 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index de5d554..2d519fe 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ The endpoint returns `{"queued":true}` only when the named device is connected a Rex creates `~/.config/rex/server.toml` and `~/.config/rex/client.toml` on first use, with owner-only file permissions. Supply another path with `--config` immediately after `server` or `client`, for example `rex server --config /etc/rex/server.toml run`. If the selected configuration is absent, Rex prints a warning naming the missing role and suggesting the other one; this catches a common `rex server` versus `rex client` typo before it quietly creates the wrong template. +Both running roles watch their TOML every half second. A server policy change (keys, devices, blacklist, or rate limit) takes effect immediately and disconnects existing clients so they re-authenticate. Client changes cause it to reconnect and re-register its actions. This includes changes made by the management CLI. Connection failures are retried indefinitely with a capped 1-to-30-second backoff. A malformed or invalid update leaves the last valid configuration active and is retried until repaired. Listener addresses and TLS paths are read only when the server starts, so changing those still requires a server restart. + ### Server (`server.toml`) Copy [examples/server.toml](examples/server.toml). All fields are optional except fields within a `[[keys]]` record. diff --git a/src/rex/client/__init__.py b/src/rex/client/__init__.py index 00ff53b..a81c81d 100644 --- a/src/rex/client/__init__.py +++ b/src/rex/client/__init__.py @@ -2,6 +2,7 @@ import asyncio import json import os import signal +import logging from pathlib import Path import websockets @@ -9,6 +10,12 @@ import websockets from rex.client.config import ClientAction, ClientConfigManager +logger = logging.getLogger(__name__) +CONFIG_POLL_INTERVAL = 0.5 +RECONNECT_INITIAL_DELAY = 1.0 +RECONNECT_MAX_DELAY = 30.0 + + async def run_command(action: ClientAction) -> None: """Run only a locally configured argv list; never parse remote shell input.""" process = await asyncio.create_subprocess_exec(*action.argv, start_new_session=os.name == "posix") @@ -38,25 +45,75 @@ async def run_command(action: ClientAction) -> None: async def listen(config_path: str | Path = "~/.config/rex/client.toml") -> None: - config = ClientConfigManager(config_path).config - actions = {action.name: action for action in config.actions} - if not config.api_key: - raise RuntimeError("client.toml has no api_key") - async with websockets.connect(config.websocket_url(), additional_headers={"X-API-Key": config.api_key}) as websocket: - await websocket.send({"type": "register", "actions": sorted(actions)}) - tasks: set[asyncio.Task[None]] = set() + manager = ClientConfigManager(config_path) + missing_key_reported = False + retry_delay = RECONNECT_INITIAL_DELAY + while True: + manager.reload() + config = manager.config + if not config.api_key: + if not missing_key_reported: + logger.warning("Client configuration has no api_key; waiting for a valid update") + missing_key_reported = True + await asyncio.sleep(CONFIG_POLL_INTERVAL) + continue + missing_key_reported = False + actions = {action.name: action for action in config.actions} try: - async for raw in websocket: + async with websockets.connect(config.websocket_url(), additional_headers={"X-API-Key": config.api_key}) as websocket: + retry_delay = RECONNECT_INITIAL_DELAY + await websocket.send(json.dumps({"type": "register", "actions": sorted(actions)})) + await _serve_connection(websocket, manager, config, actions) + except (OSError, ValueError, websockets.WebSocketException) as error: + logger.warning("Client connection failed: %s; retrying in %.0f seconds", error, retry_delay) + await asyncio.sleep(retry_delay) + retry_delay = min(retry_delay * 2, RECONNECT_MAX_DELAY) + + +async def _serve_connection( + websocket: websockets.ClientConnection, + manager: ClientConfigManager, + config: object, + actions: dict[str, ClientAction], +) -> None: + """Handle one connection until it closes or the configuration changes.""" + tasks: set[asyncio.Task[None]] = set() + receive_task = asyncio.create_task(websocket.recv()) + try: + while True: + changed = asyncio.create_task(_wait_for_config_change(manager, config)) + done, _ = await asyncio.wait({receive_task, changed}, return_when=asyncio.FIRST_COMPLETED) + if changed in done: + return + changed.cancel() + await asyncio.gather(changed, return_exceptions=True) + raw = receive_task.result() + try: message = json.loads(raw) - action = actions.get(message.get("name")) if message.get("type") == "action" else None - if action: - task = asyncio.create_task(run_command(action)) - tasks.add(task) - task.add_done_callback(tasks.discard) - finally: - for task in tasks: - task.cancel() - await asyncio.gather(*tasks, return_exceptions=True) + except json.JSONDecodeError: + logger.warning("Ignoring malformed server message") + receive_task = asyncio.create_task(websocket.recv()) + continue + action = actions.get(message.get("name")) if isinstance(message, dict) and message.get("type") == "action" else None + if action: + task = asyncio.create_task(run_command(action)) + tasks.add(task) + task.add_done_callback(tasks.discard) + receive_task = asyncio.create_task(websocket.recv()) + finally: + receive_task.cancel() + await asyncio.gather(receive_task, return_exceptions=True) + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + +async def _wait_for_config_change(manager: ClientConfigManager, config: object) -> None: + while True: + await asyncio.sleep(CONFIG_POLL_INTERVAL) + manager.reload() + if manager.config != config: + return def main(config_path: str = "~/.config/rex/client.toml") -> None: diff --git a/src/rex/client/config.py b/src/rex/client/config.py index e6c19df..b681159 100644 --- a/src/rex/client/config.py +++ b/src/rex/client/config.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from pathlib import Path from urllib.parse import urlunsplit @@ -8,6 +9,9 @@ from pydantic import BaseModel, Field, field_validator from rex.config import read_toml, write_toml +logger = logging.getLogger(__name__) + + class ClientAction(BaseModel): name: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$") argv: list[str] = Field(min_length=1, max_length=64) @@ -46,7 +50,25 @@ class ClientConfig(BaseModel): class ClientConfigManager: def __init__(self, file_path: str | Path) -> None: self.file_path = Path(file_path).expanduser() - self.config = ClientConfig.model_validate(read_toml(self.file_path, ClientConfig().model_dump(mode="json"))) + self.config = ClientConfig() + self._last_reload_error: str | None = None + self.reload() + + def reload(self) -> bool: + """Load a new configuration without discarding the last valid one.""" + try: + config = ClientConfig.model_validate(read_toml(self.file_path, ClientConfig().model_dump(mode="json"))) + except (OSError, ValueError) as error: + message = str(error) + if message != self._last_reload_error: + logger.warning("Invalid client configuration in %s; keeping the last valid configuration: %s", self.file_path, message) + self._last_reload_error = message + return False + if self._last_reload_error is not None: + logger.info("Client configuration in %s is valid again", self.file_path) + self._last_reload_error = None + self.config = config + return True def save(self) -> None: write_toml(self.file_path, self.config.model_dump(mode="json")) diff --git a/src/rex/server/__init__.py b/src/rex/server/__init__.py index e7c1f5b..c901fc2 100644 --- a/src/rex/server/__init__.py +++ b/src/rex/server/__init__.py @@ -1,6 +1,7 @@ import asyncio import time from collections import defaultdict, deque +from contextlib import asynccontextmanager from pathlib import Path import uvicorn @@ -25,16 +26,15 @@ class Registration(BaseModel): class RateLimiter: - def __init__(self, per_minute: int) -> None: - self.per_minute = per_minute + def __init__(self) -> None: self.requests: dict[str, deque[float]] = defaultdict(deque) - def allowed(self, identity: str) -> bool: + def allowed(self, identity: str, per_minute: int) -> bool: now = time.monotonic() requests = self.requests[identity] while requests and requests[0] <= now - 60: requests.popleft() - if len(requests) >= self.per_minute: + if len(requests) >= per_minute: return False requests.append(now) return True @@ -43,11 +43,20 @@ class RateLimiter: def create_apps(config_path: str | Path = "~/.config/rex/server.toml") -> tuple[FastAPI, FastAPI]: manager = ServerConfigManager(config_path) connections = ConnectionManager() - limiter = RateLimiter(manager.config.rate_limit_per_minute) + limiter = RateLimiter() + + @asynccontextmanager + async def lifespan(_: FastAPI): + watcher = asyncio.create_task(_watch_config(manager, connections)) + try: + yield + finally: + watcher.cancel() + await asyncio.gather(watcher, return_exceptions=True) async def authorize(request: Request, permission: str) -> None: host = request.client.host if request.client else None - if manager.config.is_blacklisted(host) or not limiter.allowed(host or "unknown"): + if manager.config.is_blacklisted(host) or not limiter.allowed(host or "unknown", manager.config.rate_limit_per_minute): raise HTTPException(429, "Request denied") if not manager.get_key(request.headers.get("x-api-key", ""), permission): raise HTTPException(401, "Invalid API key or insufficient permission") @@ -57,14 +66,14 @@ def create_apps(config_path: str | Path = "~/.config/rex/server.toml") -> tuple[ await authorize(request, permission) return dependency - public = FastAPI(title="Rex public API", docs_url=None, redoc_url=None, openapi_url=None) + public = FastAPI(title="Rex public API", docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan) management = FastAPI(title="Rex management API", docs_url=None, redoc_url=None, openapi_url=None) @public.websocket("/ws/{device_name}") async def websocket_endpoint(websocket: WebSocket, device_name: str): host = websocket.client.host if websocket.client else None key = websocket.headers.get("x-api-key", "") - denied = manager.config.is_blacklisted(host) or not limiter.allowed(host or "unknown") + denied = manager.config.is_blacklisted(host) or not limiter.allowed(host or "unknown", manager.config.rate_limit_per_minute) if denied or device_name not in manager.config.devices or not manager.get_key(key, "connect"): raise WebSocketException(status.WS_1008_POLICY_VIOLATION, "Connection denied") await websocket.accept() @@ -99,6 +108,15 @@ def create_apps(config_path: str | Path = "~/.config/rex/server.toml") -> tuple[ return public, management +async def _watch_config(manager: ServerConfigManager, connections: ConnectionManager) -> None: + while True: + await asyncio.sleep(0.5) + previous = manager.config + manager.reload() + if manager.config != previous: + await connections.disconnect_all() + + async def run_servers(config_path: str | Path = "~/.config/rex/server.toml") -> None: public, management = create_apps(config_path) config = ServerConfigManager(config_path).config diff --git a/src/rex/server/config.py b/src/rex/server/config.py index e405da3..3f50d84 100644 --- a/src/rex/server/config.py +++ b/src/rex/server/config.py @@ -1,6 +1,7 @@ from __future__ import annotations import ipaddress +import logging import secrets from pathlib import Path @@ -9,6 +10,9 @@ from pydantic import BaseModel, Field, field_validator from rex.config import read_toml, write_toml +logger = logging.getLogger(__name__) + + class Key(BaseModel): name: str secret: str @@ -48,7 +52,25 @@ class ServerConfig(BaseModel): class ServerConfigManager: def __init__(self, file_path: str | Path) -> None: self.file_path = Path(file_path).expanduser() - self.config = ServerConfig.model_validate(read_toml(self.file_path, ServerConfig().model_dump(mode="json"))) + self.config = ServerConfig() + self._last_reload_error: str | None = None + self.reload() + + def reload(self) -> bool: + """Load a new configuration without discarding the last valid one.""" + try: + config = ServerConfig.model_validate(read_toml(self.file_path, ServerConfig().model_dump(mode="json"))) + except (OSError, ValueError) as error: + message = str(error) + if message != self._last_reload_error: + logger.warning("Invalid server configuration in %s; keeping the last valid configuration: %s", self.file_path, message) + self._last_reload_error = message + return False + if self._last_reload_error is not None: + logger.info("Server configuration in %s is valid again", self.file_path) + self._last_reload_error = None + self.config = config + return True def save(self) -> None: write_toml(self.file_path, self.config.model_dump(mode="json")) diff --git a/src/rex/server/connection_manager.py b/src/rex/server/connection_manager.py index 0f74f25..0b308a4 100644 --- a/src/rex/server/connection_manager.py +++ b/src/rex/server/connection_manager.py @@ -17,6 +17,16 @@ class ConnectionManager: connection = self.connections.get(client_name) return sorted(connection[1]) if connection else [] + async def disconnect_all(self) -> None: + """Close all clients so a changed server policy is re-authenticated.""" + connections = list(self.connections.items()) + self.connections.clear() + for _, (websocket, _) in connections: + try: + await websocket.close(code=1012, reason="Server configuration changed") + except Exception: + pass + async def send(self, client_name: str, action_name: str) -> bool: connection = self.connections.get(client_name) diff --git a/tests/test_config.py b/tests/test_config.py index 15d0b1b..c60e333 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,7 +3,7 @@ import unittest from pathlib import Path from rex.client.config import ClientAction, ClientConfig, ClientConfigManager -from rex.server.config import ServerConfigManager +from rex.server.config import ServerConfig, ServerConfigManager from rex.server.connection_manager import ConnectionManager @@ -28,6 +28,31 @@ class ConfigTests(unittest.TestCase): with self.assertRaises(ValueError): ClientAction(name="bad name", argv=["/bin/echo"]) + def test_reload_preserves_last_valid_server_configuration(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "server.toml" + manager = ServerConfigManager(path) + manager.config = ServerConfig(devices=["desk"]) + manager.save() + manager.reload() + path.write_text("rate_limit_per_minute = 0\n") + self.assertFalse(manager.reload()) + self.assertEqual(manager.config.devices, ["desk"]) + path.write_text("rate_limit_per_minute = 100\ndevices = [\"laptop\"]\n") + self.assertTrue(manager.reload()) + self.assertEqual(manager.config.devices, ["laptop"]) + + def test_reload_preserves_last_valid_client_configuration(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "client.toml" + manager = ClientConfigManager(path) + manager.config = ClientConfig(device_name="desk") + manager.save() + manager.reload() + path.write_text("scheme = \"https\"\n") + self.assertFalse(manager.reload()) + self.assertEqual(manager.config.device_name, "desk") + class ConnectionManagerTests(unittest.IsolatedAsyncioTestCase): async def test_only_registered_actions_are_sent(self) -> None: @@ -44,3 +69,15 @@ class ConnectionManagerTests(unittest.IsolatedAsyncioTestCase): self.assertTrue(await manager.send("desk", "lock")) self.assertEqual(socket.messages, [{"type": "action", "name": "lock"}]) + async def test_disconnect_all_closes_registered_clients(self) -> None: + class Socket: + def __init__(self) -> None: + self.closed = False + async def close(self, **_: object) -> None: self.closed = True + + socket = Socket() + manager = ConnectionManager() + await manager.connect("desk", socket, {"lock"}) # type: ignore[arg-type] + await manager.disconnect_all() + self.assertTrue(socket.closed) + self.assertEqual(manager.actions("desk"), []) diff --git a/uv.lock b/uv.lock index 0ba62af..7645255 100644 --- a/uv.lock +++ b/uv.lock @@ -341,18 +341,6 @@ 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 = "linkify-it-py" -version = "2.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "uc-micro-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/53/3e/79f35b8c31a1881893b7e62be80b2573f06e38db47c33065749293ee1b97/linkify_it_py-2.1.1.tar.gz", hash = "sha256:a78f40fee177eb912e9d2375074108378523c38d3fde5d3ee804f465b6cfbfee", size = 30889, upload-time = "2026-08-24T17:16:57.028Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/3d/e34b19cd144071c583317268c4feb2c59c03ac57eef69753410c7abb11c0/linkify_it_py-2.1.1-py3-none-any.whl", hash = "sha256:8539a6b470efce90ba9b69e39b848e5b15b7ad89f7f98ca17d3532c243f987dc", size = 20532, upload-time = "2026-08-24T17:16:55.965Z" }, -] - [[package]] name = "markdown-it-py" version = "4.2.0" @@ -365,11 +353,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] -[package.optional-dependencies] -linkify = [ - { name = "linkify-it-py" }, -] - [[package]] name = "markupsafe" version = "3.0.3" @@ -422,18 +405,6 @@ wheels = [ { 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 = "mdit-py-plugins" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -443,15 +414,6 @@ 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 = "platformdirs" -version = "4.11.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/bb/ebc6636e1ae41314f796ebb7215fd28febb45f9aac72f2b04cb74b5071dc/platformdirs-4.11.4.tar.gz", hash = "sha256:f3373be828247211d0febabea97e238c3dfde8a60b3c90c32756fb52cb21556d", size = 34079, upload-time = "2026-08-24T14:53:49.676Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/28/be/0ff05fcd2938fb58ad9219bd54135968342d214737e012d62d43f06a2dd6/platformdirs-4.11.4-py3-none-any.whl", hash = "sha256:e34ff91a24bcddc6d939b878bdf3f5c437c9c46fe9e212b1bf455fdf1ee57586", size = 23741, upload-time = "2026-08-24T14:53:48.406Z" }, -] - [[package]] name = "pydantic" version = "2.13.4" @@ -626,7 +588,6 @@ dependencies = [ { name = "fastapi", extra = ["standard"] }, { name = "httpx" }, { name = "pydantic" }, - { name = "textual" }, { name = "uvicorn" }, { name = "websockets" }, ] @@ -636,7 +597,6 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.141.1" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "pydantic", specifier = ">=2.13.4" }, - { name = "textual", specifier = ">=6.1.0" }, { name = "uvicorn", specifier = ">=0.52.4" }, { name = "websockets", specifier = ">=17.0.1" }, ] @@ -790,23 +750,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, ] -[[package]] -name = "textual" -version = "8.2.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py", extra = ["linkify"] }, - { name = "mdit-py-plugins" }, - { name = "platformdirs" }, - { name = "pygments" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, -] - [[package]] name = "typer" version = "0.27.1" @@ -843,15 +786,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] -[[package]] -name = "uc-micro-py" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, -] - [[package]] name = "urllib3" version = "2.7.0"