Skip to content

Commit 90c60a2

Browse files
dmealingclaude
andcommitted
feat(cli,python): --provider hook to load a consumer provider; document custom providers (#158)
The metadata loaders already accept consumer providers in every language, but the Python CLI's _load_root → from_directory convenience used core providers only, so 'metaobjects gen/verify/docs' could not load an app's custom subtype. Add a repeatable '--provider module:symbol' that imports the consumer Provider (or a list, or a zero-arg factory) and composes it on top of core_providers — parity with TS metaobjects.config.ts providers. A provider carries a factory + optional validator (code), so it must load as a native class, not JSON. Docs: own-your-codegen.md gains a 'Custom types (custom providers)' section with the per-port load mechanism — TS config.providers, Python --provider, JVM ServiceLoader on the project classpath (META-INF/services); C# hook tracked as future work. Closes #158 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SKaSE8U5cwN4ZGFwhJwxSY
1 parent 4e78eb4 commit 90c60a2

3 files changed

Lines changed: 293 additions & 7 deletions

File tree

docs/features/own-your-codegen.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,31 @@ ADR-0015. Schema migrations are TypeScript-owned across all ports.)*
4545
the declarative template surface). This split follows each ecosystem's norms
4646
rather than forcing a single mechanism.
4747

48+
## Custom types (custom providers)
49+
50+
Beyond owning the *generators*, you can extend the *metamodel itself* — register your
51+
own type/subtype (a project-specific `view.*`, `field.*`, `validator.*`, …) through a
52+
**consumer provider**. The metadata **loaders already accept consumer providers in
53+
every language**: a runtime/library app that loads the metamodel plugs its provider in
54+
and it merges on top of the core set. The only per-port question is how the **CLI /
55+
build tool** hands that provider to the loader it already uses.
56+
57+
A provider carries **code**, not just declarations — a `factory` (how to construct the
58+
node) plus an optional imperative validator. So the mechanism must load your **native
59+
provider class** in each language: a JSON file could express the declarative surface
60+
(types / attrs / child-rules) but not the factory or validator.
61+
62+
| Port | How the CLI / build tool loads your provider |
63+
|---|---|
64+
| **TypeScript** | `metaobjects.config.ts``providers: [myProvider]`. Threaded into `meta gen`, `meta verify`, `meta docs`, **and** the offline `meta migrate` paths (baseline + generate). |
65+
| **Python** | `metaobjects gen \| verify \| docs --provider module:symbol` (repeatable). The symbol resolves to a `Provider` (or a list, or a zero-arg factory returning one) and is composed **on top of** the core providers — parity with TS `config.providers`. E.g. `metaobjects gen ./metaobjects --out ./gen --provider myapp.providers:view_provider`. |
66+
| **Java / Kotlin** | Put your compiled `MetaDataTypeProvider` on the **project classpath** with a `META-INF/services/com.metaobjects.registry.MetaDataTypeProvider` entry. `metaobjects-maven-plugin` builds the loader with the project classloader, so **Java ServiceLoader auto-discovers it** — no plugin config needed. |
67+
| **C#** | The loader accepts providers like every port; a first-class `dotnet meta` consumer-provider hook is tracked for a future release ([#158](https://github.com/metaobjectsdev/metaobjects/issues/158)). Today, extend the metamodel from an app that constructs the loader directly. |
68+
69+
This split follows each ecosystem's norms — interpreted ports (TS / Python) name or
70+
import the provider module; compiled ports (JVM) discover it on the build classpath —
71+
the same "idiomatic per port" principle as generator ownership (ADR-0035 §3).
72+
4873
## Deprecated (removed at 1.0)
4974

5075
Importing the built-in generators from `@metaobjectsdev/codegen-ts/generators`

server/python/src/metaobjects/cli.py

Lines changed: 122 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,17 +103,97 @@ def _default_generators() -> list[Generator]:
103103
]
104104

105105

106+
def _resolve_providers(specs: list[str] | None) -> tuple[list[object], list[str]]:
107+
"""Import consumer providers named as ``module:symbol`` (repeatable ``--provider``).
108+
109+
The symbol must be a metaobjects ``Provider`` (or a list of them, or a zero-arg
110+
factory returning either). A provider carries CODE — factories and optional
111+
validators — so it is loaded as a native Python object, not a JSON file (#158).
112+
This is the Python CLI's idiomatic parity with TS ``metaobjects.config.ts``
113+
``providers``; the underlying loader already accepts providers.
114+
"""
115+
if not specs:
116+
return [], []
117+
import importlib
118+
119+
from metaobjects.provider import Provider
120+
121+
providers: list[object] = []
122+
errors: list[str] = []
123+
for spec in specs:
124+
module_name, sep, symbol = spec.partition(":")
125+
if not sep or not module_name or not symbol:
126+
errors.append(
127+
f"--provider '{spec}' must be in 'module:symbol' form "
128+
"(e.g. myapp.providers:my_provider)"
129+
)
130+
continue
131+
try:
132+
module = importlib.import_module(module_name)
133+
except Exception as exc: # ImportError + anything raised at import time
134+
errors.append(f"--provider '{spec}': cannot import '{module_name}': {exc}")
135+
continue
136+
try:
137+
obj = getattr(module, symbol)
138+
except AttributeError:
139+
errors.append(
140+
f"--provider '{spec}': '{module_name}' has no attribute '{symbol}'"
141+
)
142+
continue
143+
# A zero-arg factory is supported: call it to obtain the provider(s).
144+
if callable(obj) and not isinstance(obj, Provider):
145+
try:
146+
obj = obj()
147+
except Exception as exc:
148+
errors.append(f"--provider '{spec}': factory '{symbol}()' raised: {exc}")
149+
continue
150+
for item in obj if isinstance(obj, list) else [obj]:
151+
if isinstance(item, Provider):
152+
providers.append(item)
153+
else:
154+
errors.append(
155+
f"--provider '{spec}': '{symbol}' resolved to "
156+
f"{type(item).__name__}, expected a metaobjects Provider"
157+
)
158+
return providers, errors
159+
160+
161+
def _providers_from_args(args: argparse.Namespace) -> tuple[list[object], bool]:
162+
"""Resolve ``--provider`` specs; print resolution errors. Returns (providers, ok)."""
163+
providers, errors = _resolve_providers(getattr(args, "provider", None))
164+
if errors:
165+
print("error: invalid --provider:", file=sys.stderr)
166+
for msg in errors:
167+
print(f" {msg}", file=sys.stderr)
168+
return providers, False
169+
return providers, True
170+
171+
106172
def _load_root(
107-
metadata_dir: str, strict: bool = False
173+
metadata_dir: str,
174+
strict: bool = False,
175+
providers: list[object] | None = None,
108176
) -> tuple[MetaData | None, list[str]]:
109177
"""Load metadata; return ``(root, error_messages)``. ``root`` is None on error.
110178
111179
``strict`` (ADR-0023, #96) — when True, an undeclared own ``@attr`` →
112180
``ERR_UNKNOWN_ATTR``. Defaults False so ``gen`` / ``docs`` keep the legacy
113181
open-attr load; only ``verify`` opts in (strict-by-default with a ``--lax``
114182
escape).
183+
184+
``providers`` (#158) — consumer providers (from ``--provider module:symbol``)
185+
composed ON TOP of the core set, so a config-registered custom subtype resolves
186+
through the standalone CLI just as it does when an app loads the loader directly.
187+
Empty/None keeps the core-providers-only default (``from_directory`` convenience).
115188
"""
116-
result = MetaDataLoader.from_directory(metadata_dir, strict=strict)
189+
if providers:
190+
from metaobjects.core_types import core_providers
191+
192+
result = MetaDataLoader.from_directory(
193+
metadata_dir, providers=[*core_providers, *providers], strict=strict
194+
)
195+
else:
196+
result = MetaDataLoader.from_directory(metadata_dir, strict=strict)
117197
if result.errors:
118198
msgs = [f"{e.code}: {e.message}" for e in result.errors]
119199
return None, msgs
@@ -193,6 +273,7 @@ def _generate(
193273
entity_filter: list[str] | None = None,
194274
emit_package_init: bool = True,
195275
strict: bool = False,
276+
providers: list[object] | None = None,
196277
) -> tuple[list[str], list[str]]:
197278
"""Run the generator suite into ``out_dir``.
198279
@@ -208,7 +289,7 @@ def _generate(
208289
files are written. ``strict`` (ADR-0023, #96) is threaded to the load — the
209290
``verify --codegen`` caller passes True; ``gen`` keeps the default False.
210291
"""
211-
root, errors = _load_root(metadata_dir, strict=strict)
292+
root, errors = _load_root(metadata_dir, strict=strict, providers=providers)
212293
if root is None:
213294
return [], errors
214295
return _run_suite(root, out_dir, generators, entity_filter, emit_package_init), []
@@ -237,7 +318,10 @@ def _cmd_docs(args: argparse.Namespace) -> int:
237318
model_cross_href,
238319
)
239320

240-
root, errors = _load_root(args.metadata_dir)
321+
providers, providers_ok = _providers_from_args(args)
322+
if not providers_ok:
323+
return 1
324+
root, errors = _load_root(args.metadata_dir, providers=providers)
241325
if root is None:
242326
print("error: failed to load metadata:", file=sys.stderr)
243327
for msg in errors:
@@ -364,9 +448,12 @@ def _cmd_gen(args: argparse.Namespace) -> int:
364448
spec_gens = template_spec_to_generators(spec, provider)
365449

366450
entities = _parse_entities(getattr(args, "entities", None))
451+
providers, providers_ok = _providers_from_args(args)
452+
if not providers_ok:
453+
return 1
367454
# Load the metadata ONCE; both the default suite and the (optional)
368455
# --template-spec pass run against this single loaded root.
369-
root, load_errors = _load_root(args.metadata_dir)
456+
root, load_errors = _load_root(args.metadata_dir, providers=providers)
370457
if root is None:
371458
print("error: failed to load metadata:", file=sys.stderr)
372459
for msg in load_errors:
@@ -431,10 +518,13 @@ def _verify_codegen(args: argparse.Namespace) -> int:
431518
# ADR-0023 strict-by-default (#96): verify loads strict unless --lax is given,
432519
# so an undeclared/typo'd own @attr fails verify (matching Java's Maven goal).
433520
strict = not getattr(args, "lax", False)
521+
providers, providers_ok = _providers_from_args(args)
522+
if not providers_ok:
523+
return 1
434524
with tempfile.TemporaryDirectory() as tmp:
435525
entities = _parse_entities(getattr(args, "entities", None))
436526
written, errors = _generate(
437-
args.metadata_dir, tmp, None, entities, strict=strict
527+
args.metadata_dir, tmp, None, entities, strict=strict, providers=providers
438528
)
439529
if errors:
440530
print("error: failed to load metadata:", file=sys.stderr)
@@ -508,7 +598,10 @@ def _verify_templates(args: argparse.Namespace) -> int:
508598
# ADR-0023 strict-by-default (#96) — same as --codegen: an undeclared @attr
509599
# fails verify unless --lax is passed.
510600
strict = not getattr(args, "lax", False)
511-
root, errors = _load_root(args.metadata_dir, strict=strict)
601+
providers, providers_ok = _providers_from_args(args)
602+
if not providers_ok:
603+
return 1
604+
root, errors = _load_root(args.metadata_dir, strict=strict, providers=providers)
512605
if root is None:
513606
print("error: failed to load metadata:", file=sys.stderr)
514607
for msg in errors:
@@ -717,6 +810,16 @@ def _build_parser() -> argparse.ArgumentParser:
717810
"named entities are written. Omit to emit every entity."
718811
),
719812
)
813+
gen.add_argument(
814+
"--provider",
815+
action="append",
816+
metavar="module:symbol",
817+
help=(
818+
"load a consumer metadata Provider as 'module:symbol' (repeatable) — the "
819+
"Python parity of TS metaobjects.config.ts providers, so a custom subtype "
820+
"resolves through the standalone CLI (#158)"
821+
),
822+
)
720823
gen.set_defaults(func=_cmd_gen)
721824

722825
docs = sub.add_parser(
@@ -749,6 +852,12 @@ def _build_parser() -> argparse.ArgumentParser:
749852
default=None,
750853
help="federate the model back-links to an absolute base URL (default: relative)",
751854
)
855+
docs.add_argument(
856+
"--provider",
857+
action="append",
858+
metavar="module:symbol",
859+
help="load a consumer metadata Provider as 'module:symbol' (repeatable; #158)",
860+
)
752861
docs.set_defaults(func=_cmd_docs)
753862

754863
verify = sub.add_parser(
@@ -803,6 +912,12 @@ def _build_parser() -> argparse.ArgumentParser:
803912
"open-attr load."
804913
),
805914
)
915+
verify.add_argument(
916+
"--provider",
917+
action="append",
918+
metavar="module:symbol",
919+
help="load a consumer metadata Provider as 'module:symbol' (repeatable; #158)",
920+
)
806921
verify.set_defaults(func=_cmd_verify)
807922

808923
agent_docs = sub.add_parser(
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""#158 — the Python CLI ``--provider module:symbol`` hook.
2+
3+
The metadata loaders already accept consumer providers in every language; the gap
4+
was purely at the CLI layer (``_load_root`` → ``from_directory`` used core
5+
providers only). These tests prove the standalone ``metaobjects gen``/``verify``
6+
can now load an app's custom subtype via ``--provider``, parity with the TS
7+
``metaobjects.config.ts`` ``providers`` path.
8+
"""
9+
from __future__ import annotations
10+
11+
import json
12+
import sys
13+
from pathlib import Path
14+
15+
from metaobjects.cli import main
16+
17+
# A provider module the test writes to disk + imports. Registers the custom
18+
# ``validator.geocheck`` subtype (a validator, so it exercises provider-threading
19+
# without depending on how codegen maps a novel field's physical type).
20+
PROVIDER_MODULE = '''
21+
from metaobjects.provider import Provider
22+
from metaobjects.registry import TypeDefinition
23+
from metaobjects.meta.meta_data import MetaData
24+
25+
geo_provider = Provider("test-geocheck", ("metaobjects-core-types",))
26+
geo_provider.add(TypeDefinition(
27+
type="validator",
28+
sub_type="geocheck",
29+
factory=lambda t, s, n: MetaData(t, s, n),
30+
description="A custom validator",
31+
))
32+
'''
33+
34+
# Metadata whose ``name`` field carries the custom validator — resolves ONLY when
35+
# the provider is loaded.
36+
CUSTOM_META = {
37+
"metadata.root": {
38+
"package": "acme::geo",
39+
"children": [
40+
{
41+
"object.entity": {
42+
"name": "Place",
43+
"children": [
44+
{"field.long": {"name": "id"}},
45+
{
46+
"field.string": {
47+
"name": "name",
48+
"children": [{"validator.geocheck": {"name": "chk"}}],
49+
}
50+
},
51+
{"source.rdb": {"name": "src", "@table": "places"}},
52+
{
53+
"identity.primary": {
54+
"name": "pk",
55+
"@fields": ["id"],
56+
"@generation": "increment",
57+
}
58+
},
59+
],
60+
}
61+
}
62+
],
63+
}
64+
}
65+
66+
67+
def _project(tmp_path: Path) -> str:
68+
d = tmp_path / "meta"
69+
d.mkdir()
70+
(d / "meta.json").write_text(json.dumps(CUSTOM_META))
71+
return str(d)
72+
73+
74+
def _install_provider_module(tmp_path: Path, name: str) -> None:
75+
(tmp_path / f"{name}.py").write_text(PROVIDER_MODULE)
76+
sys.path.insert(0, str(tmp_path))
77+
78+
79+
def test_gen_without_provider_fails_on_custom_subtype(tmp_path: Path) -> None:
80+
"""Baseline: the custom subtype is unknown without the provider."""
81+
rc = main(["gen", _project(tmp_path), "--out", str(tmp_path / "out")])
82+
assert rc != 0
83+
84+
85+
def test_gen_with_provider_loads_custom_subtype(tmp_path: Path) -> None:
86+
"""--provider composes the consumer provider on top of core → subtype resolves."""
87+
meta_dir = _project(tmp_path)
88+
_install_provider_module(tmp_path, "geo_prov_gen")
89+
try:
90+
rc = main(
91+
[
92+
"gen",
93+
meta_dir,
94+
"--out",
95+
str(tmp_path / "out"),
96+
"--provider",
97+
"geo_prov_gen:geo_provider",
98+
]
99+
)
100+
finally:
101+
sys.path.remove(str(tmp_path))
102+
sys.modules.pop("geo_prov_gen", None)
103+
assert rc == 0
104+
105+
106+
def test_verify_with_provider_loads_custom_subtype(tmp_path: Path) -> None:
107+
"""verify --codegen also threads --provider (reuses the gen code path)."""
108+
meta_dir = _project(tmp_path)
109+
_install_provider_module(tmp_path, "geo_prov_ver")
110+
out = tmp_path / "out"
111+
try:
112+
# generate committed output first (with the provider), then verify no drift.
113+
gen_rc = main(
114+
["gen", meta_dir, "--out", str(out), "--provider", "geo_prov_ver:geo_provider"]
115+
)
116+
assert gen_rc == 0
117+
rc = main(
118+
[
119+
"verify",
120+
meta_dir,
121+
"--codegen",
122+
"--out",
123+
str(out),
124+
"--provider",
125+
"geo_prov_ver:geo_provider",
126+
]
127+
)
128+
finally:
129+
sys.path.remove(str(tmp_path))
130+
sys.modules.pop("geo_prov_ver", None)
131+
assert rc == 0
132+
133+
134+
def test_bad_provider_spec_reports_error(tmp_path: Path) -> None:
135+
"""A malformed --provider spec fails cleanly (not 'module:symbol')."""
136+
rc = main(
137+
[
138+
"gen",
139+
_project(tmp_path),
140+
"--out",
141+
str(tmp_path / "out"),
142+
"--provider",
143+
"not-a-valid-spec",
144+
]
145+
)
146+
assert rc == 1

0 commit comments

Comments
 (0)