bloomery 0.1.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 (108) hide show
  1. bloomery/__init__.py +164 -0
  2. bloomery/_version.py +24 -0
  3. bloomery/cli/__init__.py +482 -0
  4. bloomery/cli/io.py +167 -0
  5. bloomery/cli/render.py +167 -0
  6. bloomery/cli/serialize.py +111 -0
  7. bloomery/compile.py +64 -0
  8. bloomery/dialects/__init__.py +81 -0
  9. bloomery/dialects/base.py +160 -0
  10. bloomery/dialects/duckdb.py +70 -0
  11. bloomery/dialects/postgres.py +211 -0
  12. bloomery/dialects/trino.py +80 -0
  13. bloomery/emit/__init__.py +68 -0
  14. bloomery/emit/base.py +125 -0
  15. bloomery/emit/cube/__init__.py +281 -0
  16. bloomery/emit/dbt/__init__.py +731 -0
  17. bloomery/emit/lower/__init__.py +97 -0
  18. bloomery/emit/lower/marts.py +170 -0
  19. bloomery/emit/lower/predicates.py +78 -0
  20. bloomery/emit/lower/quality_mart.py +373 -0
  21. bloomery/emit/lower/reconcile.py +435 -0
  22. bloomery/emit/lower/silver.py +1416 -0
  23. bloomery/emit/metricflow/__init__.py +576 -0
  24. bloomery/emit/sqlmesh/__init__.py +798 -0
  25. bloomery/emit/steps.py +753 -0
  26. bloomery/errors.py +673 -0
  27. bloomery/evidence.py +336 -0
  28. bloomery/guardrails/__init__.py +12 -0
  29. bloomery/guardrails/additivity.py +113 -0
  30. bloomery/guardrails/arithmetic.py +195 -0
  31. bloomery/guardrails/asserts.py +177 -0
  32. bloomery/guardrails/conflict.py +75 -0
  33. bloomery/guardrails/grain.py +173 -0
  34. bloomery/guardrails/operands.py +115 -0
  35. bloomery/guardrails/quality.py +1073 -0
  36. bloomery/guardrails/stage.py +91 -0
  37. bloomery/ir/__init__.py +111 -0
  38. bloomery/ir/fingerprint.py +80 -0
  39. bloomery/ir/lower.py +88 -0
  40. bloomery/ir/nodes.py +820 -0
  41. bloomery/marts/__init__.py +18 -0
  42. bloomery/marts/flatten.py +634 -0
  43. bloomery/naming.py +55 -0
  44. bloomery/plan/__init__.py +19 -0
  45. bloomery/plan/diff.py +1484 -0
  46. bloomery/plan/model.py +131 -0
  47. bloomery/planner/__init__.py +70 -0
  48. bloomery/planner/coverage.py +259 -0
  49. bloomery/planner/explain.py +143 -0
  50. bloomery/planner/filters.py +302 -0
  51. bloomery/planner/metricflow_planner.py +200 -0
  52. bloomery/planner/names.py +218 -0
  53. bloomery/planner/parse.py +566 -0
  54. bloomery/planner/policy.py +55 -0
  55. bloomery/planner/request.py +325 -0
  56. bloomery/planner/result.py +114 -0
  57. bloomery/py.typed +0 -0
  58. bloomery/quality/__init__.py +219 -0
  59. bloomery/quality/catalogue.py +122 -0
  60. bloomery/quality/charset.py +107 -0
  61. bloomery/quality/dedupe.py +87 -0
  62. bloomery/quality/flags.py +163 -0
  63. bloomery/quality/lower.py +569 -0
  64. bloomery/quality/mart.py +343 -0
  65. bloomery/quality/pattern.py +107 -0
  66. bloomery/quality/predicates.py +648 -0
  67. bloomery/quality/reconcile.py +117 -0
  68. bloomery/quality/reject.py +105 -0
  69. bloomery/resolve/__init__.py +24 -0
  70. bloomery/resolve/build.py +1005 -0
  71. bloomery/resolve/graph.py +263 -0
  72. bloomery/resolve/metrics.py +94 -0
  73. bloomery/resolve/order.py +77 -0
  74. bloomery/resolve/reach.py +98 -0
  75. bloomery/resolve/recipes.py +117 -0
  76. bloomery/resolve/refs.py +265 -0
  77. bloomery/resolve/resolution.py +116 -0
  78. bloomery/resolve/steps.py +785 -0
  79. bloomery/runtime/__init__.py +26 -0
  80. bloomery/runtime/hydration.py +178 -0
  81. bloomery/runtime/sql_client.py +97 -0
  82. bloomery/schema.py +287 -0
  83. bloomery/spec/__init__.py +107 -0
  84. bloomery/spec/catalog.py +119 -0
  85. bloomery/spec/common.py +289 -0
  86. bloomery/spec/entity.py +128 -0
  87. bloomery/spec/mapping.py +204 -0
  88. bloomery/spec/marts.py +158 -0
  89. bloomery/spec/metrics.py +81 -0
  90. bloomery/spec/project.py +182 -0
  91. bloomery/spec/quality.py +920 -0
  92. bloomery/spec/steps.py +187 -0
  93. bloomery/steps/__init__.py +109 -0
  94. bloomery/steps/contract.py +212 -0
  95. bloomery/steps/manifest.py +399 -0
  96. bloomery/steps/registry.py +174 -0
  97. bloomery/steps/splice.py +160 -0
  98. bloomery/transforms/__init__.py +37 -0
  99. bloomery/transforms/_builtins.py +405 -0
  100. bloomery/transforms/registry.py +177 -0
  101. bloomery/typing/__init__.py +37 -0
  102. bloomery/typing/check.py +182 -0
  103. bloomery/typing/types.py +184 -0
  104. bloomery-0.1.0.dist-info/METADATA +224 -0
  105. bloomery-0.1.0.dist-info/RECORD +108 -0
  106. bloomery-0.1.0.dist-info/WHEEL +4 -0
  107. bloomery-0.1.0.dist-info/entry_points.txt +2 -0
  108. bloomery-0.1.0.dist-info/licenses/LICENSE +21 -0
bloomery/__init__.py ADDED
@@ -0,0 +1,164 @@
1
+ """Entity-first spec compiler: declarative entity/mapping/metric specs compiled
2
+ deterministically into SQLMesh, dbt, and Cube artifacts.
3
+
4
+ Public API (spec §8): the pure loaders (``load_catalog``, ``load_project``),
5
+ compilation (``compile_project`` with ``Target``), analysis without emission
6
+ (``resolve`` / ``Resolution``, ``build_project_ir``, ``project_fingerprint``),
7
+ spec-diff planning (``plan`` with ``Plan`` / ``Change`` / ``ChangeClass`` /
8
+ ``BackfillScope`` / ``ReplayScope`` — RFC 0007, RFC 0016), the request-time planner (``MetricFlowPlanner`` with the ``MetricRequest`` /
9
+ ``QueryPlan`` port types and ``RowPolicy`` — RFC 0011/0013), manifest
10
+ hydration (``LruManifestHydrator``, ``HydrationKey`` — RFC 0014), the
11
+ JSON Schema export (``spec_json_schema`` / ``all_spec_schemas`` with
12
+ ``SpecKind`` and ``JsonDict`` — RFC 0020), spec assessment as one value
13
+ (``evaluate`` with ``SpecEvidence`` / ``MartSummary`` / ``Stage`` — RFC 0022;
14
+ ``Materialization`` comes with it, reached through ``MartSummary``), the
15
+ extension points (``register_transform``, ``register_emitter``), and the
16
+ total error hierarchy rooted at ``BloomeryError`` (import leaves from
17
+ :mod:`bloomery.errors`) — whose structured fix suggestions carry the two
18
+ payload types ``MartCoverage`` and ``MeasureRef`` (RFC 0020 D11), exported
19
+ here because D2's allowlist exempts the *errors*, not the values they hang.
20
+
21
+ The command line (``bloomery compile|plan|resolve|explain|schema|fingerprint``)
22
+ is a separate, one-directional shell over exactly this surface: it may import
23
+ the library, no library module may import it, and it is the only place in the
24
+ package that touches a filesystem (RFC 0020 D5).
25
+
26
+ **Signature closure** (RFC 0018 D1) is the rule that decides this list: a type
27
+ appearing in a public signature — parameter, return, generic argument, or field
28
+ of a returned dataclass — is exported here too, so a caller can always name what
29
+ the API hands them. ``tests/unit/test_signature_closure.py`` enforces it; the
30
+ walk stops at ``Catalog``, ``Project`` and ``ProjectIR``, which are handles
31
+ passed back rather than read (D9), and exempts ``bloomery.errors``, whose leaves
32
+ stay behind their own declared ``__all__`` (D2).
33
+ """
34
+
35
+ from bloomery.compile import Target, compile_project
36
+ from bloomery.emit import ArtifactKind, EmittedArtifact, TargetEmitter, register_emitter
37
+ from bloomery.errors import BloomeryError, MartCoverage, MeasureRef
38
+ from bloomery.evidence import MartSummary, SpecEvidence, evaluate
39
+ from bloomery.ir import Materialization, ProjectIR, UnreachableMetric, project_fingerprint
40
+ from bloomery.naming import DefaultNaming, NamingPolicy
41
+ from bloomery.plan import BackfillScope, Change, ChangeClass, Plan, ReplayScope, plan
42
+ from bloomery.planner import (
43
+ AnyOf,
44
+ Clause,
45
+ ColumnDescriptor,
46
+ ColumnRole,
47
+ Explanation,
48
+ MeasureExplanation,
49
+ MetricFlowPlanner,
50
+ MetricRequest,
51
+ Op,
52
+ OrderDirection,
53
+ OrderSpec,
54
+ Predicate,
55
+ QueryPlan,
56
+ RowPolicy,
57
+ Scalar,
58
+ TimeGrain,
59
+ )
60
+ from bloomery.resolve import (
61
+ FieldProvenance,
62
+ Node,
63
+ NodeKind,
64
+ Provenance,
65
+ Resolution,
66
+ Stage,
67
+ build_project_ir,
68
+ resolve,
69
+ )
70
+ from bloomery.runtime import HydrationKey, LruManifestHydrator
71
+ from bloomery.schema import JsonDict, SpecKind, all_spec_schemas, spec_json_schema
72
+ from bloomery.spec import Catalog, Project, load_catalog, load_project
73
+ from bloomery.steps import EMPTY_REGISTRY, StepManifest, StepRegistry
74
+ from bloomery.transforms import Builder, OutputType, TransformSpec, register_transform
75
+ from bloomery.typing import ArgKind, LogicalType
76
+
77
+ try:
78
+ #: The installed release, written at build time by ``hatch-vcs`` from the
79
+ #: git tag. A *generated module* rather than
80
+ #: ``importlib.metadata.version("bloomery")``, deliberately: the metadata
81
+ #: lookup reads the installed distribution off a disk, and this package
82
+ #: promises to touch none (RFC 0003). Importing a static module is the
83
+ #: ordinary import every other line here already does.
84
+ # Re-exported under its own name deliberately: `__version__` is not in
85
+ # `__all__` (that list governs `import *`, and a dunder does not belong in
86
+ # it), so the redundant alias is what tells a strict type checker this is a
87
+ # public re-export rather than an implementation detail leaking through.
88
+ from bloomery._version import __version__ as __version__
89
+ except ImportError: # pragma: no cover - only in a source tree that was never built
90
+ # A checkout with no build behind it: `_version.py` is generated and
91
+ # gitignored. Naming the state is better than either raising (which would
92
+ # make `import bloomery` fail on a bare clone) or inventing a number a bug
93
+ # report would then quote as real.
94
+ __version__ = "0.0.0+unknown"
95
+
96
+ __all__ = [
97
+ "AnyOf",
98
+ "ArgKind",
99
+ "ArtifactKind",
100
+ "BackfillScope",
101
+ "BloomeryError",
102
+ "Builder",
103
+ "Catalog",
104
+ "Change",
105
+ "ChangeClass",
106
+ "Clause",
107
+ "ColumnDescriptor",
108
+ "ColumnRole",
109
+ "DefaultNaming",
110
+ "EMPTY_REGISTRY",
111
+ "EmittedArtifact",
112
+ "Explanation",
113
+ "FieldProvenance",
114
+ "HydrationKey",
115
+ "JsonDict",
116
+ "LogicalType",
117
+ "LruManifestHydrator",
118
+ "MartCoverage",
119
+ "MartSummary",
120
+ "Materialization",
121
+ "MeasureExplanation",
122
+ "MeasureRef",
123
+ "MetricFlowPlanner",
124
+ "MetricRequest",
125
+ "NamingPolicy",
126
+ "Node",
127
+ "NodeKind",
128
+ "Op",
129
+ "OrderDirection",
130
+ "OrderSpec",
131
+ "OutputType",
132
+ "Plan",
133
+ "Predicate",
134
+ "Project",
135
+ "ProjectIR",
136
+ "Provenance",
137
+ "QueryPlan",
138
+ "ReplayScope",
139
+ "Resolution",
140
+ "RowPolicy",
141
+ "Scalar",
142
+ "SpecEvidence",
143
+ "SpecKind",
144
+ "Stage",
145
+ "StepManifest",
146
+ "StepRegistry",
147
+ "Target",
148
+ "TargetEmitter",
149
+ "TimeGrain",
150
+ "TransformSpec",
151
+ "UnreachableMetric",
152
+ "all_spec_schemas",
153
+ "build_project_ir",
154
+ "compile_project",
155
+ "evaluate",
156
+ "load_catalog",
157
+ "load_project",
158
+ "plan",
159
+ "project_fingerprint",
160
+ "register_emitter",
161
+ "register_transform",
162
+ "resolve",
163
+ "spec_json_schema",
164
+ ]
bloomery/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,482 @@
1
+ """The command line: six commands, each a shell over one public function
2
+ (RFC 0020 §5.2, D4–D6).
3
+
4
+ ``bloomery compile|plan|resolve|explain|schema|fingerprint``. Every command is
5
+ *read files → call the public API → write stdout or a directory*. None of them
6
+ adds logic: what they add is that the most useful thing bloomery knows — which
7
+ metrics are computable, and which specific leaf is missing for the ones that
8
+ are not — stops requiring a Python script to ask.
9
+
10
+ **Exit codes**, because a script has to be able to tell these apart:
11
+
12
+ * ``0`` — success.
13
+ * ``1`` — a refusal. A :class:`~bloomery.BloomeryError` is a *correct* outcome:
14
+ bloomery looked at the spec and said no, with a reason. Conflating it with a
15
+ crash is what makes a pipeline retry a spec error.
16
+ * ``2`` — a usage error: a path that is not there, a flag that is not a flag.
17
+
18
+ ``--format json`` on ``plan``, ``resolve`` and ``explain`` emits the same
19
+ values the Python API returns, so the CLI is not a second, lossier surface
20
+ (:mod:`bloomery.cli.serialize`).
21
+
22
+ **What is deliberately absent.** No ``run`` and no engine connection, ever
23
+ (D9) — ``explain`` prints SQL and the consumer executes it, which is what keeps
24
+ the test suite infrastructure-free and the library a compiler. No config file,
25
+ profile, or credentials. No scaffolding, watch mode or daemon: each implies
26
+ state or an opinion about project layout that bloomery does not hold.
27
+
28
+ Argument parsing is :mod:`argparse` and table rendering is hand-rolled — no new
29
+ runtime dependency (D6). Only :mod:`bloomery.cli.io` touches a filesystem
30
+ (D5/D12), and the import contract makes ``bloomery.cli`` the top layer, so no
31
+ library module can import any of this.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import json
38
+ import sys
39
+ from typing import TYPE_CHECKING, cast
40
+
41
+ from bloomery import (
42
+ BloomeryError,
43
+ LruManifestHydrator,
44
+ MetricFlowPlanner,
45
+ MetricRequest,
46
+ Op,
47
+ RowPolicy,
48
+ SpecKind,
49
+ Stage,
50
+ Target,
51
+ TimeGrain,
52
+ __version__,
53
+ all_spec_schemas,
54
+ build_project_ir,
55
+ compile_project,
56
+ evaluate,
57
+ load_catalog,
58
+ load_project,
59
+ plan,
60
+ project_fingerprint,
61
+ spec_json_schema,
62
+ )
63
+ from bloomery.cli import io, render, serialize
64
+ from bloomery.dialects import get_dialect
65
+ from bloomery.emit import get_emitter
66
+ from bloomery.errors import EmitError
67
+ from bloomery.naming import DefaultNaming
68
+ from bloomery.planner import parse_filter_json
69
+
70
+ if TYPE_CHECKING:
71
+ from collections.abc import Mapping as AbcMapping
72
+ from collections.abc import Sequence
73
+
74
+ from bloomery import Catalog, Project, ProjectIR
75
+
76
+ __all__ = ["main"]
77
+
78
+ #: Success, refusal, usage error (§5.2). Named rather than spelled inline so
79
+ #: the docs page and the tests can cite the same three constants.
80
+ EXIT_OK = 0
81
+ EXIT_REFUSED = 1
82
+ EXIT_USAGE = 2
83
+
84
+
85
+ class _Usage(Exception):
86
+ """A usage error raised after parsing — a value argparse accepted but that
87
+ cannot be used (an unknown dialect spelling, a malformed ``--policy``).
88
+
89
+ Separate from ``parser.error`` so it can be raised from a command body,
90
+ where the parser is no longer in scope, and still exit ``2``.
91
+ """
92
+
93
+
94
+ # ....................... #
95
+ # Loading — the same three lines every command starts with.
96
+
97
+
98
+ def _load(directory: str, catalog_path: str | None) -> tuple[Project, Catalog | None]:
99
+ """The two values every command starts from.
100
+
101
+ Takes the two strings rather than the parsed namespace: ``plan`` loads two
102
+ directories against one catalog and would otherwise have to fake a
103
+ namespace, and a command whose parser forgot ``--catalog`` fails at the
104
+ call site instead of silently loading without one.
105
+ """
106
+ sources, catalog_text = io.read_spec_directory(directory, catalog=catalog_path)
107
+ catalog = load_catalog(catalog_text) if catalog_text is not None else None
108
+ return load_project(sources), catalog
109
+
110
+
111
+ def _load_ir(directory: str, catalog_path: str | None) -> ProjectIR:
112
+ project, catalog = _load(directory, catalog_path)
113
+ return build_project_ir(project, catalog=catalog)
114
+
115
+
116
+ def _emit(payload: object, *, as_json: bool) -> None:
117
+ """One place that writes to stdout, so every command agrees on the shape.
118
+
119
+ JSON is indented and sorted: a CLI's JSON is read by people at least as
120
+ often as by programs, and a sorted document diffs.
121
+ """
122
+ text = json.dumps(payload, indent=2, sort_keys=True) if as_json else str(payload)
123
+ sys.stdout.write(text + "\n")
124
+
125
+
126
+ # ....................... #
127
+ # Commands
128
+
129
+
130
+ def _check_names(*, target: str | None = None, dialect: str | None = None) -> None:
131
+ """Refuse a mistyped ``--target``/``--dialect`` as a usage error.
132
+
133
+ Both resolve through the library, which raises :class:`EmitError` — so
134
+ without this a typo came back as ``1``, the code that means "bloomery read
135
+ your spec and said no", for a spec it never opened. A mistyped flag *value*
136
+ is the invocation being wrong, which is what ``2`` is for, and it is the
137
+ treatment ``--grain`` and ``--policy`` already get here.
138
+
139
+ The registries are asked rather than a list written down: the target set is
140
+ open (:func:`~bloomery.register_emitter`), so the library is the only thing
141
+ that knows it, and its message already names what it does have.
142
+ """
143
+ try:
144
+ if target is not None:
145
+ get_emitter(target)
146
+ if dialect is not None:
147
+ get_dialect(dialect)
148
+ except EmitError as exc:
149
+ raise _Usage(str(exc)) from exc
150
+
151
+
152
+ def _compile(arguments: argparse.Namespace) -> int:
153
+ _check_names(target=arguments.target, dialect=arguments.dialect)
154
+ project, catalog = _load(arguments.directory, arguments.catalog)
155
+ artifacts = compile_project(
156
+ project, target=arguments.target, dialect=arguments.dialect, catalog=catalog
157
+ )
158
+ if arguments.out is not None:
159
+ written = io.write_files(
160
+ arguments.out, {artifact.path: artifact.content for artifact in artifacts}
161
+ )
162
+ for path in written:
163
+ sys.stdout.write(f"{path}\n")
164
+ return EXIT_OK
165
+ _emit(serialize.artifacts_as_json(artifacts), as_json=True)
166
+ return EXIT_OK
167
+
168
+
169
+ def _plan(arguments: argparse.Namespace) -> int:
170
+ old = _load_ir(arguments.old, arguments.catalog)
171
+ new = _load_ir(arguments.new, arguments.catalog)
172
+ result = plan(old, new)
173
+ if arguments.format == "json":
174
+ _emit(serialize.as_json_value(result), as_json=True)
175
+ else:
176
+ _emit(render.render_plan(result), as_json=False)
177
+ return EXIT_OK
178
+
179
+
180
+ def _resolve(arguments: argparse.Namespace) -> int:
181
+ """``bloomery resolve`` — reachability *and* refusals (RFC 0022 D8).
182
+
183
+ Re-pointed from :func:`~bloomery.resolve` to :func:`~bloomery.evaluate`,
184
+ which is a strict gain for the one caller who matters here: a spec author
185
+ mid-draft. Before, a spec that refused anywhere printed nothing and exited
186
+ ``1`` with a message; now it prints how far analysis got, what was
187
+ reachable at that point, and every refusal with its source path — and still
188
+ exits ``1``, because the spec is still refused and a pipeline branching on
189
+ the code must not start treating it as fine.
190
+
191
+ **One project shape changes answer**: a spec wiring a ``steps:`` document
192
+ now reports the unwired step instead of printing reachability. No registry
193
+ is passed — the CLI offers no ``--steps``, because a ``StepRegistry`` is a
194
+ caller-assembled compile input (RFC 0017 §5.3) — and ``resolve()`` never
195
+ looked at steps at all, so it answered as though the wiring were not there.
196
+ ``compile`` on the same project already refuses for the same reason; this
197
+ makes the two agree rather than having the cheaper command quietly answer a
198
+ question about a project the expensive one will not build.
199
+ """
200
+ project, catalog = _load(arguments.directory, arguments.catalog)
201
+ evidence = evaluate(project, catalog=catalog)
202
+ if arguments.format == "json":
203
+ _emit(serialize.as_json_value(evidence), as_json=True)
204
+ else:
205
+ _emit(render.render_evidence(evidence), as_json=False)
206
+ return EXIT_OK if evidence.stage_reached is Stage.COMPLETE else EXIT_REFUSED
207
+
208
+
209
+ def _parse_policy(spelling: str | None) -> RowPolicy | None:
210
+ """``--policy 'region eq EU'`` → a :class:`~bloomery.RowPolicy`.
211
+
212
+ Three whitespace-separated tokens, the last comma-split for the multi-value
213
+ operators. RFC 0020 §10 question 2, answered yes: making row scoping
214
+ inspectable without Python is most of its debugging value, and
215
+ :class:`~bloomery.RowPolicy` is a plain dimension/operator/value triple, so
216
+ exposing it puts nothing on the surface that the public type does not
217
+ already carry.
218
+
219
+ The spelling stays neutral for the reason the type is: deciding *whose*
220
+ policy applies is upstream work bloomery must not know about. This flag
221
+ takes a filter, not an identity, and nothing about a caller reaches it.
222
+ """
223
+ if spelling is None:
224
+ return None
225
+ parts = spelling.split(maxsplit=2)
226
+ if len(parts) != 3:
227
+ msg = f"--policy takes 'dimension op value', got {spelling!r}"
228
+ raise _Usage(msg)
229
+ dimension, operator, raw = parts
230
+ try:
231
+ op = Op(operator)
232
+ except ValueError as exc:
233
+ known = ", ".join(sorted(member.value for member in Op))
234
+ msg = f"--policy operator {operator!r} is not one of: {known}"
235
+ raise _Usage(msg) from exc
236
+ value = tuple(raw.split(",")) if op in (Op.IN, Op.NOT_IN) else raw
237
+ return RowPolicy(dimension=dimension, op=op, value=value)
238
+
239
+
240
+ def _parse_where(payload: str | None) -> AbcMapping[str, object] | None:
241
+ """``--where`` as a filter document, or a usage error naming the problem.
242
+
243
+ ``json.loads`` raises ``JSONDecodeError``, which nothing above catches — so
244
+ without this a mistyped quote exits on a traceback rather than on the ``2``
245
+ a script branches on. A well-formed JSON value that is not an object is the
246
+ same kind of mistake and gets the same code.
247
+
248
+ The refusals *inside* the document are a different thing and stay
249
+ refusals: ``$regex`` is a reviewed decision (RFC 0015), not a typo.
250
+ """
251
+ if payload is None:
252
+ return None
253
+ try:
254
+ document: object = json.loads(payload)
255
+ except json.JSONDecodeError as exc:
256
+ msg = f"--where is not valid JSON: {exc}"
257
+ raise _Usage(msg) from exc
258
+ if not isinstance(document, dict):
259
+ msg = f"--where takes a JSON object, got {type(document).__name__}"
260
+ raise _Usage(msg)
261
+ return cast("AbcMapping[str, object]", document)
262
+
263
+
264
+ def _parse_grain(spelling: str | None) -> TimeGrain | None:
265
+ """``--grain`` as a :class:`~bloomery.TimeGrain`, or a usage error listing
266
+ the six. Not ``choices=`` on the parser, so the message can name the flag
267
+ the same way the other value checks here do."""
268
+ if spelling is None:
269
+ return None
270
+ try:
271
+ return TimeGrain(spelling)
272
+ except ValueError as exc:
273
+ known = ", ".join(member.value for member in TimeGrain)
274
+ msg = f"--grain {spelling!r} is not one of: {known}"
275
+ raise _Usage(msg) from exc
276
+
277
+
278
+ def _explain(arguments: argparse.Namespace) -> int:
279
+ # Every flag is checked before the project is loaded. Both orders are
280
+ # correct and one is kinder: a mistyped `--grain` is free to detect, while
281
+ # loading is the slow part, and when the spec is *also* broken the reader
282
+ # needs the invocation fixed first — a refusal about a spec they cannot
283
+ # reach yet is not the next thing they should do.
284
+ _check_names(dialect=arguments.dialect)
285
+ where = _parse_where(arguments.where)
286
+ policy = _parse_policy(arguments.policy)
287
+ request = MetricRequest(
288
+ metrics=tuple(arguments.metrics.split(",")),
289
+ dimensions=tuple(arguments.by.split(",")) if arguments.by else (),
290
+ filters=parse_filter_json(where) if where is not None else (),
291
+ time_grain=_parse_grain(arguments.grain),
292
+ limit=arguments.limit,
293
+ )
294
+ ir = _load_ir(arguments.directory, arguments.catalog)
295
+ naming = DefaultNaming()
296
+ planner = MetricFlowPlanner(LruManifestHydrator(naming), naming=naming)
297
+ query = planner.plan(ir, request, dialect=arguments.dialect, policy=policy)
298
+ if arguments.format == "json":
299
+ _emit(serialize.as_json_value(query), as_json=True)
300
+ else:
301
+ _emit(query.sql + "\n\n" + query.explanation.render(), as_json=False)
302
+ return EXIT_OK
303
+
304
+
305
+ def _schema(arguments: argparse.Namespace) -> int:
306
+ # `--kind` is the single-document case and is kept a separate branch rather
307
+ # than a one-entry mapping fished back out: stdout gets the schema itself,
308
+ # so it can be piped straight into a validator, while the no-flag form gets
309
+ # the six keyed by kind.
310
+ if arguments.kind:
311
+ kind = SpecKind(arguments.kind)
312
+ schemas = {kind: spec_json_schema(kind)}
313
+ payload: object = schemas[kind]
314
+ else:
315
+ schemas = dict(all_spec_schemas())
316
+ payload = {each.value: schema for each, schema in schemas.items()}
317
+ if arguments.out is not None:
318
+ written = io.write_files(
319
+ arguments.out,
320
+ {
321
+ f"{each.value}.json": json.dumps(schema, indent=2) + "\n"
322
+ for each, schema in schemas.items()
323
+ },
324
+ )
325
+ for path in written:
326
+ sys.stdout.write(f"{path}\n")
327
+ return EXIT_OK
328
+ _emit(payload, as_json=True)
329
+ return EXIT_OK
330
+
331
+
332
+ def _fingerprint(arguments: argparse.Namespace) -> int:
333
+ _emit(project_fingerprint(_load_ir(arguments.directory, arguments.catalog)), as_json=False)
334
+ return EXIT_OK
335
+
336
+
337
+ # ....................... #
338
+ # Parser
339
+
340
+
341
+ def _add_spec_directory(parser: argparse.ArgumentParser) -> None:
342
+ parser.add_argument("directory", help="directory of spec documents")
343
+ parser.add_argument(
344
+ "--catalog",
345
+ help="catalog document (default: catalog.yaml in the directory, if present)",
346
+ )
347
+
348
+
349
+ def _add_format(parser: argparse.ArgumentParser) -> None:
350
+ parser.add_argument(
351
+ "--format",
352
+ choices=("table", "json"),
353
+ default="table",
354
+ help="output format (json emits the same values the Python API returns)",
355
+ )
356
+
357
+
358
+ def build_parser() -> argparse.ArgumentParser:
359
+ """The whole command line, as data.
360
+
361
+ Exposed rather than private so the docs page and the tests read the same
362
+ definition — a usage string transcribed by hand is a usage string that goes
363
+ stale.
364
+ """
365
+ parser = argparse.ArgumentParser(
366
+ prog="bloomery",
367
+ description="Compile, plan and inspect bloomery specs. Reads files; executes nothing.",
368
+ )
369
+ # Not a command — a flag, so it needs no subcommand to reach. The bug
370
+ # report template asks for a bloomery version, and until this existed the
371
+ # only way to answer was to know the package name well enough to query the
372
+ # installed metadata by hand.
373
+ parser.add_argument(
374
+ "--version",
375
+ action="version",
376
+ version=f"bloomery {__version__}",
377
+ help="print the installed bloomery version and exit",
378
+ )
379
+ commands = parser.add_subparsers(dest="command", required=True)
380
+
381
+ compile_parser = commands.add_parser("compile", help="compile specs to target artifacts")
382
+ _add_spec_directory(compile_parser)
383
+ compile_parser.add_argument(
384
+ "--target",
385
+ default=Target.SQLMESH.value,
386
+ help="emit target (sqlmesh, cube, dbt, or a registered name)",
387
+ )
388
+ compile_parser.add_argument("--dialect", default="duckdb", help="SQL dialect")
389
+ compile_parser.add_argument("--out", help="write artifacts here (default: JSON on stdout)")
390
+ compile_parser.set_defaults(run=_compile)
391
+
392
+ plan_parser = commands.add_parser(
393
+ "plan", help="diff two spec directories into a migration plan"
394
+ )
395
+ plan_parser.add_argument("old", help="the deployed spec directory")
396
+ plan_parser.add_argument("new", help="the proposed spec directory")
397
+ plan_parser.add_argument("--catalog", help="catalog document used for both sides")
398
+ _add_format(plan_parser)
399
+ plan_parser.set_defaults(run=_plan)
400
+
401
+ resolve_parser = commands.add_parser(
402
+ "resolve", help="which metrics are computable, and what is missing for the rest"
403
+ )
404
+ _add_spec_directory(resolve_parser)
405
+ _add_format(resolve_parser)
406
+ resolve_parser.set_defaults(run=_resolve)
407
+
408
+ explain_parser = commands.add_parser("explain", help="plan one metric request; print SQL")
409
+ _add_spec_directory(explain_parser)
410
+ explain_parser.add_argument("--metrics", required=True, help="comma-separated metric names")
411
+ explain_parser.add_argument("--by", help="comma-separated dimension names")
412
+ explain_parser.add_argument("--where", help="filter document, as JSON")
413
+ explain_parser.add_argument("--grain", help="time grain to group date-role dimensions by")
414
+ explain_parser.add_argument("--limit", type=int, help="row limit")
415
+ explain_parser.add_argument("--policy", help="row policy, as 'dimension op value'")
416
+ explain_parser.add_argument("--dialect", default="duckdb", help="SQL dialect")
417
+ _add_format(explain_parser)
418
+ explain_parser.set_defaults(run=_explain)
419
+
420
+ schema_parser = commands.add_parser("schema", help="emit the JSON Schema for the spec kinds")
421
+ schema_parser.add_argument(
422
+ "--kind", choices=[kind.value for kind in SpecKind], help="one kind (default: all six)"
423
+ )
424
+ schema_parser.add_argument("--out", help="write one file per kind here")
425
+ schema_parser.set_defaults(run=_schema)
426
+
427
+ fingerprint_parser = commands.add_parser(
428
+ "fingerprint", help="the project's deterministic IR fingerprint"
429
+ )
430
+ _add_spec_directory(fingerprint_parser)
431
+ fingerprint_parser.set_defaults(run=_fingerprint)
432
+
433
+ return parser
434
+
435
+
436
+ def main(argv: Sequence[str] | None = None) -> int:
437
+ """Entry point for the ``bloomery`` console script.
438
+
439
+ Returns the exit code rather than calling ``sys.exit``, so a test can
440
+ invoke it as a function and read the code — which is the only way the
441
+ refusal-vs-usage split gets tested rather than asserted.
442
+
443
+ That promise covers argparse's own refusals too. ``parse_args`` writes its
444
+ message and *raises* ``SystemExit``, so an unparseable flag value —
445
+ ``--limit notanint`` — escaped this function instead of coming back as a
446
+ number. The shell saw the right code either way; a caller using ``main`` as
447
+ a function, which the paragraph above invites, did not.
448
+ """
449
+ parser = build_parser()
450
+ try:
451
+ arguments = parser.parse_args(argv)
452
+ except SystemExit as request:
453
+ # argparse exits 2 for a usage error and 0 for `--help`; both are
454
+ # already the code this function means to return, so pass them through
455
+ # rather than flattening them to one.
456
+ return request.code if isinstance(request.code, int) else EXIT_USAGE
457
+ try:
458
+ exit_code: int = arguments.run(arguments)
459
+ except BloomeryError as error:
460
+ # A refusal, not a crash: bloomery read the spec and said no.
461
+ #
462
+ # The source path is prepended here because a *single* error carries it
463
+ # as an attribute and not in its message — only the batched aggregate
464
+ # renders paths inline (RFC 0002 D6). Printing the message alone turns
465
+ # "the type string is wrong" into a sentence with no file, no key and
466
+ # nothing to act on, which is the one thing RFC 0002 §5.3 exists to
467
+ # prevent.
468
+ location = f"{error.source_path}: " if error.source_path else ""
469
+ sys.stderr.write(f"{location}{error}\n")
470
+ return EXIT_REFUSED
471
+ except (io.CliIoError, _Usage) as error:
472
+ sys.stderr.write(f"{parser.prog}: {error}\n")
473
+ return EXIT_USAGE
474
+ return exit_code
475
+
476
+
477
+ def _run() -> None: # pragma: no cover — console-script shim
478
+ raise SystemExit(main())
479
+
480
+
481
+ if __name__ == "__main__": # pragma: no cover
482
+ _run()