Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

177 lines
6.5 KiB
Python

"""Channel-schema introspection — bridges Pydantic channel configs to the Web UI.
Used by ``GET /api/v1/partners/channels/schema`` so the front-end can render
generic forms for ANY channel (built-in or plugin) without hard-coding fields.
Why live here vs inside ``deeptutor.partners.channels``?
* This is an API-shaping concern (JSON Schema flattening, secret-field
detection) — keeping it next to the route avoids polluting the runtime
channel package with HTTP-specific helpers.
"""
from __future__ import annotations
import inspect
from typing import Any
from pydantic import BaseModel
from deeptutor.services.partners.manager import _is_secret_field
def resolve_config_model(channel_cls: type) -> type[BaseModel] | None:
"""Find the Pydantic config model paired with ``channel_cls``.
Convention every built-in channel follows: ``XxxChannel`` lives in the
same module as ``XxxConfig`` (e.g. ``TelegramChannel`` ↔ ``TelegramConfig``).
Falls back to "any ``*Config`` BaseModel in the module".
"""
module = inspect.getmodule(channel_cls)
if module is None:
return None
expected = channel_cls.__name__.replace("Channel", "") + "Config"
candidate = getattr(module, expected, None)
if isinstance(candidate, type) and issubclass(candidate, BaseModel):
return candidate
for _, obj in inspect.getmembers(module):
if (
isinstance(obj, type)
and obj is not BaseModel
and issubclass(obj, BaseModel)
and obj.__name__.endswith("Config")
):
return obj
return None
def inline_refs(schema: dict[str, Any]) -> dict[str, Any]:
"""Flatten Pydantic's ``$defs`` / ``$ref`` so the front-end doesn't need a resolver.
Nested model fields (e.g. ``slack.dm: SlackDMConfig``) become inline
``type: object`` subtrees with their own ``properties``.
"""
defs: dict[str, Any] = dict(schema.get("$defs", {}))
def _walk(node: Any) -> Any:
if isinstance(node, dict):
ref = node.get("$ref")
if isinstance(ref, str) and ref.startswith("#/$defs/"):
ref_name = ref.rsplit("/", 1)[-1]
resolved = defs.get(ref_name, {})
merged = {**resolved}
# Allow per-field overrides (description, default) from the ref site.
for k, v in node.items():
if k != "$ref":
merged[k] = v
return _walk(merged)
return {k: _walk(v) for k, v in node.items()}
if isinstance(node, list):
return [_walk(item) for item in node]
return node
out = _walk(schema)
if isinstance(out, dict):
out.pop("$defs", None)
return out
def _schema_accepts_string(prop_schema: dict[str, Any]) -> bool:
"""True iff the JSON-Schema fragment can hold a string value.
Used to filter out booleans/integers/arrays whose name happens to contain
a secret-looking substring (e.g. ``user_token_read_only: bool``).
"""
t = prop_schema.get("type")
if t == "string":
return True
if isinstance(t, list) and "string" in t:
return True
for variant in prop_schema.get("anyOf", []):
if isinstance(variant, dict) and variant.get("type") == "string":
return True
return False
def collect_secret_fields(schema: dict[str, Any], prefix: str = "") -> list[str]:
"""Return dot-paths for every string-typed property whose name hints at a secret.
e.g. ``["token"]`` for telegram, ``["imap_password", "smtp_password"]``
for email, ``["bot_token", "app_token"]`` for slack. A field like
``user_token_read_only: bool`` is intentionally skipped.
"""
paths: list[str] = []
properties = schema.get("properties") if isinstance(schema, dict) else None
if not isinstance(properties, dict):
return paths
for prop_name, prop_schema in properties.items():
if not isinstance(prop_schema, dict):
continue
full = f"{prefix}{prop_name}" if not prefix else f"{prefix}.{prop_name}"
if _is_secret_field(prop_name) and _schema_accepts_string(prop_schema):
paths.append(full)
if prop_schema.get("type") == "object":
paths.extend(collect_secret_fields(prop_schema, prefix=full))
return paths
def channel_schema_payload(channel_cls: type) -> dict[str, Any] | None:
"""Build the per-channel schema payload, or ``None`` if no config model found."""
model = resolve_config_model(channel_cls)
if model is None:
return None
# by_alias=False → property names match Python field names (snake_case),
# which is exactly the shape we persist in ``config.yaml`` and what every
# channel's ``__init__`` expects when ``model_validate(dict)`` is called.
# The pydantic Base config has populate_by_name=True so the runtime still
# accepts both forms; we standardise on snake_case for the wire schema.
raw = model.model_json_schema(by_alias=False)
flat = inline_refs(raw)
secret_fields = collect_secret_fields(flat)
try:
default_config = model().model_dump(mode="json", by_alias=False)
except Exception:
default_config = {}
return {
"name": getattr(channel_cls, "name", channel_cls.__name__),
"display_name": getattr(channel_cls, "display_name", channel_cls.__name__),
"default_config": default_config,
"secret_fields": secret_fields,
"json_schema": flat,
}
def all_channel_schemas() -> dict[str, dict[str, Any]]:
"""Build the schema dict for every discovered channel (built-in + plugins).
Channels whose module failed to import (missing optional dependency)
still appear, marked ``available: False`` with the import error — the UI
shows them grayed out instead of silently dropping them.
"""
from deeptutor.partners.channels.registry import discover_all_with_errors
channels, errors = discover_all_with_errors()
out: dict[str, dict[str, Any]] = {}
for name, cls in channels.items():
payload = channel_schema_payload(cls)
if payload is not None:
payload["available"] = True
out[name] = payload
for name, reason in errors.items():
out[name] = {
"name": name,
"display_name": name.title(),
"available": False,
"unavailable_reason": reason,
"default_config": {"enabled": False},
"secret_fields": [],
"json_schema": None,
}
return out