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.
- bloomery/__init__.py +164 -0
- bloomery/_version.py +24 -0
- bloomery/cli/__init__.py +482 -0
- bloomery/cli/io.py +167 -0
- bloomery/cli/render.py +167 -0
- bloomery/cli/serialize.py +111 -0
- bloomery/compile.py +64 -0
- bloomery/dialects/__init__.py +81 -0
- bloomery/dialects/base.py +160 -0
- bloomery/dialects/duckdb.py +70 -0
- bloomery/dialects/postgres.py +211 -0
- bloomery/dialects/trino.py +80 -0
- bloomery/emit/__init__.py +68 -0
- bloomery/emit/base.py +125 -0
- bloomery/emit/cube/__init__.py +281 -0
- bloomery/emit/dbt/__init__.py +731 -0
- bloomery/emit/lower/__init__.py +97 -0
- bloomery/emit/lower/marts.py +170 -0
- bloomery/emit/lower/predicates.py +78 -0
- bloomery/emit/lower/quality_mart.py +373 -0
- bloomery/emit/lower/reconcile.py +435 -0
- bloomery/emit/lower/silver.py +1416 -0
- bloomery/emit/metricflow/__init__.py +576 -0
- bloomery/emit/sqlmesh/__init__.py +798 -0
- bloomery/emit/steps.py +753 -0
- bloomery/errors.py +673 -0
- bloomery/evidence.py +336 -0
- bloomery/guardrails/__init__.py +12 -0
- bloomery/guardrails/additivity.py +113 -0
- bloomery/guardrails/arithmetic.py +195 -0
- bloomery/guardrails/asserts.py +177 -0
- bloomery/guardrails/conflict.py +75 -0
- bloomery/guardrails/grain.py +173 -0
- bloomery/guardrails/operands.py +115 -0
- bloomery/guardrails/quality.py +1073 -0
- bloomery/guardrails/stage.py +91 -0
- bloomery/ir/__init__.py +111 -0
- bloomery/ir/fingerprint.py +80 -0
- bloomery/ir/lower.py +88 -0
- bloomery/ir/nodes.py +820 -0
- bloomery/marts/__init__.py +18 -0
- bloomery/marts/flatten.py +634 -0
- bloomery/naming.py +55 -0
- bloomery/plan/__init__.py +19 -0
- bloomery/plan/diff.py +1484 -0
- bloomery/plan/model.py +131 -0
- bloomery/planner/__init__.py +70 -0
- bloomery/planner/coverage.py +259 -0
- bloomery/planner/explain.py +143 -0
- bloomery/planner/filters.py +302 -0
- bloomery/planner/metricflow_planner.py +200 -0
- bloomery/planner/names.py +218 -0
- bloomery/planner/parse.py +566 -0
- bloomery/planner/policy.py +55 -0
- bloomery/planner/request.py +325 -0
- bloomery/planner/result.py +114 -0
- bloomery/py.typed +0 -0
- bloomery/quality/__init__.py +219 -0
- bloomery/quality/catalogue.py +122 -0
- bloomery/quality/charset.py +107 -0
- bloomery/quality/dedupe.py +87 -0
- bloomery/quality/flags.py +163 -0
- bloomery/quality/lower.py +569 -0
- bloomery/quality/mart.py +343 -0
- bloomery/quality/pattern.py +107 -0
- bloomery/quality/predicates.py +648 -0
- bloomery/quality/reconcile.py +117 -0
- bloomery/quality/reject.py +105 -0
- bloomery/resolve/__init__.py +24 -0
- bloomery/resolve/build.py +1005 -0
- bloomery/resolve/graph.py +263 -0
- bloomery/resolve/metrics.py +94 -0
- bloomery/resolve/order.py +77 -0
- bloomery/resolve/reach.py +98 -0
- bloomery/resolve/recipes.py +117 -0
- bloomery/resolve/refs.py +265 -0
- bloomery/resolve/resolution.py +116 -0
- bloomery/resolve/steps.py +785 -0
- bloomery/runtime/__init__.py +26 -0
- bloomery/runtime/hydration.py +178 -0
- bloomery/runtime/sql_client.py +97 -0
- bloomery/schema.py +287 -0
- bloomery/spec/__init__.py +107 -0
- bloomery/spec/catalog.py +119 -0
- bloomery/spec/common.py +289 -0
- bloomery/spec/entity.py +128 -0
- bloomery/spec/mapping.py +204 -0
- bloomery/spec/marts.py +158 -0
- bloomery/spec/metrics.py +81 -0
- bloomery/spec/project.py +182 -0
- bloomery/spec/quality.py +920 -0
- bloomery/spec/steps.py +187 -0
- bloomery/steps/__init__.py +109 -0
- bloomery/steps/contract.py +212 -0
- bloomery/steps/manifest.py +399 -0
- bloomery/steps/registry.py +174 -0
- bloomery/steps/splice.py +160 -0
- bloomery/transforms/__init__.py +37 -0
- bloomery/transforms/_builtins.py +405 -0
- bloomery/transforms/registry.py +177 -0
- bloomery/typing/__init__.py +37 -0
- bloomery/typing/check.py +182 -0
- bloomery/typing/types.py +184 -0
- bloomery-0.1.0.dist-info/METADATA +224 -0
- bloomery-0.1.0.dist-info/RECORD +108 -0
- bloomery-0.1.0.dist-info/WHEEL +4 -0
- bloomery-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
bloomery/cli/__init__.py
ADDED
|
@@ -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()
|