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 +194 -0
- stevin/__main__.py +6 -0
- stevin/adopt.py +398 -0
- stevin/advice.py +162 -0
- stevin/api.py +477 -0
- stevin/bundle.py +711 -0
- stevin/cli.py +1529 -0
- stevin/connect.py +201 -0
- stevin/ddl.py +231 -0
- stevin/differ.py +936 -0
- stevin/doctor.py +282 -0
- stevin/errors.py +21 -0
- stevin/executor.py +471 -0
- stevin/features.py +346 -0
- stevin/formerly.py +54 -0
- stevin/history.py +378 -0
- stevin/introspect.py +1279 -0
- stevin/loader.py +2328 -0
- stevin/manage.py +137 -0
- stevin/model/__init__.py +5 -0
- stevin/model/change.py +145 -0
- stevin/model/function.py +54 -0
- stevin/model/plan.py +299 -0
- stevin/model/schema.py +51 -0
- stevin/model/table.py +413 -0
- stevin/model/types.py +373 -0
- stevin/model/view.py +57 -0
- stevin/model/volume.py +44 -0
- stevin/planner.py +2286 -0
- stevin/planning.py +618 -0
- stevin/probes.py +890 -0
- stevin/py.typed +0 -0
- stevin/render/__init__.py +4 -0
- stevin/render/compare.py +565 -0
- stevin/render/html.py +346 -0
- stevin/render/json.py +561 -0
- stevin/render/labels.py +240 -0
- stevin/render/markdown.py +394 -0
- stevin/render/rich.py +272 -0
- stevin/serve.py +44 -0
- stevin/spec_schema.py +476 -0
- stevin/sql.py +243 -0
- stevin/sqlspec.py +712 -0
- stevin/typeparser.py +297 -0
- stevin/yamledit.py +392 -0
- stevin-0.3.0a1.dist-info/METADATA +238 -0
- stevin-0.3.0a1.dist-info/RECORD +50 -0
- stevin-0.3.0a1.dist-info/WHEEL +4 -0
- stevin-0.3.0a1.dist-info/entry_points.txt +3 -0
- stevin-0.3.0a1.dist-info/licenses/LICENSE +202 -0
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
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]}…"
|