Skip to content

Commit b005dc3

Browse files
committed
re-design pr 291
1 parent 69d9c7d commit b005dc3

9 files changed

Lines changed: 184 additions & 313 deletions

File tree

unilabos/app/main.py

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -830,29 +830,6 @@ def main():
830830
# 社区包设备直接以 community.<ns>.<id> 注册(扫描期命名空间化),不做 alias 桥接
831831
args_dict["_community_namespaces"] = community_result.namespaces
832832

833-
# Plan 09 Task 5: 发现外源包的 unilabos_registry/ 目录,并入 build_registry。
834-
# 来源:device dirs(社区包 + 显式 --devices)各自的包根 + `unilabos.registry` entry points。
835-
try:
836-
from pathlib import Path as _Path
837-
838-
from unilabos.registry.external_registry_discovery import (
839-
discover_registry_paths_from_entry_points,
840-
discover_registry_paths_from_project,
841-
)
842-
843-
_ext: list = []
844-
for _d in args_dict.get("devices") or []:
845-
_dp = _Path(_d)
846-
_ext.extend(discover_registry_paths_from_project(_dp))
847-
_ext.extend(discover_registry_paths_from_project(_dp.parent))
848-
_ext.extend(discover_registry_paths_from_entry_points())
849-
_ext_str = list(dict.fromkeys(str(p) for p in _ext))
850-
if _ext_str:
851-
args_dict["_external_registry_paths"] = _ext_str
852-
print_status(f"发现 {len(_ext_str)} 个外源 registry 目录", "info")
853-
except Exception as _ext_exc: # noqa: BLE001
854-
logger.warning(f"[ext-registry] 外源 registry 发现跳过: {_ext_exc}")
855-
856833
# Step 0: AST 分析优先 + YAML 注册表加载
857834
# check_mode 和 upload_registry 都会执行实际 import 验证
858835
devices_dirs = args_dict.get("devices", None)
@@ -866,7 +843,6 @@ def main():
866843
check_mode=check_mode,
867844
complete_registry=complete_registry,
868845
external_only=external_only,
869-
external_registry_paths=args_dict.get("_external_registry_paths"),
870846
)
871847

872848
# Check mode: 注册表验证完成后直接退出

unilabos/app/package_cli.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from pathlib import Path
1414
from typing import Any, Dict, List, Optional, Tuple
1515

16+
from unilabos.registry.init_enforce import validate_init_param_enforce
1617
from unilabos.utils import logger
1718
from unilabos.utils.banner_print import print_status
1819

@@ -53,6 +54,44 @@ def resolve_class_namespace(project_name: str, namespace: Optional[str]) -> str:
5354
return COMMUNITY_PREFIX + normalize_name(project_name)
5455

5556

57+
def discover_registry_paths_from_project(project_root: Path | str) -> List[Path]:
58+
"""从包根推导目录化注册表路径。
59+
60+
``[tool.unilabos.registry].paths`` 相对包含 ``pyproject.toml`` 的包根解析;
61+
未声明时回退到包根下的 ``unilabos_registry/``。
62+
"""
63+
root = Path(project_root).resolve()
64+
pyproject_paths = _read_pyproject_registry_paths(root)
65+
if pyproject_paths:
66+
return pyproject_paths
67+
68+
fallback = root / "unilabos_registry"
69+
if fallback.is_dir():
70+
return [fallback]
71+
return []
72+
73+
74+
def _read_pyproject_registry_paths(project_root: Path) -> List[Path]:
75+
pyproject = project_root / "pyproject.toml"
76+
if not pyproject.is_file():
77+
return []
78+
79+
data = _load_toml(pyproject)
80+
registry_config = data.get("tool", {}).get("unilabos", {}).get("registry", {})
81+
raw_paths = registry_config.get("paths", [])
82+
if not isinstance(raw_paths, list):
83+
return []
84+
85+
paths: List[Path] = []
86+
for raw_path in raw_paths:
87+
if not isinstance(raw_path, str):
88+
continue
89+
registry_path = (project_root / raw_path).resolve()
90+
if registry_path.is_dir():
91+
paths.append(registry_path)
92+
return paths
93+
94+
5695
def read_pyproject(pkg_dir: Path) -> Dict[str, Any]:
5796
"""读取 pyproject.toml 的 [project] 表,返回 name/version/summary/license/homepage/dependencies 等。"""
5897
pyproject_path = pkg_dir / "pyproject.toml"
@@ -163,7 +202,6 @@ def read_external_registry_devices(pkg_dir: Path) -> Dict[str, Dict[str, Any]]:
163202
logger.warning("[package] 未安装 pyyaml,跳过外部注册表读取")
164203
return {}
165204

166-
from unilabos.registry.external_registry_discovery import discover_registry_paths_from_project
167205
from unilabos.registry.yaml_ref import resolve_yaml_refs
168206

169207
registry_roots = discover_registry_paths_from_project(pkg_dir)
@@ -333,6 +371,13 @@ def build_resources_from_registry(
333371
for device_id, entry in entries.items():
334372
cls = entry.get("class") if isinstance(entry.get("class"), dict) else {}
335373
init_schema = entry.get("init_param_schema") if isinstance(entry.get("init_param_schema"), dict) else None
374+
init_enforce = entry.get("init_param_enforce")
375+
validate_init_param_enforce(
376+
device_id,
377+
init_schema,
378+
init_enforce,
379+
error_factory=PackageCLIError,
380+
)
336381
category = entry.get("category") or entry.get("tags") or []
337382
if isinstance(category, str):
338383
category = [category]
@@ -360,6 +405,8 @@ def build_resources_from_registry(
360405
}
361406
if init_schema is not None:
362407
resource["init_param_schema"] = init_schema
408+
if "init_param_enforce" in entry:
409+
resource["init_param_enforce"] = init_enforce
363410
resources.append(resource)
364411
return resources
365412

unilabos/registry/community_alias.py

Lines changed: 0 additions & 33 deletions
This file was deleted.

unilabos/registry/external_registry_discovery.py

Lines changed: 0 additions & 99 deletions
This file was deleted.

unilabos/registry/init_enforce.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Validation and merge helpers for enforced registry init params."""
2+
3+
import copy
4+
from typing import Any, Callable, Dict, Optional
5+
6+
7+
def merge_init_param_enforce(config: Any, init_enforce: Any) -> Dict[str, Any]:
8+
"""Merge runtime config with enforced registry params, letting registry win.
9+
10+
Runtime config may still provide fields not owned by the device model, but
11+
every field present in ``init_param_enforce`` is applied last and therefore
12+
cannot be overridden by instance-level config.
13+
"""
14+
base = copy.deepcopy(config) if isinstance(config, dict) else {}
15+
if not isinstance(init_enforce, dict):
16+
return base
17+
return _merge_dict_with_enforce(base, init_enforce)
18+
19+
20+
def _merge_dict_with_enforce(config: Dict[str, Any], init_enforce: Dict[str, Any]) -> Dict[str, Any]:
21+
merged = copy.deepcopy(config)
22+
for key, enforced_value in init_enforce.items():
23+
current_value = merged.get(key)
24+
if isinstance(current_value, dict) and isinstance(enforced_value, dict):
25+
merged[key] = _merge_dict_with_enforce(current_value, enforced_value)
26+
else:
27+
merged[key] = copy.deepcopy(enforced_value)
28+
return merged
29+
30+
31+
def validate_init_param_enforce(
32+
device_id: str,
33+
init_schema: Optional[Dict[str, Any]],
34+
init_enforce: Any,
35+
error_factory: Callable[[str], Exception] = ValueError,
36+
) -> None:
37+
"""Validate the JSON enforced config paired with ``init_param_schema``.
38+
39+
``init_param_enforce`` is a plain JSON config object. It must not reintroduce
40+
the old ``class.init`` object factory DSL; drivers should construct rich
41+
objects from JSON-friendly type strings and params inside ``__init__``.
42+
"""
43+
if not isinstance(init_schema, dict):
44+
return
45+
46+
config_schema = init_schema.get("config")
47+
if not isinstance(config_schema, dict):
48+
return
49+
50+
required = config_schema.get("required")
51+
required_fields = [str(field) for field in required] if isinstance(required, list) else []
52+
properties = config_schema.get("properties")
53+
has_config_contract = bool(required_fields) or (
54+
isinstance(properties, dict) and bool(properties)
55+
)
56+
if not has_config_contract:
57+
return
58+
59+
if not isinstance(init_enforce, dict):
60+
raise error_factory(
61+
f"{device_id}: init_param_schema.config 已声明参数,必须提供对象形式的 init_param_enforce"
62+
)
63+
64+
missing = [field for field in required_fields if field not in init_enforce]
65+
if missing:
66+
raise error_factory(
67+
f"{device_id}: init_param_enforce 缺少 required 字段: {', '.join(missing)}"
68+
)
69+
70+
_reject_legacy_init_enforce(
71+
init_enforce,
72+
f"{device_id}.init_param_enforce",
73+
error_factory,
74+
)
75+
76+
77+
def _reject_legacy_init_enforce(
78+
value: Any,
79+
path: str,
80+
error_factory: Callable[[str], Exception],
81+
) -> None:
82+
if isinstance(value, str):
83+
if "${" in value and "}" in value:
84+
raise error_factory(f"{path}: init_param_enforce 不支持 ${{...}} 模板,动态值应来自实例 config")
85+
return
86+
87+
if isinstance(value, list):
88+
for index, item in enumerate(value):
89+
_reject_legacy_init_enforce(item, f"{path}[{index}]", error_factory)
90+
return
91+
92+
if not isinstance(value, dict):
93+
return
94+
95+
keys = set(value)
96+
if "factory" in keys or "args" in keys or "kwargs" in keys or keys == {"value"}:
97+
raise error_factory(f"{path}: init_param_enforce 不支持 class.init 的 factory/args/kwargs/value DSL")
98+
99+
for key, item in value.items():
100+
_reject_legacy_init_enforce(item, f"{path}.{key}", error_factory)

0 commit comments

Comments
 (0)