Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ These come from the LoRa channel's physical constraints and must be preserved by

- Built on `anyio` (asyncio backend). `Bridge.run` creates a single task group: one consumer task per transport, one egress worker per node, one notifier flush loop. No raw `asyncio.create_task` — use the task group so cancellation propagates correctly.
- `CommitQueue` uses `anyio.create_memory_object_stream` for the queue and exposes `offer()` (non-blocking; returns `False` on full/rate-limited) and async iteration on the receive side. Don't await inside `offer`.
- Mirror-to-messenger errors are swallowed in `Bridge.mirror_to_messenger` on purpose (`# noqa: BLE001`) — a flaky messenger must not stall the LoRa pipeline.
- Mirror-to-messenger errors are swallowed in `Bridge.mirror_to_messenger` on purpose — a flaky messenger must not stall the LoRa pipeline.

### Configuration model

Expand Down
24 changes: 9 additions & 15 deletions docs/gen_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,14 @@

import inspect
import typing
from typing import Any, Union, get_args, get_origin
from typing import Any, get_args, get_origin

import mkdocs_gen_files
from pydantic import BaseModel
from pydantic.fields import FieldInfo

from lora_bridge.config import schema

from lora_bridge.config.introspect import is_union_origin, strip_annotated

# ---------------------------------------------------------------------------
# Описания секций (то, что не выводится из типов)
Expand Down Expand Up @@ -185,13 +185,13 @@ def _collect_models(root: type[BaseModel]) -> list[type[BaseModel]]:

def _models_in(t: Any) -> list[type[BaseModel]]:
"""Все BaseModel-классы, до которых можно дотянуться, развернув ``t``."""
t = _unwrap_annotated(t)
t = strip_annotated(t)
if isinstance(t, type) and issubclass(t, BaseModel):
return [t]
origin = get_origin(t)
if origin in (list, dict, tuple, set, frozenset):
return [m for a in get_args(t) for m in _models_in(a)]
if origin in (Union, typing.Union):
if is_union_origin(origin):
return [m for a in get_args(t) for m in _models_in(a)]
return []

Expand Down Expand Up @@ -235,7 +235,7 @@ def _render_type(fi: FieldInfo) -> str:

def _render_discriminated(fi: FieldInfo) -> str:
"""Discriminated union → «один из (тегов)» с ссылками на варианты."""
variants = [_unwrap_annotated(a) for a in get_args(_unwrap_annotated(fi.annotation))]
variants = [strip_annotated(a) for a in get_args(strip_annotated(fi.annotation))]
discr_field = fi.discriminator if isinstance(fi.discriminator, str) else "type"
parts: list[str] = []
for v in variants:
Expand All @@ -254,7 +254,7 @@ def _discriminator_value(model: type[BaseModel], field: str) -> str | None:
fi = model.model_fields.get(field)
if fi is None:
return None
ann = _unwrap_annotated(fi.annotation)
ann = strip_annotated(fi.annotation)
if get_origin(ann) is typing.Literal:
args = get_args(ann)
return repr(args[0]) if args else None
Expand All @@ -263,7 +263,7 @@ def _discriminator_value(model: type[BaseModel], field: str) -> str | None:

def _pretty_type(t: Any) -> str:
"""Markdown-friendly рендер аннотации типа для ячейки таблицы."""
t = _unwrap_annotated(t)
t = strip_annotated(t)
sup = getattr(t, "__supertype__", None)
if sup is not None:
# NewType — рендерим имя без ссылки: смысл id виден из описания соседних
Expand All @@ -288,7 +288,7 @@ def _pretty_type(t: Any) -> str:
return "кортеж (" + ", ".join(_pretty_type(a) for a in args) + ")"
if origin is typing.Literal:
return " \\| ".join(f"`{a!r}`" for a in args)
if origin in (Union, typing.Union):
if is_union_origin(origin):
non_none = [a for a in args if a is not type(None)]
rendered = " \\| ".join(_pretty_type(a) for a in non_none)
if len(non_none) < len(args):
Expand All @@ -303,7 +303,7 @@ def _render_default(fi: FieldInfo) -> str:
if fi.default_factory is not None:
try:
v = fi.default_factory() # type: ignore[call-arg]
except Exception:
except Exception: # noqa: BLE001 — default_factory может кинуть; показываем прочерк
return "—"
return f"`{_repr_default(v)}`"
if fi.default is None:
Expand All @@ -324,12 +324,6 @@ def _escape_cell(text: str) -> str:
return text.replace("|", r"\|").replace("\n", " ").strip()


def _unwrap_annotated(t: Any) -> Any:
while hasattr(t, "__metadata__"):
t = t.__origin__
return t


def emit_commands_page(*, path: str, title: str) -> None:
from lora_bridge.transports.telegram.commands import ALL_COMMAND_METAS

Expand Down
39 changes: 17 additions & 22 deletions lora_bridge/config/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@
from __future__ import annotations

import typing
from typing import Any, Union, get_args, get_origin
from typing import Any, get_args, get_origin

from pydantic import BaseModel, ValidationError

from .introspect import is_union_origin, strip_annotated
from .schema import AppConfig

__all__ = ["format_validation_error"]
Expand Down Expand Up @@ -278,7 +279,7 @@ def _humanize_model(model: type[BaseModel]) -> str:


def _pretty_type(t: Any) -> str:
t = _strip_annotated(t)
t = strip_annotated(t)
if t is type(None):
return "null"
# NewType — показываем «NodeId (строка)»: семантика + базовый тип
Expand All @@ -293,7 +294,7 @@ def _pretty_type(t: Any) -> str:
if origin is dict:
k, v = get_args(t)
return f"словарь {_pretty_type(k)} → {_pretty_type(v)}"
if origin in (Union, typing.Union):
if is_union_origin(origin):
inner = [_pretty_type(a) for a in get_args(t) if a is not type(None)]
return " | ".join(inner)
if origin is typing.Literal:
Expand All @@ -304,12 +305,6 @@ def _pretty_type(t: Any) -> str:
# --- резолвер: loc → набор кандидатов BaseModel ----------------------------


def _strip_annotated(t: Any) -> Any:
while hasattr(t, "__metadata__"):
t = t.__origin__
return t


def _resolve_models(loc: tuple[Any, ...]) -> list[type[BaseModel]]:
"""Идём по ``loc`` в типовом дереве ``AppConfig``; возвращаем модели на конце пути.

Expand Down Expand Up @@ -341,28 +336,28 @@ def _model_with_field(models: list[type[BaseModel]], field: Any) -> type[BaseMod


def _step(node: Any, step: Any) -> list[Any]:
node = _strip_annotated(node)
node = strip_annotated(node)
origin = get_origin(node)

if isinstance(node, type) and issubclass(node, BaseModel):
if isinstance(step, str) and step in node.model_fields:
return [_strip_annotated(node.model_fields[step].annotation)]
return [strip_annotated(node.model_fields[step].annotation)]
# smart-union: loc содержит имя класса варианта прямо здесь
if isinstance(step, str) and step == node.__name__:
return [node]
return []

if origin is list and isinstance(step, int):
return [_strip_annotated(get_args(node)[0])]
return [strip_annotated(get_args(node)[0])]

if origin is dict and isinstance(step, str):
return [_strip_annotated(get_args(node)[1])]
return [strip_annotated(get_args(node)[1])]

if origin in (Union, typing.Union):
if is_union_origin(origin):
# discriminator-тег: сузим Union до варианта, у которого Literal[step]
if isinstance(step, str):
for arg in get_args(node):
arg_t = _strip_annotated(arg)
arg_t = strip_annotated(arg)
if not (isinstance(arg_t, type) and issubclass(arg_t, BaseModel)):
continue
if arg_t.__name__ == step:
Expand All @@ -381,8 +376,8 @@ def _step(node: Any, step: Any) -> list[Any]:


def _expand_union(t: Any) -> list[Any]:
t = _strip_annotated(t)
if get_origin(t) in (Union, typing.Union):
t = strip_annotated(t)
if is_union_origin(get_origin(t)):
out: list[Any] = []
for arg in get_args(t):
if arg is type(None):
Expand All @@ -394,7 +389,7 @@ def _expand_union(t: Any) -> list[Any]:

def _variant_matches_tag(variant: type[BaseModel], tag: str) -> bool:
for fi in variant.model_fields.values():
ann = _strip_annotated(fi.annotation)
ann = strip_annotated(fi.annotation)
if get_origin(ann) is typing.Literal and tag in get_args(ann):
return True
return False
Expand All @@ -406,7 +401,7 @@ def _collect_discriminator_tags(root: type[BaseModel] | None = None) -> set[str]
seen: set[Any] = set()

def visit(t: Any) -> None:
t = _strip_annotated(t)
t = strip_annotated(t)
if t in seen:
return
seen.add(t)
Expand All @@ -415,12 +410,12 @@ def visit(t: Any) -> None:
visit(fi.annotation)
return
origin = get_origin(t)
if origin in (Union, typing.Union):
if is_union_origin(origin):
for arg in get_args(t):
arg_t = _strip_annotated(arg)
arg_t = strip_annotated(arg)
if isinstance(arg_t, type) and issubclass(arg_t, BaseModel):
for fi in arg_t.model_fields.values():
ann = _strip_annotated(fi.annotation)
ann = strip_annotated(fi.annotation)
if get_origin(ann) is typing.Literal:
for v in get_args(ann):
if isinstance(v, str):
Expand Down
23 changes: 23 additions & 0 deletions lora_bridge/config/introspect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Примитивы интроспекции типового дерева конфиг-схемы.

Общие для рендера конфиг-ошибок (``config/errors.py``) и генератора
справочника конфига (``docs/gen_pages.py``).
"""

from __future__ import annotations

import types
import typing
from typing import Any


def is_union_origin(origin: object) -> bool:
"""Union в обеих формах: ``typing.Union[X, Y]`` и PEP 604 ``X | Y`` (types.UnionType)."""
return origin is typing.Union or origin is types.UnionType


def strip_annotated(t: Any) -> Any:
"""Снять слои ``Annotated[...]``, добравшись до базового типа."""
while hasattr(t, "__metadata__"):
t = t.__origin__
return t
2 changes: 1 addition & 1 deletion lora_bridge/config/schema/app_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@

from pydantic import BaseModel, Field, model_validator

from ...domain.models import messenger_channel
from .ids import EndpointName, NodeId
from .messengers import MessengerConfig
from .nodes import LoraNode
from .rooms import LoraRef, LoraSubscriber, MessengerSubscriber, RoomConfig
from ...domain.models import messenger_channel


def validate_lora_ref(
Expand Down
4 changes: 2 additions & 2 deletions lora_bridge/config/schema/connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from __future__ import annotations

from typing import Annotated, Literal, Union
from typing import Annotated, Literal

from pydantic import BaseModel, Field

Expand Down Expand Up @@ -64,7 +64,7 @@ class BleConnection(ConnectionBase):


Connection = Annotated[
Union[UsbConnection, SerialConnection, TcpConnection, BleConnection],
UsbConnection | SerialConnection | TcpConnection | BleConnection,
Field(discriminator="type"),
]
"""Способ физического подключения к LoRa-узлу.
Expand Down
6 changes: 3 additions & 3 deletions lora_bridge/config/schema/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from __future__ import annotations

from typing import Annotated, Literal, Optional, Union
from typing import Annotated, Literal

from pydantic import BaseModel, Field, field_validator

Expand Down Expand Up @@ -103,7 +103,7 @@ def normalize_pubkey(cls, value: str) -> str:
Нормализуем здесь, чтобы ключи join'ились независимо от регистра.
"""
return value.lower()
password: Optional[str] = Field(
password: str | None = Field(
default=None,
description=(
"Гостевой пароль. Если опущен — доступ read-only (постинг недоступен)."
Expand All @@ -112,7 +112,7 @@ def normalize_pubkey(cls, value: str) -> str:


Endpoint = Annotated[
Union[PublicEndpoint, PrivateEndpoint, RoomServerEndpoint],
PublicEndpoint | PrivateEndpoint | RoomServerEndpoint,
Field(discriminator="type"),
]
"""Тип LoRa-эндпоинта в MeshCore-ноде.
Expand Down
8 changes: 4 additions & 4 deletions lora_bridge/config/schema/messengers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from __future__ import annotations

from typing import Annotated, Literal, Optional, Union
from typing import Annotated, Literal

from pydantic import BaseModel, Field

Expand All @@ -24,7 +24,7 @@ class BaseMessengerConfig(BaseModel):
kind: str = Field(
description="Тип мессенджера. Перекрывается ``Literal`` в подклассах.",
)
tag: Optional[str] = Field(
tag: str | None = Field(
default=None,
description=(
"Переопределение тега источника в префиксе ``[тип:ник]`` при выгрузке "
Expand Down Expand Up @@ -64,14 +64,14 @@ class TelegramMessengerConfig(BaseMessengerConfig):
description="Тег дискриминатора — должно быть ``telegram``."
)
token: str = Field(description="Telegram Bot API token, выданный BotFather.")
commands: Optional[TelegramCommandsConfig] = Field(
commands: TelegramCommandsConfig | None = Field(
default=None,
description="Блок команд; отсутствие или null отключает командный роутер.",
)


MessengerConfig = Annotated[
Union[TelegramMessengerConfig], # расширять Union при добавлении мессенджеров
TelegramMessengerConfig, # расширять Union при добавлении мессенджеров
Field(discriminator="kind"),
]
"""Конфиг одного мессенджер-транспорта.
Expand Down
6 changes: 2 additions & 4 deletions lora_bridge/config/schema/rooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@

from __future__ import annotations

from typing import Optional, Union

from pydantic import BaseModel, ConfigDict, Field, model_validator

from .ids import EndpointName, MessengerId, NodeId
Expand Down Expand Up @@ -36,7 +34,7 @@ class MessengerSubscriber(BaseModel):
"(узнаётся через @userinfobot или getUpdates)."
)
)
topic: Optional[str] = Field(
topic: str | None = Field(
default=None,
description=(
"Тема (thread) внутри чата. Если опущена — работаем только с General. "
Expand All @@ -53,7 +51,7 @@ class LoraSubscriber(BaseModel):
lora: LoraRef = Field(description="LoRa-эндпоинт-получатель.")


Subscriber = Union[MessengerSubscriber, LoraSubscriber]
Subscriber = MessengerSubscriber | LoraSubscriber
"""Подписчик комнаты — либо чат мессенджера, либо peer LoRa-эндпоинт.

Smart union без явного дискриминатора: pydantic выбирает форму по набору полей
Expand Down
Loading
Loading