metaobjects 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- metaobjects/__init__.py +75 -0
- metaobjects/agent_context/__init__.py +55 -0
- metaobjects/agent_context/_content/README.md +14 -0
- metaobjects/agent_context/_content/servers/csharp.meta.json +5 -0
- metaobjects/agent_context/_content/servers/java.meta.json +5 -0
- metaobjects/agent_context/_content/servers/kotlin.meta.json +5 -0
- metaobjects/agent_context/_content/servers/python.meta.json +5 -0
- metaobjects/agent_context/_content/servers/typescript.meta.json +5 -0
- metaobjects/agent_context/_content/skills/metaobjects-authoring/SKILL.md +301 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/SKILL.md +99 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/csharp.md +87 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/java.md +94 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/kotlin.md +110 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/typescript.md +135 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/SKILL.md +148 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/csharp.md +110 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/java.md +108 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/kotlin.md +130 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/python.md +116 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/typescript.md +150 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/SKILL.md +130 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/java.md +96 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/kotlin.md +99 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/react.md +86 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/tanstack.md +119 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/typescript.md +92 -0
- metaobjects/agent_context/_content/skills/metaobjects-verify/SKILL.md +107 -0
- metaobjects/agent_context/_content/skills/metaobjects-verify/references/migration.md +72 -0
- metaobjects/agent_context/_content/templates/always-on.md.mustache +27 -0
- metaobjects/agent_context/assemble.py +133 -0
- metaobjects/agent_context/content_root.py +54 -0
- metaobjects/agent_context/scaffold.py +191 -0
- metaobjects/agent_context/types.py +44 -0
- metaobjects/attr_class_map.py +23 -0
- metaobjects/cli.py +696 -0
- metaobjects/codegen/__init__.py +0 -0
- metaobjects/codegen/config.py +11 -0
- metaobjects/codegen/constants.py +13 -0
- metaobjects/codegen/extract_delegate_emitter.py +384 -0
- metaobjects/codegen/extract_schema_emitter.py +139 -0
- metaobjects/codegen/format.py +31 -0
- metaobjects/codegen/fr010_field_mapping.py +220 -0
- metaobjects/codegen/generator.py +62 -0
- metaobjects/codegen/generator_registry.py +163 -0
- metaobjects/codegen/generators/__init__.py +0 -0
- metaobjects/codegen/generators/entity_model.py +263 -0
- metaobjects/codegen/generators/extractor_generator.py +317 -0
- metaobjects/codegen/generators/filter_allowlist_generator.py +309 -0
- metaobjects/codegen/generators/m2m_codegen.py +192 -0
- metaobjects/codegen/generators/output_parser_generator.py +272 -0
- metaobjects/codegen/generators/output_prompt_generator.py +192 -0
- metaobjects/codegen/generators/payload_vo_generator.py +672 -0
- metaobjects/codegen/generators/render_helper_generator.py +451 -0
- metaobjects/codegen/generators/router_generator.py +635 -0
- metaobjects/codegen/generators/template_generator.py +70 -0
- metaobjects/codegen/generators/tph_plan.py +120 -0
- metaobjects/codegen/generators/trace_helper_generator.py +336 -0
- metaobjects/codegen/instance_artifacts.py +15 -0
- metaobjects/codegen/output_format_spec_emitter.py +79 -0
- metaobjects/codegen/overwrite_policy.py +27 -0
- metaobjects/codegen/runner.py +110 -0
- metaobjects/codegen/runtime/__init__.py +6 -0
- metaobjects/codegen/runtime/filter_parser.py +193 -0
- metaobjects/codegen/type_map.py +84 -0
- metaobjects/core_types.py +809 -0
- metaobjects/datatype.py +19 -0
- metaobjects/documentation/__init__.py +28 -0
- metaobjects/documentation/doc_constants.py +20 -0
- metaobjects/documentation/doc_provider.py +20 -0
- metaobjects/documentation/doc_schema.py +24 -0
- metaobjects/errors.py +124 -0
- metaobjects/loader/__init__.py +0 -0
- metaobjects/loader/merge.py +287 -0
- metaobjects/loader/meta_data_loader.py +245 -0
- metaobjects/loader/sources/__init__.py +24 -0
- metaobjects/loader/sources/directory_source.py +50 -0
- metaobjects/loader/sources/file_source.py +41 -0
- metaobjects/loader/sources/meta_data_source.py +67 -0
- metaobjects/loader/sources/uri_source.py +56 -0
- metaobjects/loader/validate_discriminator.py +181 -0
- metaobjects/loader/validate_field_readonly.py +146 -0
- metaobjects/loader/validate_source_parameter_ref.py +159 -0
- metaobjects/loader/validate_source_physical_names.py +140 -0
- metaobjects/loader/validation_passes.py +1513 -0
- metaobjects/meta/__init__.py +1 -0
- metaobjects/meta/core/__init__.py +0 -0
- metaobjects/meta/core/attr/__init__.py +0 -0
- metaobjects/meta/core/attr/attr_constants.py +31 -0
- metaobjects/meta/core/attr/meta_attr.py +136 -0
- metaobjects/meta/core/field/__init__.py +0 -0
- metaobjects/meta/core/field/field_constants.py +105 -0
- metaobjects/meta/core/field/meta_field.py +76 -0
- metaobjects/meta/core/identity/__init__.py +0 -0
- metaobjects/meta/core/identity/identity_constants.py +19 -0
- metaobjects/meta/core/identity/meta_identity.py +8 -0
- metaobjects/meta/core/object/__init__.py +0 -0
- metaobjects/meta/core/object/meta_object.py +65 -0
- metaobjects/meta/core/object/meta_object_aware.py +43 -0
- metaobjects/meta/core/object/object_class_registry.py +56 -0
- metaobjects/meta/core/object/object_constants.py +13 -0
- metaobjects/meta/core/object/object_extract.py +400 -0
- metaobjects/meta/core/object/value_object.py +70 -0
- metaobjects/meta/core/relationship/__init__.py +0 -0
- metaobjects/meta/core/relationship/derive_m2m_fields.py +180 -0
- metaobjects/meta/core/relationship/meta_relationship.py +54 -0
- metaobjects/meta/core/relationship/relationship_constants.py +51 -0
- metaobjects/meta/core/validator/__init__.py +0 -0
- metaobjects/meta/core/validator/validator_constants.py +18 -0
- metaobjects/meta/meta_data.py +206 -0
- metaobjects/meta/meta_root.py +8 -0
- metaobjects/meta/persistence/__init__.py +0 -0
- metaobjects/meta/persistence/db/__init__.py +1 -0
- metaobjects/meta/persistence/db/db_constants.py +41 -0
- metaobjects/meta/persistence/db/db_provider.py +60 -0
- metaobjects/meta/persistence/origin/__init__.py +0 -0
- metaobjects/meta/persistence/origin/meta_origin.py +8 -0
- metaobjects/meta/persistence/origin/origin_constants.py +20 -0
- metaobjects/meta/persistence/source/__init__.py +0 -0
- metaobjects/meta/persistence/source/meta_source.py +137 -0
- metaobjects/meta/persistence/source/source_constants.py +115 -0
- metaobjects/meta/presentation/__init__.py +0 -0
- metaobjects/meta/presentation/layout/__init__.py +0 -0
- metaobjects/meta/presentation/layout/layout_constants.py +13 -0
- metaobjects/meta/presentation/layout/meta_layout.py +8 -0
- metaobjects/meta/presentation/view/__init__.py +0 -0
- metaobjects/meta/presentation/view/meta_view.py +8 -0
- metaobjects/meta/presentation/view/view_constants.py +22 -0
- metaobjects/meta/template/__init__.py +0 -0
- metaobjects/meta/template/meta_template.py +46 -0
- metaobjects/meta/template/template_constants.py +112 -0
- metaobjects/meta/template/template_provider.py +43 -0
- metaobjects/parser.py +380 -0
- metaobjects/parser_yaml.py +82 -0
- metaobjects/provider.py +111 -0
- metaobjects/py.typed +0 -0
- metaobjects/registry.py +210 -0
- metaobjects/registry_manifest.py +223 -0
- metaobjects/render/__init__.py +74 -0
- metaobjects/render/email_document.py +14 -0
- metaobjects/render/escapers.py +109 -0
- metaobjects/render/extract/__init__.py +59 -0
- metaobjects/render/extract/coerce.py +279 -0
- metaobjects/render/extract/extract.py +211 -0
- metaobjects/render/extract/extract_map.py +61 -0
- metaobjects/render/extract/json_forgiving_reader.py +203 -0
- metaobjects/render/extract/locate.py +65 -0
- metaobjects/render/extract/normalize.py +96 -0
- metaobjects/render/extract/strip.py +20 -0
- metaobjects/render/extract/types.py +332 -0
- metaobjects/render/extract/xml_forgiving_reader.py +162 -0
- metaobjects/render/filesystem_provider.py +51 -0
- metaobjects/render/prompt/__init__.py +32 -0
- metaobjects/render/prompt/output_format_renderer.py +340 -0
- metaobjects/render/prompt/output_format_spec.py +28 -0
- metaobjects/render/prompt/prompt_field.py +29 -0
- metaobjects/render/prompt/prompt_overrides.py +29 -0
- metaobjects/render/prompt/prompt_style.py +38 -0
- metaobjects/render/renderer.py +358 -0
- metaobjects/render/verify.py +266 -0
- metaobjects/runtime/__init__.py +39 -0
- metaobjects/runtime/llm_recorder.py +210 -0
- metaobjects/runtime/n2m_resolver.py +155 -0
- metaobjects/runtime/object_manager.py +715 -0
- metaobjects/runtime/tph.py +50 -0
- metaobjects/serializer_json.py +172 -0
- metaobjects/shared/__init__.py +0 -0
- metaobjects/shared/base_types.py +16 -0
- metaobjects/shared/separators.py +4 -0
- metaobjects/shared/structural.py +9 -0
- metaobjects/source/__init__.py +79 -0
- metaobjects/source/error_source.py +266 -0
- metaobjects/source/json_path.py +106 -0
- metaobjects/source/semantic_diff.py +98 -0
- metaobjects/source/yaml_positions.py +174 -0
- metaobjects/super_resolve.py +128 -0
- metaobjects/yaml_desugar.py +481 -0
- metaobjects-0.9.0.dist-info/METADATA +97 -0
- metaobjects-0.9.0.dist-info/RECORD +181 -0
- metaobjects-0.9.0.dist-info/WHEEL +4 -0
- metaobjects-0.9.0.dist-info/entry_points.txt +2 -0
- metaobjects-0.9.0.dist-info/licenses/LICENSE +189 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""run_gen — the codegen orchestrator. Mirrors codegen-ts/src/runner.ts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from metaobjects.meta.meta_root import MetaRoot
|
|
10
|
+
from metaobjects.meta.meta_data import MetaData
|
|
11
|
+
from metaobjects.meta.core.object.meta_object import MetaObject
|
|
12
|
+
from metaobjects.shared.base_types import TYPE_OBJECT
|
|
13
|
+
from .config import GenConfig
|
|
14
|
+
from .generator import GenContext, Generator
|
|
15
|
+
from .overwrite_policy import decide_and_write
|
|
16
|
+
|
|
17
|
+
_VALID_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class RunGenResult:
|
|
22
|
+
files: list[tuple[str, str]] = field(default_factory=list) # (path, status)
|
|
23
|
+
warnings: list[str] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _objects(root: MetaData) -> list[MetaObject]:
|
|
27
|
+
return [c for c in root.own_children()
|
|
28
|
+
if c.type == TYPE_OBJECT and isinstance(c, MetaObject)]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _matcher(gen: Generator) -> Callable[[MetaObject], bool]:
|
|
32
|
+
flt = getattr(gen, "filter", None)
|
|
33
|
+
if callable(flt):
|
|
34
|
+
return flt # type: ignore[no-any-return]
|
|
35
|
+
|
|
36
|
+
def _all(_e: MetaObject) -> bool:
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
return _all
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _warn_collector(name: str, sink: list[str]) -> Callable[[str], None]:
|
|
43
|
+
def warn(msg: str) -> None:
|
|
44
|
+
sink.append(f"[{name}] {msg}")
|
|
45
|
+
|
|
46
|
+
return warn
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run_gen(
|
|
50
|
+
config: GenConfig,
|
|
51
|
+
metadata: MetaData,
|
|
52
|
+
*,
|
|
53
|
+
generators: list[Generator],
|
|
54
|
+
entity_filter: list[str] | None = None,
|
|
55
|
+
merge_strategy: str = "overwrite",
|
|
56
|
+
) -> RunGenResult:
|
|
57
|
+
result = RunGenResult()
|
|
58
|
+
if not isinstance(metadata, MetaRoot):
|
|
59
|
+
raise ValueError("run_gen: metadata must be a loaded MetaRoot.")
|
|
60
|
+
|
|
61
|
+
objs = _objects(metadata)
|
|
62
|
+
if entity_filter is not None:
|
|
63
|
+
objs = [o for o in objs if o.name in entity_filter]
|
|
64
|
+
|
|
65
|
+
if not objs:
|
|
66
|
+
reason = (
|
|
67
|
+
"no object children match the provided entity_filter"
|
|
68
|
+
if entity_filter is not None
|
|
69
|
+
else "root has no object children"
|
|
70
|
+
)
|
|
71
|
+
result.warnings.append(f"No entities to generate — {reason}.")
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
safe: list[MetaObject] = []
|
|
75
|
+
for o in objs:
|
|
76
|
+
if not _VALID_NAME.match(o.name):
|
|
77
|
+
result.warnings.append(
|
|
78
|
+
f"Skipping entity with unsafe name {o.name!r} — must match ^[A-Za-z_]\\w*$."
|
|
79
|
+
)
|
|
80
|
+
continue
|
|
81
|
+
safe.append(o)
|
|
82
|
+
if not safe:
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
emitted: dict[str, tuple[str, str]] = {} # full_path -> (content, generated_by)
|
|
86
|
+
for gen in generators:
|
|
87
|
+
ctx = GenContext(
|
|
88
|
+
entities=safe,
|
|
89
|
+
loaded_root=metadata,
|
|
90
|
+
matches=_matcher(gen),
|
|
91
|
+
config=config,
|
|
92
|
+
warn=_warn_collector(gen.name, result.warnings),
|
|
93
|
+
)
|
|
94
|
+
for f in gen.generate(ctx):
|
|
95
|
+
full = os.path.join(config.out_dir, f.path)
|
|
96
|
+
if full in emitted:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"Output path collision: {full!r} emitted by "
|
|
99
|
+
f"{emitted[full][1]!r} and {gen.name!r}."
|
|
100
|
+
)
|
|
101
|
+
emitted[full] = (f.content, gen.name)
|
|
102
|
+
|
|
103
|
+
for full, (content, _by) in emitted.items():
|
|
104
|
+
status = decide_and_write(full, content, merge_strategy)
|
|
105
|
+
result.files.append((full, status))
|
|
106
|
+
if status == "refused":
|
|
107
|
+
result.warnings.append(
|
|
108
|
+
f"Refused to overwrite {full}: file exists without the @generated header."
|
|
109
|
+
)
|
|
110
|
+
return result
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Runtime helpers shipped alongside generated routers.
|
|
2
|
+
|
|
3
|
+
Generated code imports from this package directly — the helpers are part of
|
|
4
|
+
the public surface of `metaobjects.codegen`. Keep imports here minimal and
|
|
5
|
+
substrate-neutral (no FastAPI / SQLAlchemy / pg8000 dependencies).
|
|
6
|
+
"""
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Runtime helper shipped alongside the generated FastAPI routers. Parses
|
|
2
|
+
the cross-port FR-009 filter grammar ``filter[<field>][<op>]=<value>`` (and
|
|
3
|
+
the ``filter[<field>]=<value>`` sugar form for ``eq``) against a per-entity
|
|
4
|
+
allowlist.
|
|
5
|
+
|
|
6
|
+
This module is part of ``metaobjects.codegen``'s public runtime surface —
|
|
7
|
+
generated routers import + call into it directly. It is intentionally
|
|
8
|
+
substrate-agnostic: ``parse_filter`` returns a list of ``FilterPredicate``
|
|
9
|
+
records that the consumer's repository implementation translates to its
|
|
10
|
+
persistence DSL of choice (SQLAlchemy, asyncpg, pg8000, etc.).
|
|
11
|
+
|
|
12
|
+
Returned ``FilterParseResult`` is either:
|
|
13
|
+
|
|
14
|
+
* ``predicates`` populated + ``error_envelope`` ``None`` — success path; the
|
|
15
|
+
generated router passes ``predicates`` into the repository.
|
|
16
|
+
* ``error_envelope`` non-``None`` (one of ``invalid_filter_field`` /
|
|
17
|
+
``invalid_filter_op`` / ``invalid_filter_value``) — the generated router
|
|
18
|
+
raises ``HTTPException(status_code=400, detail=result.error_envelope)``.
|
|
19
|
+
|
|
20
|
+
Mirror of the Java ``FilterParser`` / Kotlin ``KotlinFilterAllowlistGenerator`` /
|
|
21
|
+
C# ``FilterParser`` ports — same wire grammar, same error envelopes, same
|
|
22
|
+
substrate-neutral predicate shape.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Any, Iterable, Mapping
|
|
28
|
+
from urllib.parse import unquote_plus
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Sentinel returned by `_coerce_value` on a parse failure.
|
|
32
|
+
_INVALID_VALUE = object()
|
|
33
|
+
|
|
34
|
+
# Error envelope keys — must stay identical across all ports.
|
|
35
|
+
_ERR_FIELD = "invalid_filter_field"
|
|
36
|
+
_ERR_OP = "invalid_filter_op"
|
|
37
|
+
_ERR_VALUE = "invalid_filter_value"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class FilterPredicate:
|
|
42
|
+
"""A single validated filter clause from the URL.
|
|
43
|
+
|
|
44
|
+
The ``value`` field holds either:
|
|
45
|
+
|
|
46
|
+
* a ``bool`` (only for the ``isNull`` op),
|
|
47
|
+
* a ``list[str]`` (only for the ``in`` op — comma-separated values trimmed),
|
|
48
|
+
* a ``str`` (for ``eq`` / ``ne`` / ``gt`` / ``gte`` / ``lt`` / ``lte`` /
|
|
49
|
+
``like`` — raw URL-decoded text, the repository binds it via
|
|
50
|
+
parameterized SQL so the driver picks the column type).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
field: str
|
|
54
|
+
op: str
|
|
55
|
+
value: Any
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class FilterParseResult:
|
|
60
|
+
"""Outcome of parsing the ``filter[...]`` query params.
|
|
61
|
+
|
|
62
|
+
Exactly one of ``predicates`` (success) / ``error_envelope`` (failure)
|
|
63
|
+
is populated. On error, ``predicates`` is an empty list.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
predicates: list[FilterPredicate] = field(default_factory=list)
|
|
67
|
+
error_envelope: dict[str, str] | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_filter(
|
|
71
|
+
query_params: Iterable[tuple[str, str]] | Mapping[str, str],
|
|
72
|
+
allowed_fields: Iterable[str],
|
|
73
|
+
ops_by_field: Mapping[str, Iterable[str]],
|
|
74
|
+
) -> FilterParseResult:
|
|
75
|
+
"""Parse the ``filter[<field>][<op>]=<value>`` grammar from query params.
|
|
76
|
+
|
|
77
|
+
``query_params`` is the FastAPI ``Request.query_params`` object (a
|
|
78
|
+
multi-dict-like that yields ``(key, value)`` pairs when iterated as
|
|
79
|
+
``.multi_items()``). For testing, a plain ``list[tuple[str, str]]`` works
|
|
80
|
+
too — anything iterable that yields the raw key/value pairs in URL
|
|
81
|
+
order. A plain ``Mapping`` collapses repeats (last-value-wins), which is
|
|
82
|
+
fine for tests that don't repeat keys.
|
|
83
|
+
|
|
84
|
+
Returns a ``FilterParseResult`` — on success, ``predicates`` carries the
|
|
85
|
+
validated + coerced filter list (preserving URL order); on failure,
|
|
86
|
+
``error_envelope`` is one of the cross-port envelopes
|
|
87
|
+
(``invalid_filter_field`` / ``invalid_filter_op`` /
|
|
88
|
+
``invalid_filter_value``).
|
|
89
|
+
"""
|
|
90
|
+
allowed_set = set(allowed_fields)
|
|
91
|
+
ops_map: dict[str, frozenset[str]] = {
|
|
92
|
+
k: frozenset(v) for k, v in ops_by_field.items()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
pairs = _as_pairs(query_params)
|
|
96
|
+
|
|
97
|
+
out: list[FilterPredicate] = []
|
|
98
|
+
for raw_key, raw_value in pairs:
|
|
99
|
+
# FastAPI's `Request.query_params` already URL-decodes both halves.
|
|
100
|
+
# If callers pass raw URL-encoded pairs (e.g. from a hand-built test),
|
|
101
|
+
# this is a no-op; if they pass pre-decoded text, also a no-op.
|
|
102
|
+
key = raw_key
|
|
103
|
+
value = raw_value
|
|
104
|
+
if not key.startswith("filter["):
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
# Parse `filter[field]` or `filter[field][op]`. Bracket payload is
|
|
108
|
+
# opaque per the spec (no nested brackets).
|
|
109
|
+
first_close = key.find("]", 7)
|
|
110
|
+
if first_close < 0:
|
|
111
|
+
continue
|
|
112
|
+
field_name = key[7:first_close]
|
|
113
|
+
rest = first_close + 1
|
|
114
|
+
if rest >= len(key):
|
|
115
|
+
op = "eq"
|
|
116
|
+
elif key[rest] == "[":
|
|
117
|
+
second_close = key.find("]", rest + 1)
|
|
118
|
+
if second_close < 0:
|
|
119
|
+
continue
|
|
120
|
+
op = key[rest + 1 : second_close]
|
|
121
|
+
else:
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
if field_name not in allowed_set:
|
|
125
|
+
return FilterParseResult(error_envelope={"error": _ERR_FIELD})
|
|
126
|
+
ops = ops_map.get(field_name)
|
|
127
|
+
if ops is None or op not in ops:
|
|
128
|
+
return FilterParseResult(error_envelope={"error": _ERR_OP})
|
|
129
|
+
coerced = _coerce_value(value, op)
|
|
130
|
+
if coerced is _INVALID_VALUE:
|
|
131
|
+
return FilterParseResult(error_envelope={"error": _ERR_VALUE})
|
|
132
|
+
out.append(FilterPredicate(field=field_name, op=op, value=coerced))
|
|
133
|
+
|
|
134
|
+
return FilterParseResult(predicates=out)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _coerce_value(raw: str, op: str) -> Any:
|
|
138
|
+
"""Normalize a raw URL-decoded string into a typed value for ``op``.
|
|
139
|
+
|
|
140
|
+
The concrete substrate type (number vs string vs date) is left for the
|
|
141
|
+
repository to handle — this parser only normalizes the structural shape:
|
|
142
|
+
a ``list[str]`` for ``in``, a ``bool`` for ``isNull``, and a raw ``str``
|
|
143
|
+
for the scalar comparison ops.
|
|
144
|
+
"""
|
|
145
|
+
if op == "isNull":
|
|
146
|
+
if raw == "true":
|
|
147
|
+
return True
|
|
148
|
+
if raw == "false":
|
|
149
|
+
return False
|
|
150
|
+
return _INVALID_VALUE
|
|
151
|
+
if op == "in":
|
|
152
|
+
return [p.strip() for p in raw.split(",")]
|
|
153
|
+
# For eq/ne/gt/gte/lt/lte/like the parser passes through the raw string
|
|
154
|
+
# value; the repository binds it via parameterized SQL (where the driver
|
|
155
|
+
# handles the coercion to the column type).
|
|
156
|
+
return raw
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _as_pairs(
|
|
160
|
+
query_params: Iterable[tuple[str, str]] | Mapping[str, str],
|
|
161
|
+
) -> list[tuple[str, str]]:
|
|
162
|
+
"""Normalize a Mapping / FastAPI MultiDict / list-of-pairs to pairs."""
|
|
163
|
+
# FastAPI's `QueryParams` exposes `multi_items()` returning a list of
|
|
164
|
+
# `(key, value)` tuples preserving URL order + repeats.
|
|
165
|
+
multi_items = getattr(query_params, "multi_items", None)
|
|
166
|
+
if callable(multi_items):
|
|
167
|
+
return [(str(k), str(v)) for k, v in multi_items()]
|
|
168
|
+
if isinstance(query_params, Mapping):
|
|
169
|
+
return [(str(k), str(v)) for k, v in query_params.items()]
|
|
170
|
+
# Already an iterable of pairs.
|
|
171
|
+
return [(str(k), str(v)) for k, v in query_params]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def parse_filter_qs(
|
|
175
|
+
raw_query: str | None,
|
|
176
|
+
allowed_fields: Iterable[str],
|
|
177
|
+
ops_by_field: Mapping[str, Iterable[str]],
|
|
178
|
+
) -> FilterParseResult:
|
|
179
|
+
"""Convenience wrapper that takes a raw URL query string (without leading
|
|
180
|
+
``?``) and parses it via ``parse_filter``. Useful for tests + non-FastAPI
|
|
181
|
+
consumers."""
|
|
182
|
+
if not raw_query:
|
|
183
|
+
return FilterParseResult(predicates=[])
|
|
184
|
+
pairs: list[tuple[str, str]] = []
|
|
185
|
+
for pair in raw_query.split("&"):
|
|
186
|
+
if not pair:
|
|
187
|
+
continue
|
|
188
|
+
eq = pair.find("=")
|
|
189
|
+
if eq < 0:
|
|
190
|
+
pairs.append((unquote_plus(pair), ""))
|
|
191
|
+
else:
|
|
192
|
+
pairs.append((unquote_plus(pair[:eq]), unquote_plus(pair[eq + 1 :])))
|
|
193
|
+
return parse_filter(pairs, allowed_fields, ops_by_field)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Field-subtype → Python/Pydantic type mapping (sub-project A)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from metaobjects.meta.core.field.meta_field import MetaField
|
|
7
|
+
from metaobjects.meta.core.field import field_constants as fc
|
|
8
|
+
from metaobjects.shared.structural import KEY_IS_ARRAY
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class PyType:
|
|
13
|
+
expr: str # the annotation text, e.g. "str", "list[PostBrief]"
|
|
14
|
+
imports: tuple[str, ...] = () # stdlib import lines this type needs
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_SCALAR: dict[str, PyType] = {
|
|
18
|
+
fc.FIELD_SUBTYPE_STRING: PyType("str"),
|
|
19
|
+
fc.FIELD_SUBTYPE_INT: PyType("int"),
|
|
20
|
+
fc.FIELD_SUBTYPE_LONG: PyType("int"),
|
|
21
|
+
fc.FIELD_SUBTYPE_DOUBLE: PyType("float"),
|
|
22
|
+
fc.FIELD_SUBTYPE_FLOAT: PyType("float"),
|
|
23
|
+
fc.FIELD_SUBTYPE_BOOLEAN: PyType("bool"),
|
|
24
|
+
fc.FIELD_SUBTYPE_DECIMAL: PyType("Decimal", ("from decimal import Decimal",)),
|
|
25
|
+
fc.FIELD_SUBTYPE_CURRENCY: PyType("int"), # integer minor units — wire contract
|
|
26
|
+
fc.FIELD_SUBTYPE_DATE: PyType("datetime.date", ("import datetime",)),
|
|
27
|
+
fc.FIELD_SUBTYPE_TIME: PyType("datetime.time", ("import datetime",)),
|
|
28
|
+
fc.FIELD_SUBTYPE_TIMESTAMP: PyType("datetime.datetime", ("import datetime",)),
|
|
29
|
+
# R6 Plan 2a — field.uuid binds the idiomatic native uuid.UUID (ADR-0001),
|
|
30
|
+
# surfaced at build time. Wire/storage form stays a lowercase-canonical string.
|
|
31
|
+
fc.FIELD_SUBTYPE_UUID: PyType("uuid.UUID", ("import uuid",)),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def field_is_array(field: MetaField) -> bool:
|
|
36
|
+
"""Array-ness from either form: the node property (programmatic build) or the
|
|
37
|
+
`@isArray` attr (how metadata loads from JSON — the conformance-fixture form)."""
|
|
38
|
+
return field.is_array or field.attr(KEY_IS_ARRAY) is True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _py_str_literal(value: str) -> str:
|
|
42
|
+
"""A double-quoted Python string literal (JSON-safe escaping) for embedding an
|
|
43
|
+
enum member into a ``Literal[...]`` annotation — quote-style is stable across
|
|
44
|
+
ruff formatting."""
|
|
45
|
+
import json
|
|
46
|
+
|
|
47
|
+
return json.dumps(str(value), ensure_ascii=False)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def effective_enum_values(field: MetaField) -> list[str]:
|
|
51
|
+
"""The string members of an enum field's ``@values`` — EFFECTIVE (own, else
|
|
52
|
+
inherited via the ``extends`` super chain). A field that extends an abstract
|
|
53
|
+
``field.enum`` resolves the parent's ``@values`` here, mirroring the TS
|
|
54
|
+
``enumValues`` (which reads the effective attr). Empty when absent."""
|
|
55
|
+
v = field.attrs().get(fc.FIELD_ATTR_VALUES)
|
|
56
|
+
if isinstance(v, (list, tuple)):
|
|
57
|
+
return [str(x) for x in v]
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def py_type_for(field: MetaField) -> PyType:
|
|
62
|
+
"""The (non-optional) Python annotation for a field, wrapping arrays in list[...].
|
|
63
|
+
|
|
64
|
+
A ``field.enum`` with effective ``@values`` types as ``Literal[...]`` (value-
|
|
65
|
+
constrained, Pydantic runtime-validated) rather than bare ``str``; an enum array
|
|
66
|
+
becomes ``list[Literal[...]]``. An enum WITHOUT declared values falls back to
|
|
67
|
+
``str``."""
|
|
68
|
+
if field.sub_type == fc.FIELD_SUBTYPE_OBJECT:
|
|
69
|
+
ref = field.attr(fc.FIELD_ATTR_OBJECT_REF)
|
|
70
|
+
base = PyType(str(ref)) if ref else PyType("object")
|
|
71
|
+
elif field.sub_type == fc.FIELD_SUBTYPE_ENUM:
|
|
72
|
+
values = effective_enum_values(field)
|
|
73
|
+
if values:
|
|
74
|
+
# Double-quoted members so the annotation reads identically whether or not
|
|
75
|
+
# the emitted module is ruff-formatted (ruff normalizes to double quotes).
|
|
76
|
+
members = ", ".join(_py_str_literal(v) for v in values)
|
|
77
|
+
base = PyType(f"Literal[{members}]", ("from typing import Literal",))
|
|
78
|
+
else:
|
|
79
|
+
base = PyType("str")
|
|
80
|
+
else:
|
|
81
|
+
base = _SCALAR.get(field.sub_type, PyType("str"))
|
|
82
|
+
if field_is_array(field):
|
|
83
|
+
return PyType(f"list[{base.expr}]", base.imports)
|
|
84
|
+
return base
|