stevin 0.3.0a1__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.
stevin/__init__.py ADDED
@@ -0,0 +1,194 @@
1
+ """stevin: safe plan/apply migrations for Unity Catalog tables and schemas.
2
+
3
+ Two ways in, the same code underneath. The command line is the one most people
4
+ meet:
5
+
6
+ ```sh
7
+ stevin plan
8
+ stevin apply
9
+ ```
10
+
11
+ And this package is the other, for a program that runs stevin as part of
12
+ something larger — a deployment task that plans, shows the plan its own way,
13
+ and applies it:
14
+
15
+ ```python
16
+ import stevin
17
+
18
+ project = stevin.Project.find()
19
+ target = project.resolve(project.target("prod"))
20
+ ```
21
+
22
+ Everything a host needs is exported here, and only what is exported here is
23
+ meant to be relied on. Names from `stevin.<module>` that this list doesn't
24
+ mention are the implementation, and move without notice.
25
+
26
+ Errors all descend from `StevinError`, so one `except` reports any failure
27
+ and a subclass reacts to a particular one. Rendering a plan is separate from
28
+ making one: `render_plan` for a terminal, `render_markdown` for a pull request,
29
+ `plan_to_json` for a file or a queue.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from stevin.adopt import Adoption, CannotAdopt
35
+ from stevin.api import (
36
+ ImportedSchema,
37
+ ImportedSpec,
38
+ adopt,
39
+ apply,
40
+ drift,
41
+ history_for,
42
+ import_schema,
43
+ is_stale,
44
+ plan,
45
+ validate,
46
+ verify,
47
+ )
48
+ from stevin.bundle import Bundle, BundleError, BundleTarget, find_cli
49
+ from stevin.connect import Connection, NotConnected
50
+ from stevin.errors import StevinError
51
+ from stevin.executor import (
52
+ DestructiveRefused,
53
+ ExecutionError,
54
+ ExecutionResult,
55
+ Executor,
56
+ StalePlan,
57
+ )
58
+ from stevin.history import DeltaHistory, HistoryStore, MemoryHistory, NoHistory
59
+ from stevin.introspect import IntrospectionError, Introspector, WarehouseRunner
60
+ from stevin.loader import (
61
+ Diagnostic,
62
+ LoadedSpec,
63
+ Project,
64
+ SpecError,
65
+ SpecErrors,
66
+ Specs,
67
+ Target,
68
+ dump_spec,
69
+ load_project,
70
+ load_spec,
71
+ load_specs,
72
+ spec_files,
73
+ validate_spec,
74
+ )
75
+ from stevin.manage import MANAGEABLE, Manage
76
+ from stevin.model.function import Function
77
+ from stevin.model.plan import Plan, Risk, Step, Summary, TableDiff, TableFacts
78
+ from stevin.model.schema import Schema
79
+ from stevin.model.table import (
80
+ Check,
81
+ ForeignKey,
82
+ Grant,
83
+ PrimaryKey,
84
+ RowFilter,
85
+ Table,
86
+ )
87
+ from stevin.model.types import Column, Field, Mask
88
+ from stevin.model.view import Relation, View
89
+ from stevin.model.volume import Volume
90
+ from stevin.planning import PlanningError, plan_tables
91
+ from stevin.probes import PROBES, Bench, Disagrees, Probe, Result
92
+ from stevin.render.html import render_html
93
+ from stevin.render.json import PlanFileError
94
+ from stevin.render.json import dumps as plan_to_json
95
+ from stevin.render.json import loads as plan_from_json
96
+ from stevin.render.markdown import render_markdown
97
+ from stevin.render.rich import plan_text, render_plan
98
+
99
+ __all__ = [
100
+ "__version__",
101
+ "adopt",
102
+ "Adoption",
103
+ "apply",
104
+ "Bench",
105
+ "Bundle",
106
+ "BundleError",
107
+ "BundleTarget",
108
+ "CannotAdopt",
109
+ "Check",
110
+ "Column",
111
+ "Connection",
112
+ "DeltaHistory",
113
+ "DestructiveRefused",
114
+ "Diagnostic",
115
+ "Disagrees",
116
+ "drift",
117
+ "dump_spec",
118
+ "ExecutionError",
119
+ "ExecutionResult",
120
+ "Executor",
121
+ "Field",
122
+ "find_cli",
123
+ "ForeignKey",
124
+ "Function",
125
+ "Grant",
126
+ "history_for",
127
+ "HistoryStore",
128
+ "import_schema",
129
+ "ImportedSchema",
130
+ "ImportedSpec",
131
+ "IntrospectionError",
132
+ "Introspector",
133
+ "is_stale",
134
+ "load_project",
135
+ "load_spec",
136
+ "load_specs",
137
+ "LoadedSpec",
138
+ "Manage",
139
+ "MANAGEABLE",
140
+ "Mask",
141
+ "MemoryHistory",
142
+ "NoHistory",
143
+ "NotConnected",
144
+ "Plan",
145
+ "plan",
146
+ "plan_from_json",
147
+ "plan_tables",
148
+ "plan_text",
149
+ "plan_to_json",
150
+ "PlanFileError",
151
+ "PlanningError",
152
+ "PrimaryKey",
153
+ "Probe",
154
+ "PROBES",
155
+ "Project",
156
+ "Relation",
157
+ "render_html",
158
+ "render_markdown",
159
+ "render_plan",
160
+ "Result",
161
+ "Risk",
162
+ "RowFilter",
163
+ "Schema",
164
+ "spec_files",
165
+ "SpecError",
166
+ "SpecErrors",
167
+ "Specs",
168
+ "StalePlan",
169
+ "Step",
170
+ "StevinError",
171
+ "Summary",
172
+ "Table",
173
+ "TableDiff",
174
+ "TableFacts",
175
+ "Target",
176
+ "validate",
177
+ "validate_spec",
178
+ "verify",
179
+ "View",
180
+ "Volume",
181
+ "WarehouseRunner",
182
+ ]
183
+
184
+
185
+ def __getattr__(name: str) -> str:
186
+ """`stevin.__version__`, read from the installed distribution."""
187
+ if name == "__version__":
188
+ from importlib.metadata import PackageNotFoundError, version
189
+
190
+ try:
191
+ return version("stevin")
192
+ except PackageNotFoundError: # pragma: no cover - running from a checkout
193
+ return "0.0.0"
194
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
stevin/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """`python -m stevin` is `stevin`."""
2
+
3
+ from stevin.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
stevin/adopt.py ADDED
@@ -0,0 +1,398 @@
1
+ """Drift, back into the spec.
2
+
3
+ `drift` says a table has been changed by hand. That change is usually *wanted* —
4
+ someone added a column at 2am to unblock a load — and the only two ways out were
5
+ to retype it into the spec, or to apply the plan and undo their work. This is the
6
+ third: write the live shape into the spec file that already describes it, and
7
+ leave a git diff for someone to read.
8
+
9
+ Three rules decide what a file gets:
10
+
11
+ - **What stevin would otherwise have planned comes from the workspace**: a
12
+ column, a type, a nested field, `not null`, a comment, clustering, a view's
13
+ query, a function's body.
14
+ - **What the spec never claimed is left alone.** A tag, property or grant the
15
+ file doesn't mention stays unmanaged, exactly as before — adopting drift is not
16
+ the moment to start managing something new. A declared one takes the live
17
+ value; one that is declared and no longer live stops being declared.
18
+ - **What only a file can say survives**: `${catalog}`, a `renamed_from` hint, a
19
+ `using:` expression, a seed's rows, hooks — and every comment and blank line
20
+ around them, because the file is *edited* rather than rewritten (`yamledit`).
21
+
22
+ It then reads its own work back and diffs that against live, so anything it
23
+ couldn't express is reported here rather than discovered by the next plan.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from collections.abc import Mapping, Sequence
29
+ from dataclasses import dataclass, replace
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from stevin.differ import diff, diff_function, diff_schema, diff_view, diff_volume
34
+ from stevin.errors import StevinError
35
+ from stevin.loader import (
36
+ LoadedSpec,
37
+ SpecError,
38
+ load_spec_text,
39
+ spec_document,
40
+ substitute,
41
+ )
42
+ from stevin.manage import EVERYTHING, Manage
43
+ from stevin.model.change import Change
44
+ from stevin.model.function import Function
45
+ from stevin.model.schema import Schema
46
+ from stevin.model.table import Grant, Table
47
+ from stevin.model.types import Column
48
+ from stevin.model.view import Relation, View
49
+ from stevin.model.volume import Volume
50
+ from stevin.yamledit import merge_into
51
+
52
+
53
+ class CannotAdopt(StevinError):
54
+ """This spec can't be rewritten from live state, and why."""
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class Adoption:
59
+ """What adopting one spec does to its file."""
60
+
61
+ path: Path
62
+ name: str
63
+ before: str
64
+ after: str
65
+ #: What changed, one line each: `+ columns: region string`.
66
+ notes: tuple[str, ...] = ()
67
+ #: Changes a plan would still have after this — a seed's rows, say, which
68
+ #: the workspace can't tell a file.
69
+ remaining: tuple[str, ...] = ()
70
+
71
+ @property
72
+ def changed(self) -> bool:
73
+ return self.after != self.before
74
+
75
+ def write(self) -> None:
76
+ """Write the file. Nothing else here touches the disk."""
77
+ self.path.write_text(self.after, encoding="utf-8")
78
+
79
+
80
+ def adopt(
81
+ spec: LoadedSpec,
82
+ live: Relation,
83
+ *,
84
+ variables: Mapping[str, str] | None = None,
85
+ unresolved: Mapping[str, str] | None = None,
86
+ manage: Manage = EVERYTHING,
87
+ ) -> Adoption:
88
+ """The spec file this relation's live state would be written into.
89
+
90
+ Nothing is written: the result carries the new text, what changed, and what
91
+ a plan would still say afterwards.
92
+
93
+ Raises `CannotAdopt` for a spec no file edit can express — a `.sql` spec, or
94
+ a live object of another kind than the spec describes.
95
+ """
96
+ path = spec.path
97
+ if path.suffix.lower() == ".sql":
98
+ raise CannotAdopt(
99
+ f"{path} is a SQL spec, and rewriting a CREATE statement from live "
100
+ "state is not something to do by text search. Edit it, or import the "
101
+ "object again as YAML."
102
+ )
103
+ if type(spec.table) is not type(live):
104
+ raise CannotAdopt(
105
+ f"{spec.table.name} is a {_kind(spec.table)} in {path} and a "
106
+ f"{_kind(live)} in the workspace"
107
+ )
108
+ try:
109
+ source = path.read_text(encoding="utf-8")
110
+ except OSError as error:
111
+ raise CannotAdopt(f"cannot read {path}: {error}") from error
112
+
113
+ adopted = _adopted(spec.table, live)
114
+ wanted = spec_document(adopted, manage=manage)
115
+ rendered = _renderer(variables, unresolved)
116
+ try:
117
+ after = merge_into(source, wanted, rendered=rendered)
118
+ except ValueError as error:
119
+ raise CannotAdopt(f"{path}: {error}") from error
120
+ return Adoption(
121
+ path=path,
122
+ name=spec.table.name,
123
+ before=source,
124
+ after=after,
125
+ notes=_notes(spec_document(spec.table, manage=manage), wanted),
126
+ remaining=_remaining(after, path, live, variables, unresolved, manage),
127
+ )
128
+
129
+
130
+ def _renderer(variables: Mapping[str, str] | None, unresolved: Mapping[str, str] | None):
131
+ """How the file's text reads once its variables are resolved.
132
+
133
+ A name the reader can't resolve is left as it stands rather than raised: the
134
+ comparison then simply says the two differ, and a spec that can't be read at
135
+ all is `validate`'s business, not this one's.
136
+ """
137
+
138
+ def render(text: str) -> str:
139
+ try:
140
+ return substitute(text, variables or {}, unresolved)
141
+ except KeyError:
142
+ return text
143
+
144
+ return render
145
+
146
+
147
+ def _remaining(
148
+ text: str,
149
+ path: Path,
150
+ live: Relation,
151
+ variables: Mapping[str, str] | None,
152
+ unresolved: Mapping[str, str] | None,
153
+ manage: Manage,
154
+ ) -> tuple[str, ...]:
155
+ """What a plan would still say about this object after the file is written.
156
+
157
+ Adopt reads its own work back rather than trusting it. A seed is the usual
158
+ answer: its rows live in the repo, and no workspace can tell a file what
159
+ they should be.
160
+ """
161
+ try:
162
+ written = load_spec_text(text, path, variables, unresolved, manage)
163
+ except SpecError as error:
164
+ raise CannotAdopt(f"the adopted spec wouldn't read back: {error}") from error
165
+ if type(written) is not type(live): # pragma: no cover - the kind is kept
166
+ raise CannotAdopt("the adopted spec changed kind")
167
+ return tuple(
168
+ change.kind
169
+ for change in _changes(written, live)
170
+ # An ownership claim is something `apply` does, not something a file
171
+ # says: reporting it here would make every unclaimed table look unfixable.
172
+ if change.kind != "claim_table"
173
+ )
174
+
175
+
176
+ def _changes(written: Relation, live: Relation) -> tuple[Change, ...]:
177
+ match written, live:
178
+ case Table(), Table():
179
+ return diff(written, live)
180
+ case View(), View():
181
+ return diff_view(written, live)
182
+ case Function(), Function():
183
+ return diff_function(written, live)
184
+ case Schema(), Schema():
185
+ return diff_schema(written, live)
186
+ case Volume(), Volume():
187
+ return diff_volume(written, live)
188
+ case _: # pragma: no cover - guarded by the caller
189
+ return ()
190
+
191
+
192
+ def _kind(relation: Relation) -> str:
193
+ match relation:
194
+ case Table():
195
+ return "table"
196
+ case View():
197
+ return "view"
198
+ case Function():
199
+ return "function"
200
+ case Schema():
201
+ return "schema"
202
+ case Volume():
203
+ return "volume"
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # what the spec becomes
208
+ # ---------------------------------------------------------------------------
209
+
210
+
211
+ def _adopted(spec: Relation, live: Relation) -> Relation:
212
+ """Live state, minus everything this spec never claimed."""
213
+ match spec, live:
214
+ case Table(), Table():
215
+ return _adopted_table(spec, live)
216
+ case View(), View():
217
+ return replace(
218
+ _claimed(spec, live),
219
+ query=live.query,
220
+ comment=live.comment,
221
+ )
222
+ case Function(), Function():
223
+ return replace(
224
+ _claimed(spec, live),
225
+ body=live.body,
226
+ returns=live.returns,
227
+ parameters=live.parameters,
228
+ comment=live.comment,
229
+ )
230
+ case Schema() | Volume(), _:
231
+ # Both are additive in the differ: a comment the spec doesn't
232
+ # declare is never removed, so it is never adopted either.
233
+ return replace(
234
+ _claimed(spec, live),
235
+ comment=live.comment if spec.comment is not None else None,
236
+ )
237
+ case _: # pragma: no cover - guarded by the caller
238
+ return live
239
+
240
+
241
+ def _adopted_table(spec: Table, live: Table) -> Table:
242
+ return replace(
243
+ _claimed(spec, live),
244
+ comment=live.comment,
245
+ columns=tuple(_adopted_column(spec.column(c.name), c) for c in live.columns),
246
+ cluster_by=live.cluster_by,
247
+ cluster_auto=live.cluster_auto,
248
+ partitioned_by=live.partitioned_by,
249
+ constraints=live.constraints if spec.constraints else (),
250
+ row_filter=live.row_filter if spec.row_filter is not None else None,
251
+ # Only a file knows these, and the workspace can't be asked.
252
+ seed=spec.seed,
253
+ hooks=spec.hooks,
254
+ renamed_from=spec.renamed_from,
255
+ )
256
+
257
+
258
+ def _adopted_column(spec: Column | None, live: Column) -> Column:
259
+ """One live column, as the spec would write it.
260
+
261
+ A column the spec hasn't got is taken whole — that is the drift being
262
+ adopted. One it has keeps its hints, and its tags and mask stay as the file
263
+ left them unless it declared them.
264
+ """
265
+ if spec is None:
266
+ return live
267
+ return replace(
268
+ live,
269
+ tags=_kept(dict(spec.tags), spec.removed_tags, dict(live.tags)),
270
+ removed_tags=_still_gone(spec.removed_tags, dict(live.tags)),
271
+ mask=live.mask if spec.mask is not None else None,
272
+ renamed_from=spec.renamed_from,
273
+ using=spec.using,
274
+ )
275
+
276
+
277
+ def _claimed(spec: Relation, live: Relation) -> Any:
278
+ """Live, with the tags, properties, grants and owner the spec claims.
279
+
280
+ Everything else a live object carries is unmanaged, and reported as such by
281
+ every plan; adopting drift doesn't change who manages what.
282
+ """
283
+ return replace(
284
+ live,
285
+ tags=_kept(spec.tags_map(), spec.removed_tags, live.tags_map()),
286
+ removed_tags=_still_gone(spec.removed_tags, live.tags_map()),
287
+ properties=_kept(
288
+ spec.properties_map(), spec.removed_properties, live.properties_map()
289
+ ),
290
+ removed_properties=_still_gone(spec.removed_properties, live.properties_map()),
291
+ grants=_kept_grants(spec.grants, live.grants),
292
+ owner=live.owner if spec.owner is not None else None,
293
+ )
294
+
295
+
296
+ def _kept(
297
+ declared: Mapping[str, str], removed: Sequence[str], found: Mapping[str, str]
298
+ ) -> tuple[tuple[str, str], ...]:
299
+ """The live values of the keys this spec declares, and no others."""
300
+ return tuple(
301
+ sorted(
302
+ (key, value)
303
+ for key, value in found.items()
304
+ if key in declared or key in removed
305
+ )
306
+ )
307
+
308
+
309
+ def _still_gone(removed: Sequence[str], found: Mapping[str, str]) -> tuple[str, ...]:
310
+ """`tags: {pii: null}` stays only while the tag really is gone: one that is
311
+ back is now the live value, and the file says so."""
312
+ return tuple(key for key in removed if key not in found)
313
+
314
+
315
+ def _kept_grants(declared: Sequence[Grant], found: Sequence[Grant]) -> tuple[Grant, ...]:
316
+ """The live privileges of the principals this spec names."""
317
+ named = {grant.principal.casefold() for grant in declared}
318
+ return tuple(grant for grant in found if grant.principal.casefold() in named)
319
+
320
+
321
+ # ---------------------------------------------------------------------------
322
+ # what changed
323
+ # ---------------------------------------------------------------------------
324
+
325
+
326
+ def _notes(before: Mapping[str, object], after: Mapping[str, object]) -> tuple[str, ...]:
327
+ """One line per difference between two spec documents, for a person to read."""
328
+ lines: list[str] = []
329
+ _note_mapping(before, after, "", lines)
330
+ return tuple(lines)
331
+
332
+
333
+ def _note_mapping(
334
+ before: Mapping[str, object],
335
+ after: Mapping[str, object],
336
+ where: str,
337
+ lines: list[str],
338
+ ) -> None:
339
+ for key, value in after.items():
340
+ at = f"{where}{key}"
341
+ if key not in before:
342
+ lines.append(f"+ {at}: {_short(value)}")
343
+ elif before[key] != value:
344
+ _note_value(before[key], value, at, lines)
345
+ for key in before:
346
+ if key not in after:
347
+ lines.append(f"- {where}{key}")
348
+
349
+
350
+ def _note_value(before: object, after: object, at: str, lines: list[str]) -> None:
351
+ if isinstance(before, Mapping) and isinstance(after, Mapping):
352
+ _note_mapping(before, after, f"{at}.", lines)
353
+ return
354
+ if _named(before) is not None and (now := _named(after)) is not None:
355
+ _note_items(_named(before) or [], now, at, lines)
356
+ return
357
+ lines.append(f"~ {at}: {_short(before)} → {_short(after)}")
358
+
359
+
360
+ def _note_items(
361
+ before: Sequence[Mapping[str, object]],
362
+ after: Sequence[Mapping[str, object]],
363
+ at: str,
364
+ lines: list[str],
365
+ ) -> None:
366
+ was = {str(item["name"]): item for item in before}
367
+ now = {str(item["name"]): item for item in after}
368
+ for name, item in now.items():
369
+ if name not in was:
370
+ lines.append(f"+ {at}: {name} {_short(item.get('type', ''))}".rstrip())
371
+ elif was[name] != item:
372
+ _note_mapping(was[name], item, f"{at}.{name}.", lines)
373
+ for name in was:
374
+ if name not in now:
375
+ lines.append(f"- {at}: {name}")
376
+
377
+
378
+ def _named(value: object) -> list[Mapping[str, object]] | None:
379
+ """A list of things with names — columns, parameters — or None."""
380
+ if isinstance(value, Sequence) and not isinstance(value, str) and value:
381
+ items = [item for item in value if isinstance(item, Mapping) and "name" in item]
382
+ if len(items) == len(value):
383
+ return items
384
+ return None
385
+
386
+
387
+ def _short(value: object) -> str:
388
+ """A value as one readable piece, cut off before it fills a terminal."""
389
+ if isinstance(value, Mapping):
390
+ text = ", ".join(f"{key}: {_short(one)}" for key, one in value.items())
391
+ elif isinstance(value, Sequence) and not isinstance(value, str):
392
+ text = ", ".join(_short(one) for one in value)
393
+ elif value is None:
394
+ text = "nothing"
395
+ else:
396
+ text = str(value)
397
+ flat = " ".join(text.split())
398
+ return flat if len(flat) <= 60 else f"{flat[:57]}…"