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.
Files changed (181) hide show
  1. metaobjects/__init__.py +75 -0
  2. metaobjects/agent_context/__init__.py +55 -0
  3. metaobjects/agent_context/_content/README.md +14 -0
  4. metaobjects/agent_context/_content/servers/csharp.meta.json +5 -0
  5. metaobjects/agent_context/_content/servers/java.meta.json +5 -0
  6. metaobjects/agent_context/_content/servers/kotlin.meta.json +5 -0
  7. metaobjects/agent_context/_content/servers/python.meta.json +5 -0
  8. metaobjects/agent_context/_content/servers/typescript.meta.json +5 -0
  9. metaobjects/agent_context/_content/skills/metaobjects-authoring/SKILL.md +301 -0
  10. metaobjects/agent_context/_content/skills/metaobjects-codegen/SKILL.md +99 -0
  11. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/csharp.md +87 -0
  12. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/java.md +94 -0
  13. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/kotlin.md +110 -0
  14. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/typescript.md +135 -0
  15. metaobjects/agent_context/_content/skills/metaobjects-prompts/SKILL.md +148 -0
  16. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/csharp.md +110 -0
  17. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/java.md +108 -0
  18. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/kotlin.md +130 -0
  19. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/python.md +116 -0
  20. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/typescript.md +150 -0
  21. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/SKILL.md +130 -0
  22. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/java.md +96 -0
  23. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/kotlin.md +99 -0
  24. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/react.md +86 -0
  25. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/tanstack.md +119 -0
  26. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/typescript.md +92 -0
  27. metaobjects/agent_context/_content/skills/metaobjects-verify/SKILL.md +107 -0
  28. metaobjects/agent_context/_content/skills/metaobjects-verify/references/migration.md +72 -0
  29. metaobjects/agent_context/_content/templates/always-on.md.mustache +27 -0
  30. metaobjects/agent_context/assemble.py +133 -0
  31. metaobjects/agent_context/content_root.py +54 -0
  32. metaobjects/agent_context/scaffold.py +191 -0
  33. metaobjects/agent_context/types.py +44 -0
  34. metaobjects/attr_class_map.py +23 -0
  35. metaobjects/cli.py +696 -0
  36. metaobjects/codegen/__init__.py +0 -0
  37. metaobjects/codegen/config.py +11 -0
  38. metaobjects/codegen/constants.py +13 -0
  39. metaobjects/codegen/extract_delegate_emitter.py +384 -0
  40. metaobjects/codegen/extract_schema_emitter.py +139 -0
  41. metaobjects/codegen/format.py +31 -0
  42. metaobjects/codegen/fr010_field_mapping.py +220 -0
  43. metaobjects/codegen/generator.py +62 -0
  44. metaobjects/codegen/generator_registry.py +163 -0
  45. metaobjects/codegen/generators/__init__.py +0 -0
  46. metaobjects/codegen/generators/entity_model.py +263 -0
  47. metaobjects/codegen/generators/extractor_generator.py +317 -0
  48. metaobjects/codegen/generators/filter_allowlist_generator.py +309 -0
  49. metaobjects/codegen/generators/m2m_codegen.py +192 -0
  50. metaobjects/codegen/generators/output_parser_generator.py +272 -0
  51. metaobjects/codegen/generators/output_prompt_generator.py +192 -0
  52. metaobjects/codegen/generators/payload_vo_generator.py +672 -0
  53. metaobjects/codegen/generators/render_helper_generator.py +451 -0
  54. metaobjects/codegen/generators/router_generator.py +635 -0
  55. metaobjects/codegen/generators/template_generator.py +70 -0
  56. metaobjects/codegen/generators/tph_plan.py +120 -0
  57. metaobjects/codegen/generators/trace_helper_generator.py +336 -0
  58. metaobjects/codegen/instance_artifacts.py +15 -0
  59. metaobjects/codegen/output_format_spec_emitter.py +79 -0
  60. metaobjects/codegen/overwrite_policy.py +27 -0
  61. metaobjects/codegen/runner.py +110 -0
  62. metaobjects/codegen/runtime/__init__.py +6 -0
  63. metaobjects/codegen/runtime/filter_parser.py +193 -0
  64. metaobjects/codegen/type_map.py +84 -0
  65. metaobjects/core_types.py +809 -0
  66. metaobjects/datatype.py +19 -0
  67. metaobjects/documentation/__init__.py +28 -0
  68. metaobjects/documentation/doc_constants.py +20 -0
  69. metaobjects/documentation/doc_provider.py +20 -0
  70. metaobjects/documentation/doc_schema.py +24 -0
  71. metaobjects/errors.py +124 -0
  72. metaobjects/loader/__init__.py +0 -0
  73. metaobjects/loader/merge.py +287 -0
  74. metaobjects/loader/meta_data_loader.py +245 -0
  75. metaobjects/loader/sources/__init__.py +24 -0
  76. metaobjects/loader/sources/directory_source.py +50 -0
  77. metaobjects/loader/sources/file_source.py +41 -0
  78. metaobjects/loader/sources/meta_data_source.py +67 -0
  79. metaobjects/loader/sources/uri_source.py +56 -0
  80. metaobjects/loader/validate_discriminator.py +181 -0
  81. metaobjects/loader/validate_field_readonly.py +146 -0
  82. metaobjects/loader/validate_source_parameter_ref.py +159 -0
  83. metaobjects/loader/validate_source_physical_names.py +140 -0
  84. metaobjects/loader/validation_passes.py +1513 -0
  85. metaobjects/meta/__init__.py +1 -0
  86. metaobjects/meta/core/__init__.py +0 -0
  87. metaobjects/meta/core/attr/__init__.py +0 -0
  88. metaobjects/meta/core/attr/attr_constants.py +31 -0
  89. metaobjects/meta/core/attr/meta_attr.py +136 -0
  90. metaobjects/meta/core/field/__init__.py +0 -0
  91. metaobjects/meta/core/field/field_constants.py +105 -0
  92. metaobjects/meta/core/field/meta_field.py +76 -0
  93. metaobjects/meta/core/identity/__init__.py +0 -0
  94. metaobjects/meta/core/identity/identity_constants.py +19 -0
  95. metaobjects/meta/core/identity/meta_identity.py +8 -0
  96. metaobjects/meta/core/object/__init__.py +0 -0
  97. metaobjects/meta/core/object/meta_object.py +65 -0
  98. metaobjects/meta/core/object/meta_object_aware.py +43 -0
  99. metaobjects/meta/core/object/object_class_registry.py +56 -0
  100. metaobjects/meta/core/object/object_constants.py +13 -0
  101. metaobjects/meta/core/object/object_extract.py +400 -0
  102. metaobjects/meta/core/object/value_object.py +70 -0
  103. metaobjects/meta/core/relationship/__init__.py +0 -0
  104. metaobjects/meta/core/relationship/derive_m2m_fields.py +180 -0
  105. metaobjects/meta/core/relationship/meta_relationship.py +54 -0
  106. metaobjects/meta/core/relationship/relationship_constants.py +51 -0
  107. metaobjects/meta/core/validator/__init__.py +0 -0
  108. metaobjects/meta/core/validator/validator_constants.py +18 -0
  109. metaobjects/meta/meta_data.py +206 -0
  110. metaobjects/meta/meta_root.py +8 -0
  111. metaobjects/meta/persistence/__init__.py +0 -0
  112. metaobjects/meta/persistence/db/__init__.py +1 -0
  113. metaobjects/meta/persistence/db/db_constants.py +41 -0
  114. metaobjects/meta/persistence/db/db_provider.py +60 -0
  115. metaobjects/meta/persistence/origin/__init__.py +0 -0
  116. metaobjects/meta/persistence/origin/meta_origin.py +8 -0
  117. metaobjects/meta/persistence/origin/origin_constants.py +20 -0
  118. metaobjects/meta/persistence/source/__init__.py +0 -0
  119. metaobjects/meta/persistence/source/meta_source.py +137 -0
  120. metaobjects/meta/persistence/source/source_constants.py +115 -0
  121. metaobjects/meta/presentation/__init__.py +0 -0
  122. metaobjects/meta/presentation/layout/__init__.py +0 -0
  123. metaobjects/meta/presentation/layout/layout_constants.py +13 -0
  124. metaobjects/meta/presentation/layout/meta_layout.py +8 -0
  125. metaobjects/meta/presentation/view/__init__.py +0 -0
  126. metaobjects/meta/presentation/view/meta_view.py +8 -0
  127. metaobjects/meta/presentation/view/view_constants.py +22 -0
  128. metaobjects/meta/template/__init__.py +0 -0
  129. metaobjects/meta/template/meta_template.py +46 -0
  130. metaobjects/meta/template/template_constants.py +112 -0
  131. metaobjects/meta/template/template_provider.py +43 -0
  132. metaobjects/parser.py +380 -0
  133. metaobjects/parser_yaml.py +82 -0
  134. metaobjects/provider.py +111 -0
  135. metaobjects/py.typed +0 -0
  136. metaobjects/registry.py +210 -0
  137. metaobjects/registry_manifest.py +223 -0
  138. metaobjects/render/__init__.py +74 -0
  139. metaobjects/render/email_document.py +14 -0
  140. metaobjects/render/escapers.py +109 -0
  141. metaobjects/render/extract/__init__.py +59 -0
  142. metaobjects/render/extract/coerce.py +279 -0
  143. metaobjects/render/extract/extract.py +211 -0
  144. metaobjects/render/extract/extract_map.py +61 -0
  145. metaobjects/render/extract/json_forgiving_reader.py +203 -0
  146. metaobjects/render/extract/locate.py +65 -0
  147. metaobjects/render/extract/normalize.py +96 -0
  148. metaobjects/render/extract/strip.py +20 -0
  149. metaobjects/render/extract/types.py +332 -0
  150. metaobjects/render/extract/xml_forgiving_reader.py +162 -0
  151. metaobjects/render/filesystem_provider.py +51 -0
  152. metaobjects/render/prompt/__init__.py +32 -0
  153. metaobjects/render/prompt/output_format_renderer.py +340 -0
  154. metaobjects/render/prompt/output_format_spec.py +28 -0
  155. metaobjects/render/prompt/prompt_field.py +29 -0
  156. metaobjects/render/prompt/prompt_overrides.py +29 -0
  157. metaobjects/render/prompt/prompt_style.py +38 -0
  158. metaobjects/render/renderer.py +358 -0
  159. metaobjects/render/verify.py +266 -0
  160. metaobjects/runtime/__init__.py +39 -0
  161. metaobjects/runtime/llm_recorder.py +210 -0
  162. metaobjects/runtime/n2m_resolver.py +155 -0
  163. metaobjects/runtime/object_manager.py +715 -0
  164. metaobjects/runtime/tph.py +50 -0
  165. metaobjects/serializer_json.py +172 -0
  166. metaobjects/shared/__init__.py +0 -0
  167. metaobjects/shared/base_types.py +16 -0
  168. metaobjects/shared/separators.py +4 -0
  169. metaobjects/shared/structural.py +9 -0
  170. metaobjects/source/__init__.py +79 -0
  171. metaobjects/source/error_source.py +266 -0
  172. metaobjects/source/json_path.py +106 -0
  173. metaobjects/source/semantic_diff.py +98 -0
  174. metaobjects/source/yaml_positions.py +174 -0
  175. metaobjects/super_resolve.py +128 -0
  176. metaobjects/yaml_desugar.py +481 -0
  177. metaobjects-0.9.0.dist-info/METADATA +97 -0
  178. metaobjects-0.9.0.dist-info/RECORD +181 -0
  179. metaobjects-0.9.0.dist-info/WHEEL +4 -0
  180. metaobjects-0.9.0.dist-info/entry_points.txt +2 -0
  181. 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