fix(types): T7 — eliminate all remaining 38 mypy errors; fix DeckyRow subscript in engine tests

This commit is contained in:
2026-05-01 02:07:53 -04:00
parent 7e4da95091
commit ee24a7551f
27 changed files with 58 additions and 50 deletions

View File

@@ -194,7 +194,7 @@ async def self_destruct() -> None:
argv = ["/bin/bash", path] argv = ["/bin/bash", path]
spawn_kwargs = {"start_new_session": True} spawn_kwargs = {"start_new_session": True}
subprocess.Popen( # nosec B603 subprocess.Popen( # type: ignore[call-overload] # nosec B603
argv, argv,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,

View File

@@ -121,7 +121,7 @@ def start() -> Optional[asyncio.Task]:
return None return None
try: try:
from decnet import __version__ as _v from decnet import __version__ as _v # type: ignore[attr-defined]
agent_version = _v agent_version = _v
except Exception: except Exception:
agent_version = "unknown" agent_version = "unknown"

View File

@@ -58,7 +58,7 @@ def make_thread_safe_publisher(
contract the rest of this module already upholds. contract the rest of this module already upholds.
""" """
if bus is None: if bus is None:
return lambda _topic, _payload, _event_type="": None return lambda _topic, _payload, _event_type="": None # type: ignore[misc]
def _publish(topic: str, payload: dict[str, Any], event_type: str = "") -> None: def _publish(topic: str, payload: dict[str, Any], event_type: str = "") -> None:
# Stream threads may keep draining after the bus owner closed it # Stream threads may keep draining after the bus owner closed it

View File

@@ -131,7 +131,7 @@ def _build_response(
question = qname_bytes + struct.pack("!HH", query.qtype, query.qclass) question = qname_bytes + struct.pack("!HH", query.qtype, query.qclass)
answer = b"" answer = b""
if an_count: if an_count and answer_ip is not None:
# Use a name pointer back to the question (offset 12). # Use a name pointer back to the question (offset 12).
ptr = struct.pack("!H", 0xC000 | 12) ptr = struct.pack("!H", 0xC000 | 12)
rdata = bytes(int(o) for o in answer_ip.split(".")) rdata = bytes(int(o) for o in answer_ip.split("."))
@@ -190,7 +190,7 @@ class CanaryDNSProtocol(asyncio.DatagramProtocol):
return return
# Known name — answer with our sinkhole IP, then fire the hook. # Known name — answer with our sinkhole IP, then fire the hook.
self._send(addr, _build_response(query, answer_ip=self._answer_ip)) self._send(addr, _build_response(query, answer_ip=self._answer_ip))
asyncio.create_task(self._hook(slug, query, addr[0])) asyncio.ensure_future(self._hook(slug, query, addr[0]))
def _slug_for(self, qname: str) -> Optional[str]: def _slug_for(self, qname: str) -> Optional[str]:
if not self._zone or not qname.endswith(self._suffix): if not self._zone or not qname.endswith(self._suffix):

View File

@@ -65,7 +65,7 @@ def _gate_commands_by_mode(_app: typer.Typer) -> None:
return return
_app.registered_commands = [ _app.registered_commands = [
c for c in _app.registered_commands c for c in _app.registered_commands
if (c.name or c.callback.__name__) not in MASTER_ONLY_COMMANDS if (c.name or (c.callback.__name__ if c.callback else "")) not in MASTER_ONLY_COMMANDS
] ]
_app.registered_groups = [ _app.registered_groups = [
g for g in _app.registered_groups g for g in _app.registered_groups

View File

@@ -602,7 +602,7 @@ def register(app: typer.Typer) -> None:
# (Path("/"). / "/opt/decnet" == Path("/opt/decnet"), dropping pfx). # (Path("/"). / "/opt/decnet" == Path("/opt/decnet"), dropping pfx).
_install_rel = install_dir.lstrip("/") _install_rel = install_dir.lstrip("/")
required_tools = ("systemctl",) if deinit else ( required_tools: tuple[str, ...] = ("systemctl",) if deinit else (
"systemctl", "useradd", "groupadd", "systemd-tmpfiles", "systemctl", "useradd", "groupadd", "systemd-tmpfiles",
) )
if deinit: if deinit:
@@ -659,7 +659,7 @@ def register(app: typer.Typer) -> None:
) )
_step( _step(
"systemctl daemon-reload", "systemctl daemon-reload",
lambda: (_run(["systemctl", "daemon-reload"], dry_run=dry_run), "ok")[1], lambda: (_run(["systemctl", "daemon-reload"], dry_run=dry_run), "ok")[1], # type: ignore[func-returns-value]
) )
_step( _step(
f"remove {etc_decnet / 'decnet.ini'}", f"remove {etc_decnet / 'decnet.ini'}",
@@ -776,7 +776,7 @@ def register(app: typer.Typer) -> None:
for path, mode, d_owner, d_group in dirs: for path, mode, d_owner, d_group in dirs:
_step( _step(
f"ensure dir {path}", f"ensure dir {path}",
lambda p=path, m=mode, o=d_owner, g=d_group: lambda p=path, m=mode, o=d_owner, g=d_group: # type: ignore[misc]
_ensure_dir(p, mode=m, owner=o, group=g, dry_run=dry_run), _ensure_dir(p, mode=m, owner=o, group=g, dry_run=dry_run),
) )
_step( _step(
@@ -813,7 +813,7 @@ def register(app: typer.Typer) -> None:
) )
_step( _step(
"systemctl daemon-reload", "systemctl daemon-reload",
lambda: (_run(["systemctl", "daemon-reload"], dry_run=dry_run), "ok")[1], lambda: (_run(["systemctl", "daemon-reload"], dry_run=dry_run), "ok")[1], # type: ignore[func-returns-value]
) )
if no_start: if no_start:
@@ -824,7 +824,7 @@ def register(app: typer.Typer) -> None:
_step( _step(
"systemctl enable --now decnet.target", "systemctl enable --now decnet.target",
lambda: ( lambda: (
_run( _run( # type: ignore[func-returns-value]
["systemctl", "enable", "--now", "decnet.target"], ["systemctl", "enable", "--now", "decnet.target"],
dry_run=dry_run, dry_run=dry_run,
), ),

View File

@@ -11,7 +11,7 @@ import signal
import subprocess # nosec B404 import subprocess # nosec B404
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Any, Callable, Optional
import typer import typer
from rich.console import Console from rich.console import Console
@@ -96,7 +96,7 @@ def _is_running(match_fn) -> int | None:
return None return None
def _service_registry(log_file: str) -> list[tuple[str, callable, list[str]]]: def _service_registry(log_file: str) -> list[tuple[str, Callable[..., Any], list[str]]]:
"""Return the microservice registry for health-check and relaunch. """Return the microservice registry for health-check and relaunch.
On agents these run as systemd units invoking /usr/local/bin/decnet, On agents these run as systemd units invoking /usr/local/bin/decnet,
@@ -195,7 +195,7 @@ _DEFAULT_SWARMCTL_URL = "http://127.0.0.1:8770"
def _swarmctl_base_url(url: Optional[str]) -> str: def _swarmctl_base_url(url: Optional[str]) -> str:
return url or os.environ.get("DECNET_SWARMCTL_URL", _DEFAULT_SWARMCTL_URL) return url or os.environ.get("DECNET_SWARMCTL_URL") or _DEFAULT_SWARMCTL_URL
def _http_request(method: str, url: str, *, json_body: Optional[dict] = None, timeout: float = 30.0): def _http_request(method: str, url: str, *, json_body: Optional[dict] = None, timeout: float = 30.0):

View File

@@ -436,6 +436,8 @@ def _compose_with_retry(
console.print(f"[red]{result.stderr.strip()}[/]") console.print(f"[red]{result.stderr.strip()}[/]")
log.error("docker compose %s failed after %d attempts: %s", log.error("docker compose %s failed after %d attempts: %s",
" ".join(args), retries, result.stderr.strip()) " ".join(args), retries, result.stderr.strip())
if last_exc is None: # pragma: no cover — retries=0 is not a supported call
raise RuntimeError("_compose_with_retry exhausted retries without capturing an error")
raise last_exc raise last_exc

View File

@@ -101,7 +101,10 @@ async def mutate_decky(
try: try:
# Wrap blocking call in thread # Wrap blocking call in thread
await anyio.to_thread.run_sync(_compose_with_retry, "up", "-d", "--remove-orphans", compose_path) cp = compose_path
await anyio.to_thread.run_sync(
lambda: _compose_with_retry("up", "-d", "--remove-orphans", compose_file=cp)
)
except Exception as e: except Exception as e:
log.error("mutation failed decky=%s error=%s", decky_name, e) log.error("mutation failed decky=%s error=%s", decky_name, e)
console.print(f"[red]Failed to mutate '{decky_name}': {e}[/]") console.print(f"[red]Failed to mutate '{decky_name}': {e}[/]")
@@ -161,6 +164,8 @@ async def mutate_all(
if force or only is not None: if force or only is not None:
due = True due = True
else: else:
if interval_mins is None:
continue
elapsed_secs = now - decky.last_mutated elapsed_secs = now - decky.last_mutated
due = elapsed_secs >= (interval_mins * 60) due = elapsed_secs >= (interval_mins * 60)
remaining = (interval_mins * 60) - elapsed_secs remaining = (interval_mins * 60) - elapsed_secs

View File

@@ -514,7 +514,7 @@ async def _materialise_decky_services_diff(
break break
try: try:
await anyio.to_thread.run_sync( await anyio.to_thread.run_sync(
lambda args=args: _compose(*args, *rm_targets, compose_file=compose_path), lambda args=args: _compose(*args, *rm_targets, compose_file=compose_path), # type: ignore[misc]
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
_log.warning( _log.warning(

View File

@@ -65,7 +65,7 @@ def get_driver_for(action: Action) -> ActivityDriver:
try: try:
from decnet.orchestrator.emailgen.scheduler import EmailAction from decnet.orchestrator.emailgen.scheduler import EmailAction
except ImportError: # pragma: no cover - scheduler always exists except ImportError: # pragma: no cover - scheduler always exists
EmailAction = None # type: ignore[assignment] EmailAction = None # type: ignore[assignment, misc]
if EmailAction is not None and isinstance(action, EmailAction): if EmailAction is not None and isinstance(action, EmailAction):
from decnet.orchestrator.drivers.email import EmailDriver from decnet.orchestrator.drivers.email import EmailDriver
return EmailDriver() return EmailDriver()

View File

@@ -176,7 +176,7 @@ class EmailDriver(ActivityDriver):
"""Convenience accessor for telemetry / logging.""" """Convenience accessor for telemetry / logging."""
return self._llm.model return self._llm.model
async def run(self, action: EmailAction) -> ActivityResult: async def run(self, action: EmailAction) -> ActivityResult: # type: ignore[override]
return await self._run_email(action) return await self._run_email(action)
async def _run_email(self, action: EmailAction) -> ActivityResult: async def _run_email(self, action: EmailAction) -> ActivityResult:

View File

@@ -308,7 +308,7 @@ async def _pick_action(
) )
elif kind == "email": elif kind == "email":
try: try:
action = await email_scheduler.pick(repo, rand=rng) action = await email_scheduler.pick(repo, rand=rng) # type: ignore[assignment]
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.debug("orchestrator: email pick failed: %s", exc) logger.debug("orchestrator: email pick failed: %s", exc)
action = None action = None

View File

@@ -229,7 +229,7 @@ def issue_worker_cert(
) )
.add_extension(x509.SubjectAlternativeName(san_entries), critical=False) .add_extension(x509.SubjectAlternativeName(san_entries), critical=False)
) )
cert = builder.sign(private_key=ca_key, algorithm=hashes.SHA256()) cert = builder.sign(private_key=ca_key, algorithm=hashes.SHA256()) # type: ignore[arg-type]
cert_pem = _pem_cert(cert) cert_pem = _pem_cert(cert)
fp = hashlib.sha256( fp = hashlib.sha256(
cert.public_bytes(serialization.Encoding.DER) cert.public_bytes(serialization.Encoding.DER)

View File

@@ -80,7 +80,7 @@ async def _get_attacker_uuid(repo: BaseRepository, ip: str) -> Optional[str]:
from sqlalchemy import select from sqlalchemy import select
async with repo._session() as session: # type: ignore[attr-defined] async with repo._session() as session: # type: ignore[attr-defined]
result = await session.execute( result = await session.execute(
select(Attacker).where(Attacker.ip == ip) select(Attacker).where(Attacker.ip == ip) # type: ignore[arg-type]
) )
row = result.scalar_one_or_none() row = result.scalar_one_or_none()
return row.uuid if row else None return row.uuid if row else None

View File

@@ -202,20 +202,20 @@ def _backfill_decky_configs(
alloc = _alloc(lan_id) alloc = _alloc(lan_id)
if alloc is None: if alloc is None:
continue continue
ip: str | None = None assigned_ip: str | None = None
if primary_ip: if primary_ip:
try: try:
if ( if (
IPv4Address(primary_ip) in IPv4Network(lan["subnet"]) IPv4Address(primary_ip) in IPv4Network(lan["subnet"])
and alloc.is_free(primary_ip) and alloc.is_free(primary_ip)
): ):
ip = primary_ip assigned_ip = primary_ip
alloc.reserve(ip) alloc.reserve(assigned_ip)
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
if ip is None: if assigned_ip is None:
ip = alloc.next_free() assigned_ip = alloc.next_free()
ips_by_lan[lan["name"]] = ip ips_by_lan[lan["name"]] = assigned_ip
cfg["ips_by_lan"] = ips_by_lan cfg["ips_by_lan"] = ips_by_lan
decky["decky_config"] = cfg decky["decky_config"] = cfg

View File

@@ -11,7 +11,7 @@ from typing import Any, AsyncGenerator, Optional
from fastapi import FastAPI, Request, status from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import ORJSONResponse from fastapi.responses import ORJSONResponse, Response
from pydantic import ValidationError from pydantic import ValidationError
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -226,7 +226,7 @@ app: FastAPI = FastAPI(
) )
app.state.limiter = limiter app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # type: ignore[arg-type]
app.add_middleware(SlowAPIMiddleware) app.add_middleware(SlowAPIMiddleware)
app.add_middleware( app.add_middleware(
@@ -267,7 +267,7 @@ app.include_router(api_router, prefix="/api/v1")
@app.exception_handler(RequestValidationError) @app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> ORJSONResponse: async def validation_exception_handler(request: Request, exc: RequestValidationError) -> Response:
""" """
Handle validation errors with targeted status codes to satisfy contract tests. Handle validation errors with targeted status codes to satisfy contract tests.
Tiered Prioritization: Tiered Prioritization:

View File

@@ -19,6 +19,7 @@ def get_repository(**kwargs: Any) -> BaseRepository:
* MySQL accepts ``url`` and engine tuning knobs (``pool_size``, …). * MySQL accepts ``url`` and engine tuning knobs (``pool_size``, …).
""" """
db_type = os.environ.get("DECNET_DB_TYPE", "sqlite").lower() db_type = os.environ.get("DECNET_DB_TYPE", "sqlite").lower()
repo: BaseRepository
if db_type == "sqlite": if db_type == "sqlite":
from decnet.web.db.sqlite.repository import SQLiteRepository from decnet.web.db.sqlite.repository import SQLiteRepository

View File

@@ -12,11 +12,11 @@ SQLite's:
""" """
from __future__ import annotations from __future__ import annotations
from typing import List, Optional from typing import Any, List, Optional
from sqlalchemy import func, select, text, literal_column from sqlalchemy import func, select, text, literal_column
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlmodel.sql.expression import SelectOfScalar
from decnet.web.db.models import Log from decnet.web.db.models import Log
from decnet.web.db.mysql.database import get_async_engine from decnet.web.db.mysql.database import get_async_engine
@@ -162,11 +162,11 @@ class MySQLRepository(SQLModelRepository):
# Truncate each timestamp to the start of its bucket: # Truncate each timestamp to the start of its bucket:
# FROM_UNIXTIME( (UNIX_TIMESTAMP(timestamp) DIV N) * N ) # FROM_UNIXTIME( (UNIX_TIMESTAMP(timestamp) DIV N) * N )
# DIV is MySQL's integer division operator. # DIV is MySQL's integer division operator.
bucket_expr = literal_column( bucket_expr: Any = literal_column(
f"FROM_UNIXTIME((UNIX_TIMESTAMP(timestamp) DIV {bucket_seconds}) * {bucket_seconds})" f"FROM_UNIXTIME((UNIX_TIMESTAMP(timestamp) DIV {bucket_seconds}) * {bucket_seconds})"
).label("bucket_time") ).label("bucket_time")
statement: SelectOfScalar = select(bucket_expr, func.count().label("count")).select_from(Log) statement: Any = select(bucket_expr, func.count().label("count")).select_from(Log)
statement = self._apply_filters(statement, search, start_time, end_time) statement = self._apply_filters(statement, search, start_time, end_time)
statement = statement.group_by(literal_column("bucket_time")).order_by( statement = statement.group_by(literal_column("bucket_time")).order_by(
literal_column("bucket_time") literal_column("bucket_time")

View File

@@ -1,8 +1,8 @@
from typing import List, Optional from typing import Any, List, Optional
from sqlalchemy import func, select, text, literal_column from sqlalchemy import func, select, text, literal_column
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlmodel.sql.expression import SelectOfScalar
from decnet.config import _ROOT from decnet.config import _ROOT
from decnet.web.db.models import Log from decnet.web.db.models import Log
@@ -91,11 +91,11 @@ class SQLiteRepository(SQLModelRepository):
interval_minutes: int = 15, interval_minutes: int = 15,
) -> List[dict]: ) -> List[dict]:
bucket_seconds = max(interval_minutes, 1) * 60 bucket_seconds = max(interval_minutes, 1) * 60
bucket_expr = literal_column( bucket_expr: Any = literal_column(
f"datetime((strftime('%s', timestamp) / {bucket_seconds}) * {bucket_seconds}, 'unixepoch')" f"datetime((strftime('%s', timestamp) / {bucket_seconds}) * {bucket_seconds}, 'unixepoch')"
).label("bucket_time") ).label("bucket_time")
statement: SelectOfScalar = select(bucket_expr, func.count().label("count")).select_from(Log) statement: Any = select(bucket_expr, func.count().label("count")).select_from(Log)
statement = self._apply_filters(statement, search, start_time, end_time) statement = self._apply_filters(statement, search, start_time, end_time)
statement = statement.group_by(literal_column("bucket_time")).order_by( statement = statement.group_by(literal_column("bucket_time")).order_by(
literal_column("bucket_time") literal_column("bucket_time")

View File

@@ -39,7 +39,7 @@ router = APIRouter()
}, },
) )
@limiter.limit("10/5 minutes", key_func=login_ip_key) @limiter.limit("10/5 minutes", key_func=login_ip_key)
@limiter.limit("10/5 minutes", key_func=login_username_key) @limiter.limit("10/5 minutes", key_func=login_username_key) # type: ignore[arg-type]
@_traced("api.login") @_traced("api.login")
async def login(request: Request, payload: LoginRequest) -> dict[str, Any]: async def login(request: Request, payload: LoginRequest) -> dict[str, Any]:
_user: Optional[dict[str, Any]] = await get_user_by_username_cached(payload.username) _user: Optional[dict[str, Any]] = await get_user_by_username_cached(payload.username)

View File

@@ -114,7 +114,7 @@ def _get_active_connections(pid: int, ports: list[int]) -> list[dict]:
) )
async def api_enable_tarpit( async def api_enable_tarpit(
decky_name: str = Path(..., pattern=_DECKY_RE), decky_name: str = Path(..., pattern=_DECKY_RE),
req: TarpitEnableRequest = ..., req: TarpitEnableRequest = ..., # type: ignore[assignment]
admin: dict = Depends(require_admin), admin: dict = Depends(require_admin),
) -> MessageResponse: ) -> MessageResponse:
try: try:

View File

@@ -108,7 +108,7 @@ async def get_health(user: dict = Depends(require_viewer)) -> Any:
if _docker_client is None: if _docker_client is None:
_docker_client = await asyncio.to_thread(docker.from_env) _docker_client = await asyncio.to_thread(docker.from_env)
await asyncio.to_thread(_docker_client.ping) await asyncio.to_thread(_docker_client.ping) # type: ignore[union-attr]
_docker_healthy = True _docker_healthy = True
_docker_detail = "" _docker_detail = ""
except Exception as exc: except Exception as exc:

View File

@@ -59,7 +59,7 @@ def _db_key(topology_id: str, decky_name: str) -> str:
async def api_enable_tarpit( async def api_enable_tarpit(
topology_id: str = Path(..., pattern=_TOPO_RE), topology_id: str = Path(..., pattern=_TOPO_RE),
decky_name: str = Path(..., pattern=_DECKY_RE), decky_name: str = Path(..., pattern=_DECKY_RE),
req: TarpitEnableRequest = ..., req: TarpitEnableRequest = ..., # type: ignore[assignment]
admin: dict = Depends(require_admin), admin: dict = Depends(require_admin),
) -> MessageResponse: ) -> MessageResponse:
try: try:

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import json import json
import secrets import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any, cast
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@@ -66,7 +66,7 @@ async def api_create_webhook(
req: WebhookCreateRequest, req: WebhookCreateRequest,
admin: dict = Depends(require_admin), admin: dict = Depends(require_admin),
) -> WebhookCreateResponse: ) -> WebhookCreateResponse:
patterns = merge_patterns(req.simple_events, req.topic_patterns) patterns = merge_patterns(cast(list[str], req.simple_events), req.topic_patterns)
if not patterns: if not patterns:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
@@ -188,7 +188,7 @@ async def api_update_webhook(
# to clear all patterns must explicitly pass both as empty lists. # to clear all patterns must explicitly pass both as empty lists.
simple = req.simple_events if req.simple_events is not None else [] simple = req.simple_events if req.simple_events is not None else []
raw = req.topic_patterns if req.topic_patterns is not None else [] raw = req.topic_patterns if req.topic_patterns is not None else []
patterns = merge_patterns(simple, raw) patterns = merge_patterns(cast(list[str], simple), raw)
if not patterns: if not patterns:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,

View File

@@ -80,8 +80,8 @@ async def test_update_persists_validated_cfg_no_recreate_on_save(
rows = await repo.list_topology_deckies( rows = await repo.list_topology_deckies(
topology_with_ssh_decky["topology_id"] topology_with_ssh_decky["topology_id"]
) )
row = next(r for r in rows if r["uuid"] == topology_with_ssh_decky["decky_uuid"]) row = next(r for r in rows if r.uuid == topology_with_ssh_decky["decky_uuid"])
cfg_blob = row["decky_config"] cfg_blob = row.decky_config
if isinstance(cfg_blob, str): if isinstance(cfg_blob, str):
cfg_blob = json.loads(cfg_blob) cfg_blob = json.loads(cfg_blob)
assert cfg_blob["service_config"]["ssh"] == {"password": "hunter2"} assert cfg_blob["service_config"]["ssh"] == {"password": "hunter2"}

View File

@@ -18,9 +18,9 @@ async def _get_topology_decky(repo, decky_uuid: str) -> dict[str, Any]:
# Iterate all topologies' deckies — fine for tests with one row. # Iterate all topologies' deckies — fine for tests with one row.
topologies = await repo.list_topologies() topologies = await repo.list_topologies()
for t in topologies: for t in topologies:
for d in await repo.list_topology_deckies(t["id"]): for d in await repo.list_topology_deckies(t.id):
if d.get("uuid") == decky_uuid: if d.uuid == decky_uuid:
return d return d.model_dump()
raise AssertionError(f"decky {decky_uuid!r} not found in any topology") raise AssertionError(f"decky {decky_uuid!r} not found in any topology")
from decnet.bus.fake import FakeBus from decnet.bus.fake import FakeBus