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.
Files changed (144) hide show
  1. terrine/__init__.py +120 -0
  2. terrine/_time.py +21 -0
  3. terrine/adapters/__init__.py +3 -0
  4. terrine/adapters/increment.py +365 -0
  5. terrine/adapters/opentelemetry.py +63 -0
  6. terrine/agent/__init__.py +6 -0
  7. terrine/agent/definitions.py +143 -0
  8. terrine/agent/design_schema.py +220 -0
  9. terrine/agent/policy.py +144 -0
  10. terrine/agent/schemas.py +285 -0
  11. terrine/agent/tools.py +956 -0
  12. terrine/backends/__init__.py +3 -0
  13. terrine/backends/capabilities.py +117 -0
  14. terrine/backends/dialect.py +55 -0
  15. terrine/backends/extrema.py +40 -0
  16. terrine/backends/joins.py +75 -0
  17. terrine/backends/numeric.py +657 -0
  18. terrine/backends/sequences.py +40 -0
  19. terrine/backends/timestamps.py +421 -0
  20. terrine/compile/__init__.py +3 -0
  21. terrine/compile/_asof.py +165 -0
  22. terrine/compile/_predicates.py +110 -0
  23. terrine/compile/anchors.py +492 -0
  24. terrine/compile/columns.py +1353 -0
  25. terrine/compile/cost.py +110 -0
  26. terrine/compile/dispatch.py +148 -0
  27. terrine/compile/layers.py +242 -0
  28. terrine/compile/materialize.py +91 -0
  29. terrine/compile/provenance.py +120 -0
  30. terrine/compile/ranges.py +91 -0
  31. terrine/compile/retention.py +127 -0
  32. terrine/compile/shapes/__init__.py +3 -0
  33. terrine/compile/shapes/_observations.py +502 -0
  34. terrine/compile/shapes/calendar_comparison.py +370 -0
  35. terrine/compile/shapes/cohorts.py +285 -0
  36. terrine/compile/shapes/event_panel.py +46 -0
  37. terrine/compile/shapes/event_sequence.py +498 -0
  38. terrine/compile/shapes/life_table.py +244 -0
  39. terrine/compile/shapes/ltv.py +287 -0
  40. terrine/compile/shapes/matched_t0.py +75 -0
  41. terrine/compile/shapes/sequential.py +320 -0
  42. terrine/compile/shapes/state_panel.py +460 -0
  43. terrine/compile/shapes/time_to_event.py +521 -0
  44. terrine/data.py +1311 -0
  45. terrine/dataset/__init__.py +5 -0
  46. terrine/dataset/export.py +463 -0
  47. terrine/dataset/manifest.py +254 -0
  48. terrine/dataset/reference.py +582 -0
  49. terrine/dataset/splits.py +373 -0
  50. terrine/dataset/training.py +114 -0
  51. terrine/dataset/transforms.py +182 -0
  52. terrine/errors.py +98 -0
  53. terrine/execution/__init__.py +5 -0
  54. terrine/execution/_bigquery.py +318 -0
  55. terrine/execution/_metadata.py +47 -0
  56. terrine/execution/_native.py +574 -0
  57. terrine/execution/_postgres.py +468 -0
  58. terrine/execution/_publish.py +299 -0
  59. terrine/execution/_snowflake.py +684 -0
  60. terrine/execution/backend.py +349 -0
  61. terrine/execution/gates.py +90 -0
  62. terrine/execution/inputs.py +923 -0
  63. terrine/export/__init__.py +31 -0
  64. terrine/export/diff_diff.py +177 -0
  65. terrine/export/lifelines.py +219 -0
  66. terrine/export/preflight.py +243 -0
  67. terrine/export/surrogate.py +168 -0
  68. terrine/frame.py +1042 -0
  69. terrine/model.py +397 -0
  70. terrine/orchestration/__init__.py +3 -0
  71. terrine/orchestration/airflow.py +115 -0
  72. terrine/orchestration/dagster.py +103 -0
  73. terrine/py.typed +0 -0
  74. terrine/recipes.py +295 -0
  75. terrine/runtime/__init__.py +70 -0
  76. terrine/runtime/api.py +803 -0
  77. terrine/runtime/arrow.py +98 -0
  78. terrine/runtime/backends/base.py +70 -0
  79. terrine/runtime/backends/duckdb.py +645 -0
  80. terrine/runtime/changes.py +71 -0
  81. terrine/runtime/cli.py +108 -0
  82. terrine/runtime/control.py +355 -0
  83. terrine/runtime/cost.py +211 -0
  84. terrine/runtime/events.py +194 -0
  85. terrine/runtime/execute.py +932 -0
  86. terrine/runtime/gc.py +43 -0
  87. terrine/runtime/graph.py +200 -0
  88. terrine/runtime/layers.py +61 -0
  89. terrine/runtime/leases.py +165 -0
  90. terrine/runtime/materialize.py +53 -0
  91. terrine/runtime/owner.py +57 -0
  92. terrine/runtime/planner.py +441 -0
  93. terrine/runtime/planning.py +394 -0
  94. terrine/runtime/saved_plan.py +337 -0
  95. terrine/runtime/snapshot.py +541 -0
  96. terrine/runtime/state.py +585 -0
  97. terrine/runtime/status.py +360 -0
  98. terrine/runtime/synchronization.py +15 -0
  99. terrine/semantic/__init__.py +3 -0
  100. terrine/semantic/bind.py +309 -0
  101. terrine/semantic/io.py +116 -0
  102. terrine/semantic/models.py +342 -0
  103. terrine/semantic/rules.py +113 -0
  104. terrine/semantic/sql.py +185 -0
  105. terrine/semantic/time.py +42 -0
  106. terrine/simulate/__init__.py +24 -0
  107. terrine/simulate/baseline.yaml +57 -0
  108. terrine/simulate/generate.py +182 -0
  109. terrine/simulate/scale.py +234 -0
  110. terrine/simulate/world.py +75 -0
  111. terrine/spec/__init__.py +96 -0
  112. terrine/spec/anchors.py +107 -0
  113. terrine/spec/dataset.py +260 -0
  114. terrine/spec/design.py +361 -0
  115. terrine/spec/expressions.py +440 -0
  116. terrine/spec/io.py +829 -0
  117. terrine/spec/panels.py +359 -0
  118. terrine/spec/sequence.py +165 -0
  119. terrine/spec/state_panel.py +246 -0
  120. terrine/spec/trajectory.py +144 -0
  121. terrine/spec/transforms.py +260 -0
  122. terrine/spec/validate.py +758 -0
  123. terrine/temporal/__init__.py +3 -0
  124. terrine/temporal/aggregate.py +688 -0
  125. terrine/temporal/attributes.py +389 -0
  126. terrine/temporal/daily.py +481 -0
  127. terrine/temporal/first.py +239 -0
  128. terrine/temporal/lookup.py +194 -0
  129. terrine/temporal/nonadditive.py +466 -0
  130. terrine/temporal/prefix.py +104 -0
  131. terrine/temporal/ranges.py +122 -0
  132. terrine/temporal/readiness.py +41 -0
  133. terrine/temporal/retention.py +225 -0
  134. terrine/temporal/sequence.py +900 -0
  135. terrine/temporal/sessionize.py +135 -0
  136. terrine/temporal/sketches.py +329 -0
  137. terrine/windows.py +54 -0
  138. terrine-0.1.0.dev0.dist-info/METADATA +164 -0
  139. terrine-0.1.0.dev0.dist-info/RECORD +144 -0
  140. terrine-0.1.0.dev0.dist-info/WHEEL +4 -0
  141. terrine-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  142. terrine-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
  143. terrine-0.1.0.dev0.dist-info/licenses/NOTICE +4 -0
  144. terrine-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.txt +219 -0
@@ -0,0 +1,143 @@
1
+ """Typed discovery records for the existing DatasetDefinition codec."""
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from typing import Annotated, Any, Literal
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, field_validator
8
+
9
+ from terrine._time import utc_instant
10
+ from terrine.agent.design_schema import design_schema
11
+ from terrine.dataset.splits import policy_from_record
12
+ from terrine.errors import thaw
13
+ from terrine.model import Model
14
+ from terrine.spec.anchors import FixedDate
15
+ from terrine.spec.dataset import INVALID_DEFINITION, DatasetDefinition
16
+ from terrine.spec.io import from_json
17
+
18
+
19
+ class ViewRecord(BaseModel):
20
+ model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
21
+ features: tuple[str, ...]
22
+
23
+ @field_validator("features")
24
+ @classmethod
25
+ def _features(cls, value: tuple[str, ...]) -> tuple[str, ...]:
26
+ if len(set(value)) != len(value) or any(not item for item in value):
27
+ raise ValueError("features must contain unique nonempty names")
28
+ return value
29
+
30
+
31
+ class GroupRecord(BaseModel):
32
+ model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
33
+ kind: Literal["group"]
34
+ group: str
35
+ train_fraction: str
36
+ validation_fraction: str
37
+ seed: int
38
+ codec: Literal["terrine.group.v1"]
39
+
40
+
41
+ class TimeRecord(BaseModel):
42
+ model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
43
+ kind: Literal["time"]
44
+ train_until: str
45
+ validation_until: str | None
46
+ embargo_microseconds: Annotated[int, Field(ge=0)]
47
+ groups: GroupRecord | None
48
+
49
+
50
+ class TrainingView(ViewRecord):
51
+ kind: Literal["training"]
52
+ labels: Annotated[tuple[str, ...], Field(min_length=1)]
53
+ incomplete_labels: Literal["drop", "keep"]
54
+ split: Annotated[TimeRecord | GroupRecord, Field(discriminator="kind")] | None
55
+
56
+ @field_validator("labels")
57
+ @classmethod
58
+ def _labels(cls, value: tuple[str, ...]) -> tuple[str, ...]:
59
+ return cls._features(value)
60
+
61
+
62
+ class ScoringView(ViewRecord):
63
+ kind: Literal["scoring"]
64
+ as_of: str
65
+ eligibility: dict[str, Any]
66
+ parent_design_hash: Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")]
67
+
68
+ @field_validator("as_of")
69
+ @classmethod
70
+ def _instant(cls, value: str) -> str:
71
+ utc_instant(value)
72
+ return value
73
+
74
+
75
+ _VIEW = TypeAdapter(Annotated[TrainingView | ScoringView, Field(discriminator="kind")])
76
+
77
+
78
+ def parse_definition(serialized: str, model: Model) -> DatasetDefinition:
79
+ definition = DatasetDefinition.from_json(serialized)
80
+ if definition.view is not None:
81
+ try:
82
+ _VIEW.validate_json(json.dumps(thaw(definition.view)))
83
+ if definition.view["kind"] == "training" and definition.view["split"] is not None:
84
+ policy_from_record(thaw(definition.view["split"]))
85
+ except (ValueError, OverflowError) as error:
86
+ raise INVALID_DEFINITION.error(
87
+ reason="use the discovered typed training or scoring view"
88
+ ) from error
89
+ if definition.view["kind"] == "scoring":
90
+ design = definition.base_design
91
+ if (
92
+ design.shape != "cross_section"
93
+ or not isinstance(design.anchor, FixedDate)
94
+ or design.outcomes
95
+ or set(definition.view["features"]) != set(design.features)
96
+ ):
97
+ raise INVALID_DEFINITION.error(
98
+ reason="scoring view must match its fixed feature-only base Design"
99
+ )
100
+ anchor = design.anchor.date
101
+ resolved = (
102
+ utc_instant(anchor) if isinstance(anchor, datetime) else model.day_boundary(anchor)
103
+ )
104
+ if utc_instant(definition.view["as_of"]) != resolved:
105
+ raise INVALID_DEFINITION.error(
106
+ reason="scoring instant must match its base Design anchor"
107
+ )
108
+ document = json.loads(definition.canonical_json())["base_design"]
109
+ document["eligibility"] = thaw(definition.view["eligibility"])
110
+ try:
111
+ eligibility_design = from_json(
112
+ json.dumps({"schema_version": 1, "design": document})
113
+ )
114
+ except (KeyError, TypeError, ValueError, OverflowError) as error:
115
+ raise INVALID_DEFINITION.error(
116
+ reason="scoring eligibility must use typed predicates"
117
+ ) from error
118
+ if eligibility_design.eligibility != design.eligibility:
119
+ raise INVALID_DEFINITION.error(
120
+ reason="scoring eligibility must match its base Design"
121
+ )
122
+ return definition
123
+
124
+
125
+ def definition_schema() -> dict[str, Any]:
126
+ design = design_schema()
127
+ view = _VIEW.json_schema()
128
+ definitions = {**design["$defs"], **view.pop("$defs", {})}
129
+ definitions["ScoringView"]["properties"]["eligibility"] = definitions["Design"]["properties"][
130
+ "eligibility"
131
+ ]
132
+ return {
133
+ "$schema": design["$schema"],
134
+ "$defs": definitions,
135
+ "type": "object",
136
+ "additionalProperties": False,
137
+ "required": ["schema_version", "base_design", "view"],
138
+ "properties": {
139
+ "schema_version": {"const": 1},
140
+ "base_design": {"$ref": "#/$defs/Design"},
141
+ "view": {"anyOf": [view, {"type": "null"}]},
142
+ },
143
+ }
@@ -0,0 +1,220 @@
1
+ """Discovery schema for the existing versioned Design codec."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ def design_schema() -> dict[str, Any]:
7
+ def ref(name):
8
+ return {"$ref": f"#/$defs/{name}"}
9
+
10
+ def union(*names):
11
+ return {"anyOf": [ref(name) for name in names]}
12
+
13
+ def nullable(schema):
14
+ return {"anyOf": [schema, {"type": "null"}]}
15
+
16
+ def array(schema):
17
+ return {"type": "array", "items": schema}
18
+
19
+ def mapping(schema):
20
+ return {"type": "object", "additionalProperties": schema}
21
+
22
+ def obj(properties, required=None):
23
+ return {
24
+ "type": "object",
25
+ "properties": properties,
26
+ "required": list(properties) if required is None else required,
27
+ "additionalProperties": False,
28
+ }
29
+
30
+ text = {"type": "string"}
31
+ number = {"type": "number"}
32
+ integer = {"type": "integer"}
33
+ boolean = {"type": "boolean"}
34
+ scalar = {"type": ["string", "number", "boolean", "null"]}
35
+ duration = {"type": ["integer", "string"]}
36
+ definitions = {"Temporal": obj({"type": {"enum": ["date", "datetime"]}, "value": text})}
37
+ definitions["Cutpoint"] = {
38
+ "anyOf": [
39
+ obj({"type": {"enum": ["int", "float64"]}, "value": text}),
40
+ obj(
41
+ {
42
+ "type": {"const": "decimal"},
43
+ "sign": {"enum": [0, 1]},
44
+ "digits": array({"type": "integer", "minimum": 0, "maximum": 9}),
45
+ "exponent": integer,
46
+ }
47
+ ),
48
+ ]
49
+ }
50
+ expression = union("MetricAgg", "AttributeAsOf", "EventTime")
51
+ predicate = union("Gt", "Eq", "IsNull", "And")
52
+ named = mapping(expression)
53
+ outputs = mapping({"anyOf": [expression, predicate]})
54
+ nodes = {
55
+ "Window": {"start": number, "end": number},
56
+ "FixedDate": {"date": ref("Temporal")},
57
+ "EventAnchor": {
58
+ "fact": text,
59
+ "which": {"enum": ["first", "nth", "each"]},
60
+ "n": nullable(integer),
61
+ },
62
+ "Schedule": {"every": duration, "start": ref("Temporal"), "end": ref("Temporal")},
63
+ "AnchorRange": {"start": nullable(ref("Temporal")), "end": nullable(ref("Temporal"))},
64
+ "MetricAgg": {
65
+ "metric": text,
66
+ "window": ref("Window"),
67
+ "relative_to": nullable(ref("EventTime")),
68
+ },
69
+ "AttributeAsOf": {
70
+ "attribute": text,
71
+ "before": number,
72
+ "relative_to": nullable(ref("EventTime")),
73
+ },
74
+ "EventTime": {
75
+ "fact": text,
76
+ "window": ref("Window"),
77
+ "label_property": nullable(text),
78
+ "equals": scalar,
79
+ "occurrence": {"enum": ["first", "last", "nth"]},
80
+ "n": nullable(integer),
81
+ "relative_to": nullable(ref("EventTime")),
82
+ },
83
+ "Gt": {"expr": expression, "value": scalar},
84
+ "Eq": {"left": expression, "value": scalar},
85
+ "IsNull": {"expr": expression, "negate": boolean},
86
+ "And": {"terms": array(predicate)},
87
+ "Treatment": {"attribute": text, "at": number, "known_at_t0": boolean},
88
+ "StateObservations": {
89
+ "property": text,
90
+ "levels": array(scalar),
91
+ "ordered": boolean,
92
+ "absorbing": array(scalar),
93
+ },
94
+ "BinnedMetricState": {
95
+ "metric": text,
96
+ "cutpoints": array(ref("Cutpoint")),
97
+ "levels": array(scalar),
98
+ },
99
+ "StatePanel": {
100
+ "state": union("StateObservations", "BinnedMetricState"),
101
+ "max_periods": integer,
102
+ "baseline_features": named,
103
+ },
104
+ "Trajectory": {
105
+ "metric": text,
106
+ "period": duration,
107
+ "periods": integer,
108
+ "cumulative": boolean,
109
+ },
110
+ "CohortSpec": {"origin": text, "trajectory": ref("Trajectory")},
111
+ "TimeToEvent": {
112
+ "event": text,
113
+ "exit": nullable(text),
114
+ "entry": nullable(text),
115
+ "delayed_entry": boolean,
116
+ },
117
+ "LifeTable": {
118
+ "time_to_event": ref("TimeToEvent"),
119
+ "interval": duration,
120
+ "group_by": array(text),
121
+ },
122
+ "SessionSpec": {"gap_seconds": number},
123
+ "FunnelSpec": {
124
+ "event_property": text,
125
+ "steps": array(text),
126
+ "within_seconds": nullable(duration),
127
+ },
128
+ "EventSequence": {
129
+ "fact": text,
130
+ "window": ref("Window"),
131
+ "max_events": integer,
132
+ "representation": text,
133
+ "properties": array(text),
134
+ "label_property": nullable(text),
135
+ "sessions": nullable(ref("SessionSpec")),
136
+ "funnel": nullable(ref("FunnelSpec")),
137
+ },
138
+ "Initiation": {"source": text, "property": text, "history_start": text, "knowledge": text},
139
+ "PersistentAdoption": {
140
+ "source": text,
141
+ "property": text,
142
+ "adopted_value": scalar,
143
+ "history_start": text,
144
+ "persistence": text,
145
+ "knowledge": text,
146
+ },
147
+ "MatchedT0Sample": {
148
+ "strata": array(text),
149
+ "controls_per_treated": integer,
150
+ "seed": integer,
151
+ },
152
+ "TrialWeight": {"kind": text, "values": nullable(mapping(number))},
153
+ "SequentialSpec": {
154
+ "treatment": union("Initiation", "PersistentAdoption"),
155
+ "trial_weight": nullable(ref("TrialWeight")),
156
+ "anticipation_seconds": number,
157
+ "reference_period_seconds": number,
158
+ },
159
+ "EventPanelSpec": {
160
+ "periods": array(integer),
161
+ "period_seconds": number,
162
+ "reference_period": integer,
163
+ },
164
+ "CalendarComparisonSpec": {
165
+ "cohort": ref("Initiation"),
166
+ "control": text,
167
+ "reference_period": integer,
168
+ "comparison_periods": array(integer),
169
+ "anticipation_periods": array(integer),
170
+ },
171
+ }
172
+ optional = {
173
+ "EventAnchor": {"n"},
174
+ "MetricAgg": {"relative_to"},
175
+ "AttributeAsOf": {"relative_to"},
176
+ "EventTime": {"relative_to"},
177
+ "IsNull": {"negate"},
178
+ "Treatment": {"at"},
179
+ "StateObservations": {"ordered", "absorbing"},
180
+ }
181
+ for name, fields in nodes.items():
182
+ properties = {"type": {"const": name}, **fields}
183
+ definitions[name] = obj(
184
+ properties, [key for key in properties if key not in optional.get(name, set())]
185
+ )
186
+ definitions["Design"] = obj(
187
+ {
188
+ "unit": text,
189
+ "anchor": union("FixedDate", "EventAnchor", "Schedule"),
190
+ "features": outputs,
191
+ "outcomes": outputs,
192
+ "eligibility": mapping(predicate),
193
+ "treatment": nullable(ref("Treatment")),
194
+ "gap": number,
195
+ "shape": {
196
+ "anyOf": [
197
+ text,
198
+ union(
199
+ "StatePanel",
200
+ "CohortSpec",
201
+ "TimeToEvent",
202
+ "LifeTable",
203
+ "EventSequence",
204
+ "SequentialSpec",
205
+ "EventPanelSpec",
206
+ "CalendarComparisonSpec",
207
+ ),
208
+ ]
209
+ },
210
+ "anchor_range": nullable(ref("AnchorRange")),
211
+ "sample": nullable(ref("MatchedT0Sample")),
212
+ "administrative_cutoff": nullable(ref("Temporal")),
213
+ },
214
+ ["unit", "anchor"],
215
+ )
216
+ return {
217
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
218
+ "$defs": definitions,
219
+ **obj({"schema_version": {"const": 1}, "design": ref("Design")}),
220
+ }
@@ -0,0 +1,144 @@
1
+ """Owner-only grants and resource limits for dataset operations."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Literal
6
+
7
+ from terrine.data import Budget
8
+ from terrine.errors import RefusalSpec
9
+ from terrine.runtime.api import Executor
10
+ from terrine.runtime.saved_plan import DestinationRef, ModelReference, ModelResolver
11
+ from terrine.runtime.snapshot import SnapshotAuthority
12
+
13
+ __all__ = ["AgentPolicy"]
14
+
15
+ type Permission = Literal[
16
+ "inspect", "validate", "prepare", "write", "export", "preview", "read", "cancel"
17
+ ]
18
+
19
+ _DENIED = RefusalSpec("terrine.agent.permission_denied", "Owner policy denies this operation.")
20
+ _SCOPE = RefusalSpec("terrine.agent.scope_denied", "Requested resources are outside owner policy.")
21
+ _LIMIT = RefusalSpec("terrine.agent.request_limit", "Owner tool request limit has been reached.")
22
+
23
+
24
+ def effective_budget(owner: Budget, requested: Budget | None) -> Budget:
25
+ """Intersect optional nonnegative limits; None denotes no numeric bound."""
26
+ if not isinstance(owner, Budget) or (
27
+ requested is not None and not isinstance(requested, Budget)
28
+ ):
29
+ raise TypeError("budgets must be Budget values")
30
+ if requested is None:
31
+ return owner
32
+ values = {}
33
+ for name in ("max_rows", "max_output_bytes", "max_scan_bytes"):
34
+ limits = [
35
+ value for value in (getattr(owner, name), getattr(requested, name)) if value is not None
36
+ ]
37
+ values[name] = min(limits) if limits else None
38
+ return Budget(**values, strict_scan=owner.strict_scan or requested.strict_scan)
39
+
40
+
41
+ @dataclass(frozen=True, slots=True, kw_only=True)
42
+ class AgentPolicy:
43
+ """Trusted configuration, never deserialized from a tool request.
44
+
45
+ Source identities are Catalog source names, destinations are exact owner
46
+ references, and catalogs use the owner ModelReference's catalog hash.
47
+ """
48
+
49
+ inspect: bool = True
50
+ validate: bool = True
51
+ prepare: bool = False
52
+ write: bool = False
53
+ export: bool = False
54
+ preview: bool = False
55
+ read: bool = False
56
+ cancel: bool = False
57
+ allowed_sources: frozenset[str] = frozenset()
58
+ allowed_catalogs: frozenset[str] = frozenset()
59
+ allowed_destinations: frozenset[DestinationRef] = frozenset()
60
+ allowed_exports: frozenset[Path] = frozenset()
61
+ design_root: Path | None = None
62
+ budget: Budget = field(default_factory=Budget)
63
+ max_run_seconds: int = 300
64
+ max_concurrency: int = 1
65
+ max_requests: int = 100
66
+ max_preview_rows: int = 20
67
+ max_bounded_count: int = 1000
68
+ batch_cap: int = 100
69
+ preview_columns: frozenset[str] = frozenset()
70
+ redacted_columns: frozenset[str] = frozenset()
71
+ model_reference: ModelReference | None = None
72
+ resolver: ModelResolver | None = field(default=None, repr=False, compare=False)
73
+ snapshot_authority: SnapshotAuthority | None = field(default=None, repr=False, compare=False)
74
+ executor: Executor | None = field(default=None, repr=False, compare=False)
75
+
76
+ def __post_init__(self) -> None:
77
+ for name in (
78
+ "inspect",
79
+ "validate",
80
+ "prepare",
81
+ "write",
82
+ "export",
83
+ "preview",
84
+ "read",
85
+ "cancel",
86
+ ):
87
+ if not isinstance(getattr(self, name), bool):
88
+ raise TypeError(f"{name} must be bool")
89
+ for name in (
90
+ "max_run_seconds",
91
+ "max_concurrency",
92
+ "max_requests",
93
+ "max_preview_rows",
94
+ "max_bounded_count",
95
+ "batch_cap",
96
+ ):
97
+ value = getattr(self, name)
98
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
99
+ raise ValueError(f"{name} must be a positive integer")
100
+ for name in ("allowed_sources", "allowed_catalogs", "preview_columns", "redacted_columns"):
101
+ value = frozenset(getattr(self, name))
102
+ if any(not isinstance(item, str) or not item for item in value):
103
+ raise TypeError(f"{name} must contain nonempty strings")
104
+ object.__setattr__(self, name, value)
105
+ destinations = frozenset(self.allowed_destinations)
106
+ if any(not isinstance(item, DestinationRef) for item in destinations):
107
+ raise TypeError("allowed_destinations must contain DestinationRef values")
108
+ object.__setattr__(self, "allowed_destinations", destinations)
109
+ exports = frozenset(Path(path).expanduser().resolve() for path in self.allowed_exports)
110
+ object.__setattr__(self, "allowed_exports", exports)
111
+ if not isinstance(self.budget, Budget):
112
+ raise TypeError("budget must be Budget")
113
+ if self.design_root is not None:
114
+ object.__setattr__(self, "design_root", Path(self.design_root).resolve())
115
+
116
+ def authorize(
117
+ self,
118
+ permission: Permission,
119
+ *,
120
+ sources: tuple[str, ...] = (),
121
+ catalog: str | None = None,
122
+ destination: DestinationRef | None = None,
123
+ ) -> None:
124
+ if permission not in {
125
+ "inspect",
126
+ "validate",
127
+ "prepare",
128
+ "write",
129
+ "export",
130
+ "preview",
131
+ "read",
132
+ "cancel",
133
+ } or not getattr(self, permission):
134
+ raise _DENIED.error(
135
+ operation=permission, route="Ask the owner to grant this operation."
136
+ )
137
+ if (
138
+ not set(sources) <= self.allowed_sources
139
+ or (catalog is not None and catalog not in self.allowed_catalogs)
140
+ or (destination is not None and destination not in self.allowed_destinations)
141
+ ):
142
+ raise _SCOPE.error(
143
+ operation=permission, route="Use owner-approved sources and destinations."
144
+ )