lely 0.2.0__tar.gz → 0.3.0__tar.gz
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-0.2.0 → lely-0.3.0}/CHANGELOG.md +21 -0
- {lely-0.2.0 → lely-0.3.0}/PKG-INFO +23 -13
- {lely-0.2.0 → lely-0.3.0}/README.md +22 -12
- {lely-0.2.0 → lely-0.3.0}/pyproject.toml +1 -2
- {lely-0.2.0 → lely-0.3.0}/src/lely/cli.py +2 -3
- lely-0.3.0/src/lely/flags.py +236 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/planfile.py +5 -4
- {lely-0.2.0 → lely-0.3.0}/src/lely/process.py +2 -2
- {lely-0.2.0 → lely-0.3.0}/src/lely/render/markdown.py +1 -1
- {lely-0.2.0 → lely-0.3.0}/src/lely/render/rich.py +2 -2
- lely-0.3.0/src/lely/solo.py +474 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/step.py +139 -1
- {lely-0.2.0 → lely-0.3.0}/tests/project.py +36 -10
- {lely-0.2.0 → lely-0.3.0}/tests/unit/__snapshots__/test_render_html.ambr +9 -9
- {lely-0.2.0 → lely-0.3.0}/tests/unit/__snapshots__/test_render_markdown.ambr +11 -11
- {lely-0.2.0 → lely-0.3.0}/tests/unit/__snapshots__/test_render_rich.ambr +16 -16
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_cli.py +114 -57
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_config.py +8 -8
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_planning.py +116 -93
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_registry.py +12 -6
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_render_rich.py +3 -4
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_repo.py +52 -2
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_running.py +69 -126
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_schema.py +16 -11
- lely-0.3.0/tests/unit/test_solo.py +366 -0
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_step_bundle_run.py +2 -3
- lely-0.3.0/tests/unit/test_step_program.py +148 -0
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_testing.py +2 -4
- lely-0.2.0/src/lely/steps/command.py +0 -298
- lely-0.2.0/tests/unit/test_step_command.py +0 -468
- {lely-0.2.0 → lely-0.3.0}/.gitignore +0 -0
- {lely-0.2.0 → lely-0.3.0}/LICENSE +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/__init__.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/__main__.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/approval.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/config.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/databricks.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/errors.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/github.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/model.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/options.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/planning.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/py.typed +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/refs.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/registry.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/render/__init__.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/render/html.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/render/words.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/running.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/schema.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/source.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/steps/__init__.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/steps/bundle.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/steps/bundle_run.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/steps/stevin.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/src/lely/testing.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/browser/test_frame_in_a_browser.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/fake_databricks.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/fake_github.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/fake_stevin.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/fakes.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/fixtures/cli/README.md +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/fixtures/cli/plan-create.json +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/fixtures/cli/plan-delete-update.json +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/fixtures/cli/plan-update-recreate.json +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/fixtures/cli/summary-default-python.json +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/fixtures/cli/validate-default-python.json +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/fixtures/stevin-create.json +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/fixtures/stevin-destroy.json +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/fixtures/stevin-empty.json +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/unit/conftest.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_approval.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_databricks.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_github.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_options.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_planfile.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_process.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_quietly.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_refs.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_render_html.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_render_markdown.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_source.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_step_bundle.py +0 -0
- {lely-0.2.0 → lely-0.3.0}/tests/unit/test_step_stevin.py +0 -0
|
@@ -6,6 +6,27 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.0] - 2026-10-08
|
|
10
|
+
|
|
11
|
+
A breaking release: the `command` plugin is gone, and a step runs on its own.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **A step on its own** (`spec/011`). A step that inherits `lely.step.Step` and ends with
|
|
16
|
+
`Step.main()` is a command of its own: `plan`, `apply`, `destroy`, `status` and `check`,
|
|
17
|
+
its options as flags, with no `lely.yml`. A plan it writes is a one-step lely plan:
|
|
18
|
+
`lely apply plan.json` takes it, and the same checks are made before anything runs.
|
|
19
|
+
`check` holds the step to the rules from the command line; `--live` applies and destroys
|
|
20
|
+
on the target.
|
|
21
|
+
- **`Program`**: a program as a step, in ten lines — one `run` every apply, destructive if
|
|
22
|
+
it says so, outputs from the JSON on its last line. What `command` did, under the rules.
|
|
23
|
+
|
|
24
|
+
### Removed
|
|
25
|
+
|
|
26
|
+
- **`command`**: a program is a `Program` (ten lines, see Writing a step); a config that
|
|
27
|
+
names `command` is told by `lely validate` that no plugin of that name is installed.
|
|
28
|
+
→ [011](https://github.com/kostavo-oss/lely/blob/main/spec/011-a-step-on-its-own.md)
|
|
29
|
+
|
|
9
30
|
## [0.2.0] - 2026-10-07
|
|
10
31
|
|
|
11
32
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lely
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: One plan for your whole Databricks deploy: the bundle and the steps around it, reviewed before anything runs
|
|
5
5
|
Project-URL: Homepage, https://github.com/kostavo-oss/lely
|
|
6
6
|
Project-URL: Documentation, https://kostavo-oss.github.io/lely/
|
|
@@ -118,23 +118,24 @@ steps:
|
|
|
118
118
|
model_version: ${steps.model.version} # … and the bundle says what it takes
|
|
119
119
|
|
|
120
120
|
- name: backfill # needs something from the bundle: it is below it …
|
|
121
|
-
uses:
|
|
122
|
-
with:
|
|
123
|
-
apply: [./ops/backfill.sh, "${steps.app.resources.jobs.backfill.id}"] # … and says what
|
|
121
|
+
uses: ./ops/steps.py:Backfill
|
|
122
|
+
with: {job: "${steps.app.resources.jobs.backfill.id}"} # … and says what
|
|
124
123
|
```
|
|
125
124
|
|
|
126
125
|
A step takes a value from another step in one way only: `${steps.<name>.<output>}`, in its own
|
|
127
126
|
options, and only from a step above it. Reading a step's `with:` is enough to know everything
|
|
128
127
|
it depends on.
|
|
129
128
|
|
|
130
|
-
`bundle`
|
|
131
|
-
|
|
129
|
+
`bundle` comes with lely; the other two are yours — classes in a file in the repo.
|
|
130
|
+
`LatestModel` runs as it stands, and your lookup goes where the `14` is; `Backfill` is a
|
|
131
|
+
program as a step, one run every apply:
|
|
132
132
|
|
|
133
133
|
```python
|
|
134
134
|
# ops/steps.py
|
|
135
135
|
from dataclasses import dataclass
|
|
136
136
|
|
|
137
137
|
from lely.model import Output, StepPlan
|
|
138
|
+
from lely.step import Program
|
|
138
139
|
|
|
139
140
|
|
|
140
141
|
class LatestModel:
|
|
@@ -154,6 +155,17 @@ class LatestModel:
|
|
|
154
155
|
|
|
155
156
|
def apply(self, ctx, plan):
|
|
156
157
|
return plan.outputs # nothing to do: what it gives was known at plan
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class Backfill(Program):
|
|
161
|
+
"""Backfills the job this deploy made."""
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True, slots=True)
|
|
164
|
+
class Options:
|
|
165
|
+
job: str
|
|
166
|
+
|
|
167
|
+
def command(self, ctx):
|
|
168
|
+
return ["./ops/backfill.sh", ctx.options.job]
|
|
157
169
|
```
|
|
158
170
|
|
|
159
171
|
The rest is yours too: a bundle beside `lely.yml` with a variable `model_version` and a job
|
|
@@ -187,7 +199,7 @@ lely plan · target dev · https://dbc-example.cloud.databricks.com as jane@exam
|
|
|
187
199
|
± pipelines.foo destructive
|
|
188
200
|
replaced: storage (immutable)
|
|
189
201
|
▶ uploads the bundle's files
|
|
190
|
-
notify
|
|
202
|
+
notify ./ops/steps.py:Notify
|
|
191
203
|
⏸ waiting for app.resources.jobs.bar.id
|
|
192
204
|
backfill bundle.run
|
|
193
205
|
bundle ← app
|
|
@@ -254,17 +266,15 @@ what they can do.
|
|
|
254
266
|
- **`bundle`** — an Asset Bundle: planned, deployed, listed and destroyed through the
|
|
255
267
|
Databricks CLI. It gives the bundle's variables, its resources' names, and their ids and
|
|
256
268
|
links once they exist.
|
|
257
|
-
- **`command`** — commands you give it: `apply`, and optionally `plan`, `destroy` and the
|
|
258
|
-
`outputs` it gives. No shell, and no secrets in arguments.
|
|
259
269
|
- **`bundle.run`** — runs a job, pipeline or app from a bundle step.
|
|
260
270
|
- **Your own** — a class in a file in your repo (`uses: ./ops/steps.py:LatestModel`), or a
|
|
261
|
-
package that registers one under the `lely.steps` entry point.
|
|
262
|
-
against the same rules as the ones above.
|
|
271
|
+
package that registers one under the `lely.steps` entry point. A program is a `Program`,
|
|
272
|
+
in ten lines. `lely.testing` checks it against the same rules as the ones above.
|
|
263
273
|
- **`stevin`** — tables, planned by [stevin](https://github.com/kostavo-oss/stevin). Parked:
|
|
264
274
|
it can plan, and can't apply yet.
|
|
265
275
|
|
|
266
|
-
Planning runs your project's own code — a
|
|
267
|
-
|
|
276
|
+
Planning runs your project's own code — a step in the repo. On a pull request, give
|
|
277
|
+
`lely plan` credentials that can read and nothing more.
|
|
268
278
|
|
|
269
279
|
## What has been tried
|
|
270
280
|
|
|
@@ -87,23 +87,24 @@ steps:
|
|
|
87
87
|
model_version: ${steps.model.version} # … and the bundle says what it takes
|
|
88
88
|
|
|
89
89
|
- name: backfill # needs something from the bundle: it is below it …
|
|
90
|
-
uses:
|
|
91
|
-
with:
|
|
92
|
-
apply: [./ops/backfill.sh, "${steps.app.resources.jobs.backfill.id}"] # … and says what
|
|
90
|
+
uses: ./ops/steps.py:Backfill
|
|
91
|
+
with: {job: "${steps.app.resources.jobs.backfill.id}"} # … and says what
|
|
93
92
|
```
|
|
94
93
|
|
|
95
94
|
A step takes a value from another step in one way only: `${steps.<name>.<output>}`, in its own
|
|
96
95
|
options, and only from a step above it. Reading a step's `with:` is enough to know everything
|
|
97
96
|
it depends on.
|
|
98
97
|
|
|
99
|
-
`bundle`
|
|
100
|
-
|
|
98
|
+
`bundle` comes with lely; the other two are yours — classes in a file in the repo.
|
|
99
|
+
`LatestModel` runs as it stands, and your lookup goes where the `14` is; `Backfill` is a
|
|
100
|
+
program as a step, one run every apply:
|
|
101
101
|
|
|
102
102
|
```python
|
|
103
103
|
# ops/steps.py
|
|
104
104
|
from dataclasses import dataclass
|
|
105
105
|
|
|
106
106
|
from lely.model import Output, StepPlan
|
|
107
|
+
from lely.step import Program
|
|
107
108
|
|
|
108
109
|
|
|
109
110
|
class LatestModel:
|
|
@@ -123,6 +124,17 @@ class LatestModel:
|
|
|
123
124
|
|
|
124
125
|
def apply(self, ctx, plan):
|
|
125
126
|
return plan.outputs # nothing to do: what it gives was known at plan
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Backfill(Program):
|
|
130
|
+
"""Backfills the job this deploy made."""
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True, slots=True)
|
|
133
|
+
class Options:
|
|
134
|
+
job: str
|
|
135
|
+
|
|
136
|
+
def command(self, ctx):
|
|
137
|
+
return ["./ops/backfill.sh", ctx.options.job]
|
|
126
138
|
```
|
|
127
139
|
|
|
128
140
|
The rest is yours too: a bundle beside `lely.yml` with a variable `model_version` and a job
|
|
@@ -156,7 +168,7 @@ lely plan · target dev · https://dbc-example.cloud.databricks.com as jane@exam
|
|
|
156
168
|
± pipelines.foo destructive
|
|
157
169
|
replaced: storage (immutable)
|
|
158
170
|
▶ uploads the bundle's files
|
|
159
|
-
notify
|
|
171
|
+
notify ./ops/steps.py:Notify
|
|
160
172
|
⏸ waiting for app.resources.jobs.bar.id
|
|
161
173
|
backfill bundle.run
|
|
162
174
|
bundle ← app
|
|
@@ -223,17 +235,15 @@ what they can do.
|
|
|
223
235
|
- **`bundle`** — an Asset Bundle: planned, deployed, listed and destroyed through the
|
|
224
236
|
Databricks CLI. It gives the bundle's variables, its resources' names, and their ids and
|
|
225
237
|
links once they exist.
|
|
226
|
-
- **`command`** — commands you give it: `apply`, and optionally `plan`, `destroy` and the
|
|
227
|
-
`outputs` it gives. No shell, and no secrets in arguments.
|
|
228
238
|
- **`bundle.run`** — runs a job, pipeline or app from a bundle step.
|
|
229
239
|
- **Your own** — a class in a file in your repo (`uses: ./ops/steps.py:LatestModel`), or a
|
|
230
|
-
package that registers one under the `lely.steps` entry point.
|
|
231
|
-
against the same rules as the ones above.
|
|
240
|
+
package that registers one under the `lely.steps` entry point. A program is a `Program`,
|
|
241
|
+
in ten lines. `lely.testing` checks it against the same rules as the ones above.
|
|
232
242
|
- **`stevin`** — tables, planned by [stevin](https://github.com/kostavo-oss/stevin). Parked:
|
|
233
243
|
it can plan, and can't apply yet.
|
|
234
244
|
|
|
235
|
-
Planning runs your project's own code — a
|
|
236
|
-
|
|
245
|
+
Planning runs your project's own code — a step in the repo. On a pull request, give
|
|
246
|
+
`lely plan` credentials that can read and nothing more.
|
|
237
247
|
|
|
238
248
|
## What has been tried
|
|
239
249
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "lely"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "One plan for your whole Databricks deploy: the bundle and the steps around it, reviewed before anything runs"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -47,7 +47,6 @@ lely = "lely.cli:main"
|
|
|
47
47
|
# The plugins lely ships register exactly as anyone's would.
|
|
48
48
|
[project.entry-points."lely.steps"]
|
|
49
49
|
bundle = "lely.steps.bundle:Bundle"
|
|
50
|
-
command = "lely.steps.command:Command"
|
|
51
50
|
stevin = "lely.steps.stevin:Stevin"
|
|
52
51
|
"bundle.run" = "lely.steps.bundle_run:BundleRun"
|
|
53
52
|
|
|
@@ -957,9 +957,8 @@ def doctor(path: ConfigOption = None, profile: ProfileOption = None) -> None:
|
|
|
957
957
|
line(None, f"what these credentials may do couldn't be read: {error}")
|
|
958
958
|
line(
|
|
959
959
|
None,
|
|
960
|
-
"planning runs the project's own code — a
|
|
961
|
-
"
|
|
962
|
-
"can read and nothing more.",
|
|
960
|
+
"planning runs the project's own code — a step in the repo. On a pull "
|
|
961
|
+
"request, give `lely plan` credentials that can read and nothing more.",
|
|
963
962
|
)
|
|
964
963
|
if loaded is None:
|
|
965
964
|
line(None, f"no project checked: {unread}")
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""A step's `Options` as the flags of its own command line, and back into a
|
|
2
|
+
config block — for a step run on its own (`spec/011`, R5).
|
|
3
|
+
|
|
4
|
+
Each field of the `Options` dataclass is `--<name>`, with the field's type, its
|
|
5
|
+
default and the `#:` comment above it as its help. What a flag gives is then
|
|
6
|
+
written into the same config nodes a `lely.yml` would hold, so the step's
|
|
7
|
+
options are read by `lely.options` exactly as under lely: one reader, one set
|
|
8
|
+
of errors, one `made_from` hash.
|
|
9
|
+
|
|
10
|
+
The types a flag can carry: `str`, `int`, `float`, `bool` (as a word: `true`,
|
|
11
|
+
`yes`, `no`…, so a bundle variable can set it), `Secret` (text), `Literal[…]`
|
|
12
|
+
(one of them), `tuple[T, …]` (the flag more than once), `Mapping[str, T]`
|
|
13
|
+
(`--name key=value`, more than once), and `T | None`. Anything else is refused
|
|
14
|
+
when the step starts, with this list.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import dataclasses
|
|
20
|
+
import types
|
|
21
|
+
import typing
|
|
22
|
+
from collections.abc import Mapping, Sequence
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
25
|
+
|
|
26
|
+
from typer.core import TyperOption
|
|
27
|
+
|
|
28
|
+
from lely.config import Entry, Loc, Map, Scalar, Seq
|
|
29
|
+
from lely.errors import LelyError, Refused
|
|
30
|
+
from lely.model import Linked, Secret
|
|
31
|
+
from lely.options import default_of, option_fields
|
|
32
|
+
from lely.registry import option_docs
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from typer._click.types import ParamType
|
|
36
|
+
else: # typer carries its own click since 0.21
|
|
37
|
+
try:
|
|
38
|
+
from typer._click.types import ParamType
|
|
39
|
+
except ImportError: # pragma: no cover - typer before its own click
|
|
40
|
+
from click import ParamType
|
|
41
|
+
|
|
42
|
+
#: Where an option given as a flag "was written".
|
|
43
|
+
COMMAND_LINE = Loc("command line", 0, 0)
|
|
44
|
+
|
|
45
|
+
_SCALARS = (str, int, float, bool)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _YesNo(ParamType):
|
|
49
|
+
"""A yes or a no as a job's parameter can give it: a word, not a flag."""
|
|
50
|
+
|
|
51
|
+
name = "true|false"
|
|
52
|
+
|
|
53
|
+
def convert(self, value: Any, param: Any, ctx: Any) -> bool:
|
|
54
|
+
if isinstance(value, bool):
|
|
55
|
+
return value
|
|
56
|
+
word = str(value).strip().lower()
|
|
57
|
+
if word in ("true", "1", "yes", "y"):
|
|
58
|
+
return True
|
|
59
|
+
if word in ("false", "0", "no", "n"):
|
|
60
|
+
return False
|
|
61
|
+
self.fail(f"'{value}' is not one of true, 1, yes, y, false, 0, no, n", param, ctx)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _OneOf(ParamType):
|
|
65
|
+
"""One of a `Literal`'s values, of its own kind."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, allowed: tuple[Any, ...]) -> None:
|
|
68
|
+
self.allowed = allowed
|
|
69
|
+
self.name = "|".join(str(a) for a in allowed)
|
|
70
|
+
|
|
71
|
+
def convert(self, value: Any, param: Any, ctx: Any) -> Any:
|
|
72
|
+
for allowed in self.allowed:
|
|
73
|
+
if value == allowed or (isinstance(value, str) and str(allowed) == value):
|
|
74
|
+
return allowed
|
|
75
|
+
self.fail(f"'{value}' is not one of {self.name}", param, ctx)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _Pair(ParamType):
|
|
79
|
+
"""`key=value`, for a mapping option."""
|
|
80
|
+
|
|
81
|
+
name = "key=value"
|
|
82
|
+
|
|
83
|
+
def convert(self, value: Any, param: Any, ctx: Any) -> tuple[str, str]:
|
|
84
|
+
if isinstance(value, tuple):
|
|
85
|
+
return value
|
|
86
|
+
key, separator, rest = str(value).partition("=")
|
|
87
|
+
if not separator or not key.strip():
|
|
88
|
+
self.fail(f"'{value}' is not key=value", param, ctx)
|
|
89
|
+
return key.strip(), rest
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class Flag:
|
|
94
|
+
"""One option of the step, as its flag takes it."""
|
|
95
|
+
|
|
96
|
+
field: str
|
|
97
|
+
name: str
|
|
98
|
+
kind: Any
|
|
99
|
+
many: bool
|
|
100
|
+
required: bool
|
|
101
|
+
default: Any
|
|
102
|
+
help: str
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def option(self) -> str:
|
|
106
|
+
return "--" + self.field.replace("_", "-")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def flags_of(options: type) -> tuple[Flag, ...]:
|
|
110
|
+
"""The flags for an `Options` class. Raises `LelyError` for a field no
|
|
111
|
+
flag can carry."""
|
|
112
|
+
hints = typing.get_type_hints(options)
|
|
113
|
+
docs = option_docs(options)
|
|
114
|
+
result: list[Flag] = []
|
|
115
|
+
for f in option_fields(options):
|
|
116
|
+
made = default_of(options, f)
|
|
117
|
+
required = made is dataclasses.MISSING
|
|
118
|
+
kind, many = _kind(hints[f.name], f.name, options)
|
|
119
|
+
result.append(
|
|
120
|
+
Flag(
|
|
121
|
+
field=f.name,
|
|
122
|
+
name=f.name.replace("_", "-"),
|
|
123
|
+
kind=kind,
|
|
124
|
+
many=many,
|
|
125
|
+
required=required,
|
|
126
|
+
default=None if required else made,
|
|
127
|
+
help=docs.get(f.name, ""),
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
return tuple(result)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _kind(tp: Any, name: str, options: type) -> tuple[Any, bool]:
|
|
134
|
+
"""What a flag for a field of type `tp` takes, and whether more than once."""
|
|
135
|
+
origin = typing.get_origin(tp)
|
|
136
|
+
args = typing.get_args(tp)
|
|
137
|
+
if origin in (types.UnionType, typing.Union):
|
|
138
|
+
members = [a for a in args if a is not type(None)]
|
|
139
|
+
if len(members) == 1:
|
|
140
|
+
return _kind(members[0], name, options)
|
|
141
|
+
elif origin is Literal:
|
|
142
|
+
return _OneOf(args), False
|
|
143
|
+
elif origin in (tuple, list):
|
|
144
|
+
element = args[0] if args else str
|
|
145
|
+
if element is Ellipsis or element in _SCALARS or element is Secret:
|
|
146
|
+
inner, _ = _kind(str if element is Ellipsis else element, name, options)
|
|
147
|
+
return inner, True
|
|
148
|
+
elif origin in (dict, Mapping) or tp in (dict, Mapping):
|
|
149
|
+
element = args[1] if len(args) == 2 else str
|
|
150
|
+
if element in _SCALARS or element is Secret:
|
|
151
|
+
return _Pair(), True
|
|
152
|
+
elif tp is bool:
|
|
153
|
+
return _YesNo(), False
|
|
154
|
+
elif tp in (str, int, float):
|
|
155
|
+
return tp, False
|
|
156
|
+
elif tp is Secret:
|
|
157
|
+
return str, False
|
|
158
|
+
which = "the name of a step" if tp is Linked else _said(tp)
|
|
159
|
+
raise LelyError(
|
|
160
|
+
f"`{options.__qualname__}.{name}` is {which}: a flag can't carry it. On its "
|
|
161
|
+
"own, a step takes text, whole and decimal numbers, true or false, a secret, "
|
|
162
|
+
"one of a few values, a list or a mapping of those, and `T | None`. Run it "
|
|
163
|
+
"under lely for the rest."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _said(tp: Any) -> str:
|
|
168
|
+
return getattr(tp, "__name__", None) or str(tp)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def options_of(flags: Sequence[Flag]) -> list[TyperOption]:
|
|
172
|
+
"""The step's own flags, as typer builds a command from."""
|
|
173
|
+
made = []
|
|
174
|
+
for flag in flags:
|
|
175
|
+
kind: Any = flag.kind
|
|
176
|
+
# No default here: a flag not given is left out of the block, so the
|
|
177
|
+
# option's own default applies and the step is "made from" the same
|
|
178
|
+
# thing as a config that doesn't write it. The default is said in the help.
|
|
179
|
+
help_ = flag.help
|
|
180
|
+
if not flag.required and not flag.many and flag.default is not None:
|
|
181
|
+
help_ = f"{help_} [default: {flag.default}]".strip()
|
|
182
|
+
made.append(
|
|
183
|
+
TyperOption(
|
|
184
|
+
param_decls=[flag.option, flag.field],
|
|
185
|
+
type=kind,
|
|
186
|
+
required=flag.required,
|
|
187
|
+
multiple=flag.many,
|
|
188
|
+
default=None,
|
|
189
|
+
show_default=False,
|
|
190
|
+
help=help_ or None,
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
return made
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def block_from(flags: Sequence[Flag], values: Mapping[str, Any]) -> Map:
|
|
197
|
+
"""What the flags gave, as the `with:` block a config would hold.
|
|
198
|
+
|
|
199
|
+
A flag not given is left out, so the option's own default applies. A value
|
|
200
|
+
that looks like a reference is refused: on its own, a step has no step
|
|
201
|
+
above to take it from (`spec/011`, R8).
|
|
202
|
+
"""
|
|
203
|
+
entries: list[Entry] = []
|
|
204
|
+
for flag in flags:
|
|
205
|
+
value = values.get(flag.field)
|
|
206
|
+
if value is None or (flag.many and not value):
|
|
207
|
+
continue
|
|
208
|
+
_no_reference(flag, value)
|
|
209
|
+
entries.append(Entry(flag.field, COMMAND_LINE, _node(value, flag)))
|
|
210
|
+
return Map(tuple(entries), COMMAND_LINE)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _node(value: Any, flag: Flag) -> Scalar | Seq | Map:
|
|
214
|
+
if flag.many and isinstance(flag.kind, _Pair):
|
|
215
|
+
pairs = [
|
|
216
|
+
v if isinstance(v, tuple) else _Pair().convert(v, None, None) for v in value
|
|
217
|
+
]
|
|
218
|
+
return Map(
|
|
219
|
+
tuple(Entry(k, COMMAND_LINE, Scalar(v, COMMAND_LINE)) for k, v in pairs),
|
|
220
|
+
COMMAND_LINE,
|
|
221
|
+
)
|
|
222
|
+
if flag.many:
|
|
223
|
+
return Seq(tuple(Scalar(v, COMMAND_LINE) for v in value), COMMAND_LINE)
|
|
224
|
+
return Scalar(value, COMMAND_LINE)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _no_reference(flag: Flag, value: Any) -> None:
|
|
228
|
+
texts = [value] if not isinstance(value, list | tuple) else list(value)
|
|
229
|
+
for text in texts:
|
|
230
|
+
if isinstance(text, tuple):
|
|
231
|
+
text = text[1]
|
|
232
|
+
if isinstance(text, str) and "${" in text:
|
|
233
|
+
raise Refused(
|
|
234
|
+
f"{flag.option} is `{text}`: on its own, a step has no step above to "
|
|
235
|
+
"take a `${…}` reference from. Run it under lely, or pass the value."
|
|
236
|
+
)
|
|
@@ -11,9 +11,10 @@ A secret never reaches the file: a secret output is written as
|
|
|
11
11
|
holding one is refused outright. Nothing in the file is kept for apply — every
|
|
12
12
|
step is planned again, and gives its values again.
|
|
13
13
|
|
|
14
|
-
A step's plan
|
|
15
|
-
|
|
16
|
-
`Result`, a `Status` — is written here too, and
|
|
14
|
+
A step's plan is written by `step_plan_to_json` and read back by
|
|
15
|
+
`step_plan_from_json`; `lely.testing` holds a plugin's plan to that round trip.
|
|
16
|
+
What a run leaves behind — a `Result`, a `Status` — is written here too, and
|
|
17
|
+
never read back.
|
|
17
18
|
"""
|
|
18
19
|
|
|
19
20
|
from __future__ import annotations
|
|
@@ -79,7 +80,7 @@ _CHANGE_KEYS = frozenset({"key", "action", "summary", "destructive", "detail"})
|
|
|
79
80
|
|
|
80
81
|
|
|
81
82
|
class PlanFileError(LelyError):
|
|
82
|
-
"""A plan file
|
|
83
|
+
"""A plan file that can't be read."""
|
|
83
84
|
|
|
84
85
|
|
|
85
86
|
#: The longest view a step's plan keeps, in characters: a view is a picture of
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
"""Running another program: the Databricks CLI, stevin, a
|
|
1
|
+
"""Running another program: the Databricks CLI, stevin, a step's program.
|
|
2
2
|
|
|
3
3
|
One place, so every step that shells out fails the same way: a missing program
|
|
4
4
|
says so, and a failing one is shown in its own words — what it wrote on both of
|
|
5
5
|
its streams.
|
|
6
6
|
|
|
7
7
|
A program that only answers a question is run to its end and its output taken.
|
|
8
|
-
One that changes something — a deploy, a job run, a step's
|
|
8
|
+
One that changes something — a deploy, a job run, a step's program — can
|
|
9
9
|
take minutes, so its caller may ask to hear it (`said`): each line is passed on
|
|
10
10
|
as it comes, and still kept for whoever reads the result.
|
|
11
11
|
"""
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
± pipelines.ingest destructive
|
|
11
11
|
replaced: storage (immutable)
|
|
12
12
|
▶ uploads the bundle's files
|
|
13
|
-
notify
|
|
13
|
+
notify ./ops/steps.py:Notify
|
|
14
14
|
⏸ waiting for app.resources.jobs.backfill.id
|
|
15
|
-
warm
|
|
15
|
+
warm ./ops/steps.py:Warm
|
|
16
16
|
– skipped: not for target `dev`
|
|
17
17
|
|
|
18
18
|
Plan: 2 changes · 1 run · 1 destructive · 1 waiting
|