Additional tweaks
This commit is contained in:
@@ -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.
|
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`)
|
### Server (`server.toml`)
|
||||||
|
|
||||||
Copy [examples/server.toml](examples/server.toml). All fields are optional except fields within a `[[keys]]` record.
|
Copy [examples/server.toml](examples/server.toml). All fields are optional except fields within a `[[keys]]` record.
|
||||||
|
|||||||
+74
-17
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import websockets
|
import websockets
|
||||||
@@ -9,6 +10,12 @@ import websockets
|
|||||||
from rex.client.config import ClientAction, ClientConfigManager
|
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:
|
async def run_command(action: ClientAction) -> None:
|
||||||
"""Run only a locally configured argv list; never parse remote shell input."""
|
"""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")
|
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:
|
async def listen(config_path: str | Path = "~/.config/rex/client.toml") -> None:
|
||||||
config = ClientConfigManager(config_path).config
|
manager = ClientConfigManager(config_path)
|
||||||
actions = {action.name: action for action in config.actions}
|
missing_key_reported = False
|
||||||
if not config.api_key:
|
retry_delay = RECONNECT_INITIAL_DELAY
|
||||||
raise RuntimeError("client.toml has no api_key")
|
while True:
|
||||||
async with websockets.connect(config.websocket_url(), additional_headers={"X-API-Key": config.api_key}) as websocket:
|
manager.reload()
|
||||||
await websocket.send({"type": "register", "actions": sorted(actions)})
|
config = manager.config
|
||||||
tasks: set[asyncio.Task[None]] = set()
|
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:
|
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)
|
message = json.loads(raw)
|
||||||
action = actions.get(message.get("name")) if message.get("type") == "action" else None
|
except json.JSONDecodeError:
|
||||||
if action:
|
logger.warning("Ignoring malformed server message")
|
||||||
task = asyncio.create_task(run_command(action))
|
receive_task = asyncio.create_task(websocket.recv())
|
||||||
tasks.add(task)
|
continue
|
||||||
task.add_done_callback(tasks.discard)
|
action = actions.get(message.get("name")) if isinstance(message, dict) and message.get("type") == "action" else None
|
||||||
finally:
|
if action:
|
||||||
for task in tasks:
|
task = asyncio.create_task(run_command(action))
|
||||||
task.cancel()
|
tasks.add(task)
|
||||||
await asyncio.gather(*tasks, return_exceptions=True)
|
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:
|
def main(config_path: str = "~/.config/rex/client.toml") -> None:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlunsplit
|
from urllib.parse import urlunsplit
|
||||||
|
|
||||||
@@ -8,6 +9,9 @@ from pydantic import BaseModel, Field, field_validator
|
|||||||
from rex.config import read_toml, write_toml
|
from rex.config import read_toml, write_toml
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ClientAction(BaseModel):
|
class ClientAction(BaseModel):
|
||||||
name: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
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)
|
argv: list[str] = Field(min_length=1, max_length=64)
|
||||||
@@ -46,7 +50,25 @@ class ClientConfig(BaseModel):
|
|||||||
class ClientConfigManager:
|
class ClientConfigManager:
|
||||||
def __init__(self, file_path: str | Path) -> None:
|
def __init__(self, file_path: str | Path) -> None:
|
||||||
self.file_path = Path(file_path).expanduser()
|
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:
|
def save(self) -> None:
|
||||||
write_toml(self.file_path, self.config.model_dump(mode="json"))
|
write_toml(self.file_path, self.config.model_dump(mode="json"))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict, deque
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
@@ -25,16 +26,15 @@ class Registration(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class RateLimiter:
|
class RateLimiter:
|
||||||
def __init__(self, per_minute: int) -> None:
|
def __init__(self) -> None:
|
||||||
self.per_minute = per_minute
|
|
||||||
self.requests: dict[str, deque[float]] = defaultdict(deque)
|
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()
|
now = time.monotonic()
|
||||||
requests = self.requests[identity]
|
requests = self.requests[identity]
|
||||||
while requests and requests[0] <= now - 60:
|
while requests and requests[0] <= now - 60:
|
||||||
requests.popleft()
|
requests.popleft()
|
||||||
if len(requests) >= self.per_minute:
|
if len(requests) >= per_minute:
|
||||||
return False
|
return False
|
||||||
requests.append(now)
|
requests.append(now)
|
||||||
return True
|
return True
|
||||||
@@ -43,11 +43,20 @@ class RateLimiter:
|
|||||||
def create_apps(config_path: str | Path = "~/.config/rex/server.toml") -> tuple[FastAPI, FastAPI]:
|
def create_apps(config_path: str | Path = "~/.config/rex/server.toml") -> tuple[FastAPI, FastAPI]:
|
||||||
manager = ServerConfigManager(config_path)
|
manager = ServerConfigManager(config_path)
|
||||||
connections = ConnectionManager()
|
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:
|
async def authorize(request: Request, permission: str) -> None:
|
||||||
host = request.client.host if request.client else 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")
|
raise HTTPException(429, "Request denied")
|
||||||
if not manager.get_key(request.headers.get("x-api-key", ""), permission):
|
if not manager.get_key(request.headers.get("x-api-key", ""), permission):
|
||||||
raise HTTPException(401, "Invalid API key or insufficient 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)
|
await authorize(request, permission)
|
||||||
return dependency
|
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)
|
management = FastAPI(title="Rex management API", docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
|
|
||||||
@public.websocket("/ws/{device_name}")
|
@public.websocket("/ws/{device_name}")
|
||||||
async def websocket_endpoint(websocket: WebSocket, device_name: str):
|
async def websocket_endpoint(websocket: WebSocket, device_name: str):
|
||||||
host = websocket.client.host if websocket.client else None
|
host = websocket.client.host if websocket.client else None
|
||||||
key = websocket.headers.get("x-api-key", "")
|
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"):
|
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")
|
raise WebSocketException(status.WS_1008_POLICY_VIOLATION, "Connection denied")
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
@@ -99,6 +108,15 @@ def create_apps(config_path: str | Path = "~/.config/rex/server.toml") -> tuple[
|
|||||||
return public, management
|
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:
|
async def run_servers(config_path: str | Path = "~/.config/rex/server.toml") -> None:
|
||||||
public, management = create_apps(config_path)
|
public, management = create_apps(config_path)
|
||||||
config = ServerConfigManager(config_path).config
|
config = ServerConfigManager(config_path).config
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -9,6 +10,9 @@ from pydantic import BaseModel, Field, field_validator
|
|||||||
from rex.config import read_toml, write_toml
|
from rex.config import read_toml, write_toml
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Key(BaseModel):
|
class Key(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
secret: str
|
secret: str
|
||||||
@@ -48,7 +52,25 @@ class ServerConfig(BaseModel):
|
|||||||
class ServerConfigManager:
|
class ServerConfigManager:
|
||||||
def __init__(self, file_path: str | Path) -> None:
|
def __init__(self, file_path: str | Path) -> None:
|
||||||
self.file_path = Path(file_path).expanduser()
|
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:
|
def save(self) -> None:
|
||||||
write_toml(self.file_path, self.config.model_dump(mode="json"))
|
write_toml(self.file_path, self.config.model_dump(mode="json"))
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ class ConnectionManager:
|
|||||||
connection = self.connections.get(client_name)
|
connection = self.connections.get(client_name)
|
||||||
return sorted(connection[1]) if connection else []
|
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:
|
async def send(self, client_name: str, action_name: str) -> bool:
|
||||||
connection = self.connections.get(client_name)
|
connection = self.connections.get(client_name)
|
||||||
|
|
||||||
|
|||||||
+38
-1
@@ -3,7 +3,7 @@ import unittest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from rex.client.config import ClientAction, ClientConfig, ClientConfigManager
|
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
|
from rex.server.connection_manager import ConnectionManager
|
||||||
|
|
||||||
|
|
||||||
@@ -28,6 +28,31 @@ class ConfigTests(unittest.TestCase):
|
|||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
ClientAction(name="bad name", argv=["/bin/echo"])
|
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):
|
class ConnectionManagerTests(unittest.IsolatedAsyncioTestCase):
|
||||||
async def test_only_registered_actions_are_sent(self) -> None:
|
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.assertTrue(await manager.send("desk", "lock"))
|
||||||
self.assertEqual(socket.messages, [{"type": "action", "name": "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"), [])
|
||||||
|
|||||||
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "markdown-it-py"
|
name = "markdown-it-py"
|
||||||
version = "4.2.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "markupsafe"
|
name = "markupsafe"
|
||||||
version = "3.0.3"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "mdurl"
|
name = "mdurl"
|
||||||
version = "0.1.2"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.13.4"
|
version = "2.13.4"
|
||||||
@@ -626,7 +588,6 @@ dependencies = [
|
|||||||
{ name = "fastapi", extra = ["standard"] },
|
{ name = "fastapi", extra = ["standard"] },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "textual" },
|
|
||||||
{ name = "uvicorn" },
|
{ name = "uvicorn" },
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
@@ -636,7 +597,6 @@ requires-dist = [
|
|||||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.141.1" },
|
{ name = "fastapi", extras = ["standard"], specifier = ">=0.141.1" },
|
||||||
{ name = "httpx", specifier = ">=0.28.1" },
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
{ name = "pydantic", specifier = ">=2.13.4" },
|
{ name = "pydantic", specifier = ">=2.13.4" },
|
||||||
{ name = "textual", specifier = ">=6.1.0" },
|
|
||||||
{ name = "uvicorn", specifier = ">=0.52.4" },
|
{ name = "uvicorn", specifier = ">=0.52.4" },
|
||||||
{ name = "websockets", specifier = ">=17.0.1" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "typer"
|
name = "typer"
|
||||||
version = "0.27.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.7.0"
|
version = "2.7.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user