terrine 0.1.0.dev0__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.
- terrine/__init__.py +120 -0
- terrine/_time.py +21 -0
- terrine/adapters/__init__.py +3 -0
- terrine/adapters/increment.py +365 -0
- terrine/adapters/opentelemetry.py +63 -0
- terrine/agent/__init__.py +6 -0
- terrine/agent/definitions.py +143 -0
- terrine/agent/design_schema.py +220 -0
- terrine/agent/policy.py +144 -0
- terrine/agent/schemas.py +285 -0
- terrine/agent/tools.py +956 -0
- terrine/backends/__init__.py +3 -0
- terrine/backends/capabilities.py +117 -0
- terrine/backends/dialect.py +55 -0
- terrine/backends/extrema.py +40 -0
- terrine/backends/joins.py +75 -0
- terrine/backends/numeric.py +657 -0
- terrine/backends/sequences.py +40 -0
- terrine/backends/timestamps.py +421 -0
- terrine/compile/__init__.py +3 -0
- terrine/compile/_asof.py +165 -0
- terrine/compile/_predicates.py +110 -0
- terrine/compile/anchors.py +492 -0
- terrine/compile/columns.py +1353 -0
- terrine/compile/cost.py +110 -0
- terrine/compile/dispatch.py +148 -0
- terrine/compile/layers.py +242 -0
- terrine/compile/materialize.py +91 -0
- terrine/compile/provenance.py +120 -0
- terrine/compile/ranges.py +91 -0
- terrine/compile/retention.py +127 -0
- terrine/compile/shapes/__init__.py +3 -0
- terrine/compile/shapes/_observations.py +502 -0
- terrine/compile/shapes/calendar_comparison.py +370 -0
- terrine/compile/shapes/cohorts.py +285 -0
- terrine/compile/shapes/event_panel.py +46 -0
- terrine/compile/shapes/event_sequence.py +498 -0
- terrine/compile/shapes/life_table.py +244 -0
- terrine/compile/shapes/ltv.py +287 -0
- terrine/compile/shapes/matched_t0.py +75 -0
- terrine/compile/shapes/sequential.py +320 -0
- terrine/compile/shapes/state_panel.py +460 -0
- terrine/compile/shapes/time_to_event.py +521 -0
- terrine/data.py +1311 -0
- terrine/dataset/__init__.py +5 -0
- terrine/dataset/export.py +463 -0
- terrine/dataset/manifest.py +254 -0
- terrine/dataset/reference.py +582 -0
- terrine/dataset/splits.py +373 -0
- terrine/dataset/training.py +114 -0
- terrine/dataset/transforms.py +182 -0
- terrine/errors.py +98 -0
- terrine/execution/__init__.py +5 -0
- terrine/execution/_bigquery.py +318 -0
- terrine/execution/_metadata.py +47 -0
- terrine/execution/_native.py +574 -0
- terrine/execution/_postgres.py +468 -0
- terrine/execution/_publish.py +299 -0
- terrine/execution/_snowflake.py +684 -0
- terrine/execution/backend.py +349 -0
- terrine/execution/gates.py +90 -0
- terrine/execution/inputs.py +923 -0
- terrine/export/__init__.py +31 -0
- terrine/export/diff_diff.py +177 -0
- terrine/export/lifelines.py +219 -0
- terrine/export/preflight.py +243 -0
- terrine/export/surrogate.py +168 -0
- terrine/frame.py +1042 -0
- terrine/model.py +397 -0
- terrine/orchestration/__init__.py +3 -0
- terrine/orchestration/airflow.py +115 -0
- terrine/orchestration/dagster.py +103 -0
- terrine/py.typed +0 -0
- terrine/recipes.py +295 -0
- terrine/runtime/__init__.py +70 -0
- terrine/runtime/api.py +803 -0
- terrine/runtime/arrow.py +98 -0
- terrine/runtime/backends/base.py +70 -0
- terrine/runtime/backends/duckdb.py +645 -0
- terrine/runtime/changes.py +71 -0
- terrine/runtime/cli.py +108 -0
- terrine/runtime/control.py +355 -0
- terrine/runtime/cost.py +211 -0
- terrine/runtime/events.py +194 -0
- terrine/runtime/execute.py +932 -0
- terrine/runtime/gc.py +43 -0
- terrine/runtime/graph.py +200 -0
- terrine/runtime/layers.py +61 -0
- terrine/runtime/leases.py +165 -0
- terrine/runtime/materialize.py +53 -0
- terrine/runtime/owner.py +57 -0
- terrine/runtime/planner.py +441 -0
- terrine/runtime/planning.py +394 -0
- terrine/runtime/saved_plan.py +337 -0
- terrine/runtime/snapshot.py +541 -0
- terrine/runtime/state.py +585 -0
- terrine/runtime/status.py +360 -0
- terrine/runtime/synchronization.py +15 -0
- terrine/semantic/__init__.py +3 -0
- terrine/semantic/bind.py +309 -0
- terrine/semantic/io.py +116 -0
- terrine/semantic/models.py +342 -0
- terrine/semantic/rules.py +113 -0
- terrine/semantic/sql.py +185 -0
- terrine/semantic/time.py +42 -0
- terrine/simulate/__init__.py +24 -0
- terrine/simulate/baseline.yaml +57 -0
- terrine/simulate/generate.py +182 -0
- terrine/simulate/scale.py +234 -0
- terrine/simulate/world.py +75 -0
- terrine/spec/__init__.py +96 -0
- terrine/spec/anchors.py +107 -0
- terrine/spec/dataset.py +260 -0
- terrine/spec/design.py +361 -0
- terrine/spec/expressions.py +440 -0
- terrine/spec/io.py +829 -0
- terrine/spec/panels.py +359 -0
- terrine/spec/sequence.py +165 -0
- terrine/spec/state_panel.py +246 -0
- terrine/spec/trajectory.py +144 -0
- terrine/spec/transforms.py +260 -0
- terrine/spec/validate.py +758 -0
- terrine/temporal/__init__.py +3 -0
- terrine/temporal/aggregate.py +688 -0
- terrine/temporal/attributes.py +389 -0
- terrine/temporal/daily.py +481 -0
- terrine/temporal/first.py +239 -0
- terrine/temporal/lookup.py +194 -0
- terrine/temporal/nonadditive.py +466 -0
- terrine/temporal/prefix.py +104 -0
- terrine/temporal/ranges.py +122 -0
- terrine/temporal/readiness.py +41 -0
- terrine/temporal/retention.py +225 -0
- terrine/temporal/sequence.py +900 -0
- terrine/temporal/sessionize.py +135 -0
- terrine/temporal/sketches.py +329 -0
- terrine/windows.py +54 -0
- terrine-0.1.0.dev0.dist-info/METADATA +164 -0
- terrine-0.1.0.dev0.dist-info/RECORD +144 -0
- terrine-0.1.0.dev0.dist-info/WHEEL +4 -0
- terrine-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- terrine-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
- terrine-0.1.0.dev0.dist-info/licenses/NOTICE +4 -0
- terrine-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.txt +219 -0
terrine/__init__.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Typed, semantic-layer dataset construction."""
|
|
2
|
+
|
|
3
|
+
from importlib import import_module
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from terrine.errors import TerrineError
|
|
7
|
+
from terrine.windows import Window
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from terrine import recipes as recipes
|
|
11
|
+
from terrine.compile.cost import preview
|
|
12
|
+
from terrine.data import Budget, Coverage, Events, UnitSource
|
|
13
|
+
from terrine.dataset.reference import Dataset
|
|
14
|
+
from terrine.frame import Frame
|
|
15
|
+
from terrine.model import Model
|
|
16
|
+
from terrine.spec import (
|
|
17
|
+
AnchorRange,
|
|
18
|
+
BinnedMetricState,
|
|
19
|
+
CohortGrouping,
|
|
20
|
+
CohortSpec,
|
|
21
|
+
DatasetDefinition,
|
|
22
|
+
Design,
|
|
23
|
+
EventAnchor,
|
|
24
|
+
EventSequence,
|
|
25
|
+
EventTime,
|
|
26
|
+
FixedDate,
|
|
27
|
+
FunnelSpec,
|
|
28
|
+
LifeTable,
|
|
29
|
+
Schedule,
|
|
30
|
+
SessionSpec,
|
|
31
|
+
SketchRequest,
|
|
32
|
+
StateObservations,
|
|
33
|
+
StatePanel,
|
|
34
|
+
TimeToEvent,
|
|
35
|
+
Trajectory,
|
|
36
|
+
Treatment,
|
|
37
|
+
agg,
|
|
38
|
+
all_of,
|
|
39
|
+
approx_distinct,
|
|
40
|
+
approx_quantile,
|
|
41
|
+
asof,
|
|
42
|
+
eq,
|
|
43
|
+
event_time,
|
|
44
|
+
from_yaml,
|
|
45
|
+
gt,
|
|
46
|
+
is_null,
|
|
47
|
+
quantile,
|
|
48
|
+
retention,
|
|
49
|
+
to_yaml,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"Dataset",
|
|
54
|
+
"DatasetDefinition",
|
|
55
|
+
"AnchorRange",
|
|
56
|
+
"Budget",
|
|
57
|
+
"EventSequence",
|
|
58
|
+
"EventTime",
|
|
59
|
+
"FunnelSpec",
|
|
60
|
+
"SessionSpec",
|
|
61
|
+
"Coverage",
|
|
62
|
+
"Events",
|
|
63
|
+
"Design",
|
|
64
|
+
"EventAnchor",
|
|
65
|
+
"FixedDate",
|
|
66
|
+
"Frame",
|
|
67
|
+
"retention",
|
|
68
|
+
"Model",
|
|
69
|
+
"Schedule",
|
|
70
|
+
"SketchRequest",
|
|
71
|
+
"Treatment",
|
|
72
|
+
"UnitSource",
|
|
73
|
+
"Window",
|
|
74
|
+
"TerrineError",
|
|
75
|
+
"agg",
|
|
76
|
+
"all_of",
|
|
77
|
+
"approx_quantile",
|
|
78
|
+
"approx_distinct",
|
|
79
|
+
"asof",
|
|
80
|
+
"eq",
|
|
81
|
+
"from_yaml",
|
|
82
|
+
"event_time",
|
|
83
|
+
"gt",
|
|
84
|
+
"is_null",
|
|
85
|
+
"preview",
|
|
86
|
+
"BinnedMetricState",
|
|
87
|
+
"StateObservations",
|
|
88
|
+
"StatePanel",
|
|
89
|
+
"CohortGrouping",
|
|
90
|
+
"CohortSpec",
|
|
91
|
+
"LifeTable",
|
|
92
|
+
"Trajectory",
|
|
93
|
+
"TimeToEvent",
|
|
94
|
+
"to_yaml",
|
|
95
|
+
"quantile",
|
|
96
|
+
"recipes",
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
_LAZY_MODULES = {
|
|
100
|
+
"Dataset": "terrine.dataset.reference",
|
|
101
|
+
"Budget": "terrine.data",
|
|
102
|
+
"Frame": "terrine.frame",
|
|
103
|
+
"preview": "terrine.compile.cost",
|
|
104
|
+
"Coverage": "terrine.data",
|
|
105
|
+
"Events": "terrine.data",
|
|
106
|
+
"Model": "terrine.model",
|
|
107
|
+
"UnitSource": "terrine.data",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def __getattr__(name: str):
|
|
112
|
+
if name not in __all__:
|
|
113
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
114
|
+
value = (
|
|
115
|
+
import_module("terrine.recipes")
|
|
116
|
+
if name == "recipes"
|
|
117
|
+
else getattr(import_module(_LAZY_MODULES.get(name, "terrine.spec")), name)
|
|
118
|
+
)
|
|
119
|
+
globals()[name] = value
|
|
120
|
+
return value
|
terrine/_time.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Exact microsecond UTC instants for portable definitions."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
|
|
6
|
+
_FRACTION = re.compile(r"[.,]([0-9]+)")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def utc_instant(value: datetime | str) -> datetime:
|
|
10
|
+
"""Normalize an aware instant without silently discarding submicroseconds."""
|
|
11
|
+
if isinstance(value, str):
|
|
12
|
+
for match in _FRACTION.finditer(value):
|
|
13
|
+
if match.group(1)[6:].strip("0"):
|
|
14
|
+
raise ValueError("instant must be exactly representable at microsecond precision")
|
|
15
|
+
value = datetime.fromisoformat(value)
|
|
16
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
17
|
+
raise ValueError("instant must be timezone-aware")
|
|
18
|
+
value = value.astimezone(UTC)
|
|
19
|
+
if type(value) is not datetime:
|
|
20
|
+
return utc_instant(value.isoformat())
|
|
21
|
+
return value
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Optional bridge from published increment definitions into native Catalogs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from importlib import import_module
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from yaml.constructor import ConstructorError
|
|
12
|
+
from yaml.resolver import BaseResolver
|
|
13
|
+
|
|
14
|
+
from terrine.errors import RefusalSpec
|
|
15
|
+
from terrine.semantic.models import (
|
|
16
|
+
Catalog,
|
|
17
|
+
Dimension,
|
|
18
|
+
Fact,
|
|
19
|
+
Filter,
|
|
20
|
+
Measure,
|
|
21
|
+
Metric,
|
|
22
|
+
Property,
|
|
23
|
+
Source,
|
|
24
|
+
SqlQuery,
|
|
25
|
+
Validity,
|
|
26
|
+
)
|
|
27
|
+
from terrine.semantic.rules import UNSUPPORTED_TEMPORAL_KIND
|
|
28
|
+
from terrine.semantic.sql import admit_read_query
|
|
29
|
+
|
|
30
|
+
__all__ = ["to_catalog"]
|
|
31
|
+
|
|
32
|
+
_MISSING = RefusalSpec(
|
|
33
|
+
"terrine.increment.missing_extra",
|
|
34
|
+
"The increment compatibility extra is required; install terrine[increment].",
|
|
35
|
+
)
|
|
36
|
+
_DUPLICATE_YAML_KEY = RefusalSpec(
|
|
37
|
+
"terrine.increment.duplicate_yaml_key",
|
|
38
|
+
"increment YAML repeats mapping key {key!r} in {path!r}.",
|
|
39
|
+
)
|
|
40
|
+
_UNSUPPORTED = RefusalSpec(
|
|
41
|
+
"terrine.increment.unsupported_definition",
|
|
42
|
+
"increment declaration {declaration!r} has semantics not represented by the native Catalog.",
|
|
43
|
+
)
|
|
44
|
+
_INVALID_DEFINITIONS = RefusalSpec(
|
|
45
|
+
"terrine.increment.invalid_definitions",
|
|
46
|
+
"increment definitions were rejected with upstream code {upstream_code!r}.",
|
|
47
|
+
)
|
|
48
|
+
_PROPERTY_COLLISION = RefusalSpec(
|
|
49
|
+
"terrine.increment.property_name_collision",
|
|
50
|
+
"increment property names cannot be qualified without a Catalog name collision: {name!r}.",
|
|
51
|
+
)
|
|
52
|
+
_AMBIGUOUS_PROPERTY = RefusalSpec(
|
|
53
|
+
"terrine.increment.ambiguous_property",
|
|
54
|
+
"increment filter property {property!r} is ambiguous in source {source!r}.",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _DuplicateKey(Exception):
|
|
59
|
+
def __init__(self, key: object) -> None:
|
|
60
|
+
self.key = key
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _UniqueSafeLoader(yaml.SafeLoader):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _construct_unique_mapping(loader: _UniqueSafeLoader, node, deep: bool = False):
|
|
68
|
+
mapping = {}
|
|
69
|
+
for key_node, value_node in node.value:
|
|
70
|
+
key = loader.construct_object(key_node, deep=deep)
|
|
71
|
+
try:
|
|
72
|
+
duplicate = key in mapping
|
|
73
|
+
except TypeError as exc:
|
|
74
|
+
raise ConstructorError(
|
|
75
|
+
"while constructing a mapping",
|
|
76
|
+
node.start_mark,
|
|
77
|
+
"unhashable mapping key",
|
|
78
|
+
key_node.start_mark,
|
|
79
|
+
) from exc
|
|
80
|
+
if duplicate:
|
|
81
|
+
raise _DuplicateKey(key)
|
|
82
|
+
mapping[key] = loader.construct_object(value_node, deep=deep)
|
|
83
|
+
return mapping
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_UniqueSafeLoader.add_constructor(BaseResolver.DEFAULT_MAPPING_TAG, _construct_unique_mapping)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _check_duplicate_keys(path: Path) -> None:
|
|
90
|
+
yaml_files = (
|
|
91
|
+
(path,) if path.is_file() else tuple(sorted((*path.rglob("*.yaml"), *path.rglob("*.yml"))))
|
|
92
|
+
)
|
|
93
|
+
for yaml_path in yaml_files:
|
|
94
|
+
try:
|
|
95
|
+
with yaml_path.open(encoding="utf-8") as stream:
|
|
96
|
+
yaml.load(stream, Loader=_UniqueSafeLoader)
|
|
97
|
+
except _DuplicateKey as exc:
|
|
98
|
+
raise _DUPLICATE_YAML_KEY.error(key=str(exc.key), path=str(yaml_path)) from exc
|
|
99
|
+
except yaml.YAMLError:
|
|
100
|
+
# Let increment's loader provide its coded YAML refusal.
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _add_property_name(
|
|
105
|
+
raw_name: str,
|
|
106
|
+
scope_kind: str,
|
|
107
|
+
scope: str,
|
|
108
|
+
counts: Counter[str],
|
|
109
|
+
scope_counts: Counter[str],
|
|
110
|
+
names: dict[tuple[str, str, str], str],
|
|
111
|
+
) -> None:
|
|
112
|
+
if counts[raw_name] == 1:
|
|
113
|
+
name = raw_name
|
|
114
|
+
elif scope_counts[scope] > 1:
|
|
115
|
+
name = f"{scope_kind}.{scope}.{raw_name}"
|
|
116
|
+
else:
|
|
117
|
+
name = f"{scope}.{raw_name}"
|
|
118
|
+
names[(scope_kind, scope, raw_name)] = name
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _filter_records(
|
|
122
|
+
filters: Any,
|
|
123
|
+
*,
|
|
124
|
+
source: str,
|
|
125
|
+
available_scopes: tuple[tuple[str, str], ...],
|
|
126
|
+
names: dict[tuple[str, str, str], str],
|
|
127
|
+
):
|
|
128
|
+
records = []
|
|
129
|
+
for source_filter in filters:
|
|
130
|
+
matching = [
|
|
131
|
+
names[(scope_kind, scope, source_filter.property)]
|
|
132
|
+
for scope_kind, scope in available_scopes
|
|
133
|
+
if (scope_kind, scope, source_filter.property) in names
|
|
134
|
+
]
|
|
135
|
+
if len(matching) > 1:
|
|
136
|
+
raise _AMBIGUOUS_PROPERTY.error(property=source_filter.property, source=source)
|
|
137
|
+
property_name = matching[0] if matching else source_filter.property
|
|
138
|
+
records.append(
|
|
139
|
+
Filter(
|
|
140
|
+
property=property_name,
|
|
141
|
+
op=source_filter.op,
|
|
142
|
+
values=tuple(source_filter.values),
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
return tuple(records)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _measure(
|
|
149
|
+
fact: str,
|
|
150
|
+
aggregation: Literal[
|
|
151
|
+
"sum", "count", "avg_event", "avg_calendar_day", "min", "max", "count_distinct"
|
|
152
|
+
],
|
|
153
|
+
filters: Any,
|
|
154
|
+
window_days: int | None,
|
|
155
|
+
*,
|
|
156
|
+
source: str,
|
|
157
|
+
scopes: tuple[tuple[str, str], ...],
|
|
158
|
+
names: dict[tuple[str, str, str], str],
|
|
159
|
+
) -> Measure:
|
|
160
|
+
return Measure(
|
|
161
|
+
fact=fact,
|
|
162
|
+
aggregation=aggregation,
|
|
163
|
+
filters=_filter_records(filters, source=source, available_scopes=scopes, names=names),
|
|
164
|
+
window_days=window_days,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def to_catalog(path_or_definitions: Any, *, dialect: str) -> Catalog:
|
|
169
|
+
"""Convert increment's published semantic definitions to a native Catalog.
|
|
170
|
+
|
|
171
|
+
The optional dependency is loaded only when this bridge is invoked. The bridge
|
|
172
|
+
projects fact sources, metrics, and referenced dimensions; exposure, experiment,
|
|
173
|
+
week-start, and unreferenced-dimension declarations are not interpreted. Source
|
|
174
|
+
SQL is re-admitted by Terrine and execution remains the native Model.from_catalog
|
|
175
|
+
path.
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
semantics = import_module("increment.semantics")
|
|
179
|
+
metric_models = import_module("increment.semantics.models")
|
|
180
|
+
except (ImportError, ModuleNotFoundError) as exc:
|
|
181
|
+
raise _MISSING.error(extra="increment") from exc
|
|
182
|
+
|
|
183
|
+
if isinstance(path_or_definitions, semantics.Definitions):
|
|
184
|
+
definitions = path_or_definitions
|
|
185
|
+
else:
|
|
186
|
+
path = Path(path_or_definitions)
|
|
187
|
+
_check_duplicate_keys(path)
|
|
188
|
+
try:
|
|
189
|
+
definitions = semantics.load(path)
|
|
190
|
+
except semantics.DefinitionError as exc:
|
|
191
|
+
raise _INVALID_DEFINITIONS.error(upstream_code=exc.code) from exc
|
|
192
|
+
|
|
193
|
+
fact_sources = {source.name: source for source in definitions.fact_sources}
|
|
194
|
+
dimension_sources = {source.name: source for source in definitions.dim_sources}
|
|
195
|
+
referenced_dimensions = {
|
|
196
|
+
dimension_name for source in definitions.fact_sources for dimension_name in source.dims
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
scopes_by_property: dict[str, list[tuple[str, str]]] = {}
|
|
200
|
+
for source in definitions.fact_sources:
|
|
201
|
+
for prop in source.properties:
|
|
202
|
+
scopes_by_property.setdefault(prop.name, []).append(("source", source.name))
|
|
203
|
+
for dimension in definitions.dim_sources:
|
|
204
|
+
if dimension.name in referenced_dimensions:
|
|
205
|
+
for prop in dimension.properties:
|
|
206
|
+
scopes_by_property.setdefault(prop.name, []).append(("dimension", dimension.name))
|
|
207
|
+
counts = Counter({name: len(scopes) for name, scopes in scopes_by_property.items()})
|
|
208
|
+
property_names: dict[tuple[str, str, str], str] = {}
|
|
209
|
+
for raw_name, scopes in scopes_by_property.items():
|
|
210
|
+
scope_counts = Counter(scope for _, scope in scopes)
|
|
211
|
+
for scope_kind, scope in scopes:
|
|
212
|
+
_add_property_name(raw_name, scope_kind, scope, counts, scope_counts, property_names)
|
|
213
|
+
projected_names = list(property_names.values())
|
|
214
|
+
if len(set(projected_names)) != len(projected_names):
|
|
215
|
+
duplicate = next(name for name, count in Counter(projected_names).items() if count > 1)
|
|
216
|
+
raise _PROPERTY_COLLISION.error(name=duplicate)
|
|
217
|
+
|
|
218
|
+
sources: dict[str, Source] = {}
|
|
219
|
+
facts: dict[str, Fact] = {}
|
|
220
|
+
properties: dict[str, Property] = {}
|
|
221
|
+
for source in definitions.fact_sources:
|
|
222
|
+
source_properties = []
|
|
223
|
+
for source_fact in source.facts:
|
|
224
|
+
facts[source_fact.name] = Fact(source=source.name, column=source_fact.column)
|
|
225
|
+
for source_property in source.properties:
|
|
226
|
+
name = property_names[("source", source.name, source_property.name)]
|
|
227
|
+
source_properties.append(name)
|
|
228
|
+
properties[name] = Property(
|
|
229
|
+
source=source.name,
|
|
230
|
+
column=source_property.column,
|
|
231
|
+
dtype=source_property.dtype,
|
|
232
|
+
as_of=source_property.as_of,
|
|
233
|
+
)
|
|
234
|
+
sources[source.name] = Source(
|
|
235
|
+
relation=SqlQuery(kind="sql", sql=source.sql),
|
|
236
|
+
timestamp=source.timestamp_column,
|
|
237
|
+
entities={column: column for column in source.entities},
|
|
238
|
+
facts=tuple(fact.name for fact in source.facts),
|
|
239
|
+
properties=tuple(source_properties),
|
|
240
|
+
dimensions=tuple(source.dims),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
dimensions: dict[str, Dimension] = {}
|
|
244
|
+
for dimension_name in sorted(referenced_dimensions):
|
|
245
|
+
dimension = dimension_sources[dimension_name]
|
|
246
|
+
owners = sorted(
|
|
247
|
+
source.name for source in definitions.fact_sources if dimension_name in source.dims
|
|
248
|
+
)
|
|
249
|
+
owner = owners[0]
|
|
250
|
+
dimension_properties = []
|
|
251
|
+
for source_property in dimension.properties:
|
|
252
|
+
name = property_names[("dimension", dimension.name, source_property.name)]
|
|
253
|
+
dimension_properties.append(name)
|
|
254
|
+
properties[name] = Property(
|
|
255
|
+
source=owner,
|
|
256
|
+
column=source_property.column,
|
|
257
|
+
dtype=source_property.dtype,
|
|
258
|
+
as_of=source_property.as_of,
|
|
259
|
+
dimension=dimension.name,
|
|
260
|
+
)
|
|
261
|
+
validity = (
|
|
262
|
+
Validity(
|
|
263
|
+
valid_from=dimension.validity.valid_from,
|
|
264
|
+
valid_to=dimension.validity.valid_to,
|
|
265
|
+
changed_at=dimension.validity.changed_at,
|
|
266
|
+
)
|
|
267
|
+
if dimension.validity is not None
|
|
268
|
+
else None
|
|
269
|
+
)
|
|
270
|
+
dimensions[dimension.name] = Dimension(
|
|
271
|
+
relation=SqlQuery(kind="sql", sql=dimension.sql),
|
|
272
|
+
source_entity=dimension.entity,
|
|
273
|
+
entity_column=dimension.entity,
|
|
274
|
+
validity=validity,
|
|
275
|
+
properties=tuple(dimension_properties),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
measures: dict[str, Measure] = {}
|
|
279
|
+
metrics: dict[str, Metric] = {}
|
|
280
|
+
for metric in definitions.metrics:
|
|
281
|
+
kind = metric.type
|
|
282
|
+
if not isinstance(
|
|
283
|
+
metric,
|
|
284
|
+
(metric_models.MeanMetric, metric_models.ConversionMetric, metric_models.RatioMetric),
|
|
285
|
+
):
|
|
286
|
+
raise UNSUPPORTED_TEMPORAL_KIND.error(kind=kind)
|
|
287
|
+
if isinstance(metric, metric_models.RatioMetric):
|
|
288
|
+
numerator_name = f"{metric.name}.numerator"
|
|
289
|
+
denominator_name = f"{metric.name}.denominator"
|
|
290
|
+
for name, part in (
|
|
291
|
+
(numerator_name, metric.numerator),
|
|
292
|
+
(denominator_name, metric.denominator),
|
|
293
|
+
):
|
|
294
|
+
if name in measures:
|
|
295
|
+
raise _UNSUPPORTED.error(declaration=f"measure name collision {name!r}")
|
|
296
|
+
owner = next(
|
|
297
|
+
source.name
|
|
298
|
+
for source in definitions.fact_sources
|
|
299
|
+
if any(fact.name == part.fact for fact in source.facts)
|
|
300
|
+
)
|
|
301
|
+
source = fact_sources[owner]
|
|
302
|
+
measures[name] = _measure(
|
|
303
|
+
part.fact,
|
|
304
|
+
part.aggregation,
|
|
305
|
+
part.filters,
|
|
306
|
+
part.window_days,
|
|
307
|
+
source=owner,
|
|
308
|
+
scopes=(("source", owner), *(("dimension", name) for name in source.dims)),
|
|
309
|
+
names=property_names,
|
|
310
|
+
)
|
|
311
|
+
metrics[metric.name] = Metric(
|
|
312
|
+
kind=kind,
|
|
313
|
+
entity=metric.entity,
|
|
314
|
+
numerator=numerator_name,
|
|
315
|
+
denominator=denominator_name,
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
name = metric.name
|
|
319
|
+
if name in measures:
|
|
320
|
+
raise _UNSUPPORTED.error(declaration=f"measure name collision {name!r}")
|
|
321
|
+
owner = next(
|
|
322
|
+
source.name
|
|
323
|
+
for source in definitions.fact_sources
|
|
324
|
+
if any(fact.name == metric.fact for fact in source.facts)
|
|
325
|
+
)
|
|
326
|
+
source = fact_sources[owner]
|
|
327
|
+
aggregation = (
|
|
328
|
+
metric.aggregation if isinstance(metric, metric_models.MeanMetric) else "count"
|
|
329
|
+
)
|
|
330
|
+
measures[name] = _measure(
|
|
331
|
+
metric.fact,
|
|
332
|
+
aggregation,
|
|
333
|
+
metric.filters,
|
|
334
|
+
metric.window_days,
|
|
335
|
+
source=owner,
|
|
336
|
+
scopes=(("source", owner), *(("dimension", dim_name) for dim_name in source.dims)),
|
|
337
|
+
names=property_names,
|
|
338
|
+
)
|
|
339
|
+
winsorization = (
|
|
340
|
+
metric.winsorization.model_dump(mode="json")
|
|
341
|
+
if isinstance(metric, metric_models.MeanMetric) and metric.winsorization is not None
|
|
342
|
+
else None
|
|
343
|
+
)
|
|
344
|
+
metrics[metric.name] = Metric(
|
|
345
|
+
kind=kind, entity=metric.entity, measure=name, winsorization=winsorization
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
catalog_dialect = definitions.dialect or dialect
|
|
349
|
+
for source in definitions.fact_sources:
|
|
350
|
+
admit_read_query(source.sql, catalog_dialect, dialect)
|
|
351
|
+
for dimension in definitions.dim_sources:
|
|
352
|
+
if dimension.name in referenced_dimensions:
|
|
353
|
+
admit_read_query(dimension.sql, catalog_dialect, dialect)
|
|
354
|
+
|
|
355
|
+
return Catalog(
|
|
356
|
+
schema_version=1,
|
|
357
|
+
day_boundary=definitions.day_boundary,
|
|
358
|
+
dialect=catalog_dialect,
|
|
359
|
+
sources=sources,
|
|
360
|
+
facts=facts,
|
|
361
|
+
properties=properties,
|
|
362
|
+
dimensions=dimensions,
|
|
363
|
+
measures=measures,
|
|
364
|
+
metrics=metrics,
|
|
365
|
+
)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import queue
|
|
4
|
+
import threading
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
__all__ = ["OpenTelemetryAdapter"]
|
|
8
|
+
|
|
9
|
+
_STATES = frozenset({"queued", "running", "cancel_requested", "succeeded", "failed", "cancelled"})
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OpenTelemetryAdapter:
|
|
13
|
+
"""Project safe lifecycle states to application-configured OpenTelemetry instruments."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, tracer: Any, meter: Any, *, queue_size: int = 128) -> None:
|
|
16
|
+
if isinstance(queue_size, bool) or queue_size < 1:
|
|
17
|
+
raise ValueError("queue_size must be positive")
|
|
18
|
+
self._tracer = tracer
|
|
19
|
+
self._events: queue.Queue[str] = queue.Queue(maxsize=queue_size)
|
|
20
|
+
try:
|
|
21
|
+
self._counter = meter.create_counter("terrine.runtime.lifecycle")
|
|
22
|
+
except Exception:
|
|
23
|
+
self._counter = None
|
|
24
|
+
self._stopping = threading.Event()
|
|
25
|
+
self._worker = threading.Thread(target=self._export, name="terrine-otel", daemon=True)
|
|
26
|
+
self._worker.start()
|
|
27
|
+
|
|
28
|
+
def observe_event(self, event: Any) -> None:
|
|
29
|
+
"""Queue an allowlisted lifecycle state without blocking the caller."""
|
|
30
|
+
if event.kind != "lifecycle" or not isinstance(event.payload, dict):
|
|
31
|
+
return
|
|
32
|
+
state = event.payload.get("state")
|
|
33
|
+
if not isinstance(state, str) or state not in _STATES:
|
|
34
|
+
return
|
|
35
|
+
try:
|
|
36
|
+
self._events.put_nowait(state)
|
|
37
|
+
except queue.Full:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
def close(self) -> None:
|
|
41
|
+
"""Request worker shutdown without waiting indefinitely on an exporter."""
|
|
42
|
+
self._stopping.set()
|
|
43
|
+
self._worker.join(timeout=0.05)
|
|
44
|
+
|
|
45
|
+
def _export(self) -> None:
|
|
46
|
+
while not self._stopping.is_set() or not self._events.empty():
|
|
47
|
+
try:
|
|
48
|
+
state = self._events.get(timeout=0.05)
|
|
49
|
+
except queue.Empty:
|
|
50
|
+
continue
|
|
51
|
+
attributes = {"terrine.run.state": state}
|
|
52
|
+
try:
|
|
53
|
+
if self._counter is not None:
|
|
54
|
+
self._counter.add(1, attributes)
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
try:
|
|
58
|
+
span = self._tracer.start_span("terrine.runtime.lifecycle", attributes=attributes)
|
|
59
|
+
span.end()
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
finally:
|
|
63
|
+
self._events.task_done()
|