lely 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lely/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """One plan for your whole Databricks deploy.
2
+
3
+ The bundle and everything around it — the steps before, the steps after —
4
+ reviewed before anything runs, and taken down again when you say so. `spec/`
5
+ says what each piece must deliver; `docs/DESIGN.md` says how it is built.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from importlib.metadata import PackageNotFoundError, version
11
+
12
+ try:
13
+ __version__ = version("lely")
14
+ except PackageNotFoundError: # pragma: no cover - running from a source tree
15
+ __version__ = "0.0.0"
lely/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """`python -m lely` is `lely`."""
2
+
3
+ from lely.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
lely/approval.py ADDED
@@ -0,0 +1,206 @@
1
+ """What may run: the approval check, and the checks on a plan file.
2
+
3
+ Pure. Two sentences carry it: a plan reaches as far as lely can see, and
4
+ consent covers what was shown.
5
+
6
+ Every refusal here is a `Refused`: running the same thing again won't help, it
7
+ takes a new plan.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Sequence
13
+
14
+ from lely.errors import Refused
15
+ from lely.model import (
16
+ Change,
17
+ Input,
18
+ Plan,
19
+ PlannedStep,
20
+ Secret,
21
+ Source,
22
+ StepPlan,
23
+ Value,
24
+ Workspace,
25
+ )
26
+
27
+ AGAIN = "Plan again."
28
+
29
+
30
+ def same_project(approved: Plan, steps: Sequence[tuple[str, str, str]]) -> None:
31
+ """Refuse a plan file whose steps, or any step's options as written, differ
32
+ from the project's now. `steps` are (name, uses, made-from hash), in order.
33
+
34
+ What is compared is the config as lely reads it, not the file's text — an
35
+ unrelated edit doesn't make a plan stale — with environment values by name.
36
+ """
37
+ planned = [step.name for step in approved.steps]
38
+ current = [name for name, _, _ in steps]
39
+ if planned != current:
40
+ raise Refused(
41
+ f"The plan was made for the steps {', '.join(planned) or 'none'}; the "
42
+ f"project now has {', '.join(current) or 'none'}. {AGAIN}"
43
+ )
44
+ for step, (name, uses, made_from) in zip(approved.steps, steps, strict=True):
45
+ if step.uses != uses or step.made_from != made_from:
46
+ raise Refused(
47
+ f"Step `{name}` is configured differently than when the plan was "
48
+ f"made. {AGAIN}"
49
+ )
50
+
51
+
52
+ def same_workspace(approved: Plan, workspace: Workspace) -> None:
53
+ """A plan made against one workspace is refused on another. Consent is
54
+ given to a target *on a workspace*, not to a name that could mean anything.
55
+
56
+ The identity may differ: a plan is made with credentials that can only
57
+ read, and applied with ones that can write.
58
+ """
59
+ if _host(approved.workspace.host) != _host(workspace.host):
60
+ raise Refused(
61
+ f"The plan was made for {approved.workspace.host}, and this run talks to "
62
+ f"{workspace.host}. {AGAIN}"
63
+ )
64
+
65
+
66
+ def same_source(approved: Plan, source: Source) -> None:
67
+ """What was reviewed is what is deployed. A plan file is for a clean
68
+ checkout: refuse it for another project of the repository, on another
69
+ tree, and whenever something differs from `HEAD` — now, or when the plan
70
+ was made, since lely then can't say what it was made on.
71
+
72
+ A plan made outside a git repository, or in one with no commit yet,
73
+ recorded nothing it can be held to — it said so when it was made.
74
+ """
75
+ planned = approved.source
76
+ if planned.tree is None:
77
+ return
78
+ if source.tree is None:
79
+ raise Refused(
80
+ "The plan was made on a commit of a git repository, and there is none "
81
+ f"here: lely can't check that the project is as it was planned. {AGAIN}"
82
+ )
83
+ if planned.root != source.root:
84
+ raise Refused(
85
+ f"The plan was made for the project in {_folder(planned.root)}, and this "
86
+ f"is the one in {_folder(source.root)}. {AGAIN}"
87
+ )
88
+ if planned.dirty:
89
+ raise Refused(
90
+ "The plan was made with uncommitted changes, so lely can't say what it "
91
+ "was made on. Plan again on a clean checkout — or run it without a file."
92
+ )
93
+ if source.dirty:
94
+ raise Refused(
95
+ f"The project has uncommitted changes that the plan was made without. {AGAIN}"
96
+ )
97
+ if source.tree != planned.tree:
98
+ raise Refused(
99
+ f"The plan was made on git tree {planned.tree[:12]}, and the project is "
100
+ f"now on {source.tree[:12]}. {AGAIN}"
101
+ )
102
+
103
+
104
+ def _folder(root: str | None) -> str:
105
+ return "the top of the repository" if root in (None, ".") else f"`{root}`"
106
+
107
+
108
+ def same_inputs(approved: PlannedStep, inputs: Sequence[Input]) -> None:
109
+ """Every value a step took when it was planned must be the value it takes
110
+ now: the plan showed `model_version = 14`, and 15 was never approved.
111
+
112
+ The same value means the same kind of value: `1` is not `true`, `14` is
113
+ not `14.0` and not `"14"`. A secret can't be compared, and stays one: a
114
+ value the plan showed is not replaced by one nobody can see, nor the other
115
+ way round.
116
+ """
117
+ known = {(one.label, one.source): one.value for one in approved.inputs if one.known}
118
+ for taken in inputs:
119
+ key = (taken.label, taken.source)
120
+ if key not in known or not taken.known:
121
+ continue
122
+ if not _unchanged(known[key], taken.value):
123
+ raise Refused(
124
+ f"Step `{approved.name}` takes {taken.source} = {_said(taken.value)} "
125
+ f"now; the plan was approved with {_said(known[key])}. {AGAIN}"
126
+ )
127
+
128
+
129
+ def _unchanged(was: Value, now: Value) -> bool:
130
+ if isinstance(was, Secret) or isinstance(now, Secret):
131
+ return isinstance(was, Secret) and isinstance(now, Secret)
132
+ return _same(was, now)
133
+
134
+
135
+ def _said(value: Value) -> str:
136
+ return "a secret" if isinstance(value, Secret) else repr(value)
137
+
138
+
139
+ def _same(one: object, other: object) -> bool:
140
+ """Equal, and of one kind. Python calls `1 == True` and `14 == 14.0` true;
141
+ a reviewer who was shown one was not shown the other. A tuple and a list
142
+ of the same things are the same: one is what a plugin gave, the other what
143
+ a plan file read back."""
144
+ if isinstance(one, list | tuple) and isinstance(other, list | tuple):
145
+ return len(one) == len(other) and all(
146
+ _same(a, b) for a, b in zip(one, other, strict=True)
147
+ )
148
+ if isinstance(one, dict) and isinstance(other, dict):
149
+ return one.keys() == other.keys() and all(
150
+ _same(value, other[key]) for key, value in one.items()
151
+ )
152
+ return type(one) is type(other) and one == other
153
+
154
+
155
+ def approved_changes(approved: StepPlan, fresh: StepPlan, step: str) -> None:
156
+ """The approval check: every change in the fresh plan must be one that was
157
+ shown — the same thing, the same kind of change, the same lines.
158
+
159
+ Fewer changes is fine: someone else did part of the work, or an earlier run
160
+ did. Anything new, or anything that reads differently, stops the run.
161
+ """
162
+ shown = {change.key: change for change in approved.changes}
163
+ for change in fresh.changes:
164
+ was = shown.get(change.key)
165
+ if was is None:
166
+ raise Refused(
167
+ f"Step `{step}` would now also {_says(change)}, which the approved "
168
+ f"plan didn't show. {AGAIN}"
169
+ )
170
+ if was != change:
171
+ raise Refused(
172
+ f"Step `{step}` would now {_says(change)}; the approved plan showed "
173
+ f"it would {_says(was)}. {AGAIN}"
174
+ )
175
+
176
+
177
+ def destructive_allowed(steps: Sequence[PlannedStep], allow: bool) -> None:
178
+ """A destructive change needs `--allow-destructive`. Where the plan already
179
+ shows one, this refuses before anything runs."""
180
+ if allow:
181
+ return
182
+ found = [
183
+ f"{step.name}: {change.summary}"
184
+ for step in steps
185
+ if step.state != "skipped"
186
+ for change in step.plan.changes
187
+ if change.destructive
188
+ ]
189
+ if found:
190
+ raise Refused(
191
+ "The plan holds destructive changes — " + "; ".join(found) + ". "
192
+ "Pass --allow-destructive to apply them."
193
+ )
194
+
195
+
196
+ def _says(change: Change) -> str:
197
+ words = f"{change.action} {change.summary}"
198
+ if change.destructive and change.action not in ("delete", "replace"):
199
+ words += " (destructive)"
200
+ if change.detail:
201
+ words += f" [{'; '.join(change.detail)}]"
202
+ return words
203
+
204
+
205
+ def _host(host: str) -> str:
206
+ return host.rstrip("/").lower()