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.
Files changed (84) hide show
  1. {lely-0.2.0 → lely-0.3.0}/CHANGELOG.md +21 -0
  2. {lely-0.2.0 → lely-0.3.0}/PKG-INFO +23 -13
  3. {lely-0.2.0 → lely-0.3.0}/README.md +22 -12
  4. {lely-0.2.0 → lely-0.3.0}/pyproject.toml +1 -2
  5. {lely-0.2.0 → lely-0.3.0}/src/lely/cli.py +2 -3
  6. lely-0.3.0/src/lely/flags.py +236 -0
  7. {lely-0.2.0 → lely-0.3.0}/src/lely/planfile.py +5 -4
  8. {lely-0.2.0 → lely-0.3.0}/src/lely/process.py +2 -2
  9. {lely-0.2.0 → lely-0.3.0}/src/lely/render/markdown.py +1 -1
  10. {lely-0.2.0 → lely-0.3.0}/src/lely/render/rich.py +2 -2
  11. lely-0.3.0/src/lely/solo.py +474 -0
  12. {lely-0.2.0 → lely-0.3.0}/src/lely/step.py +139 -1
  13. {lely-0.2.0 → lely-0.3.0}/tests/project.py +36 -10
  14. {lely-0.2.0 → lely-0.3.0}/tests/unit/__snapshots__/test_render_html.ambr +9 -9
  15. {lely-0.2.0 → lely-0.3.0}/tests/unit/__snapshots__/test_render_markdown.ambr +11 -11
  16. {lely-0.2.0 → lely-0.3.0}/tests/unit/__snapshots__/test_render_rich.ambr +16 -16
  17. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_cli.py +114 -57
  18. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_config.py +8 -8
  19. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_planning.py +116 -93
  20. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_registry.py +12 -6
  21. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_render_rich.py +3 -4
  22. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_repo.py +52 -2
  23. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_running.py +69 -126
  24. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_schema.py +16 -11
  25. lely-0.3.0/tests/unit/test_solo.py +366 -0
  26. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_step_bundle_run.py +2 -3
  27. lely-0.3.0/tests/unit/test_step_program.py +148 -0
  28. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_testing.py +2 -4
  29. lely-0.2.0/src/lely/steps/command.py +0 -298
  30. lely-0.2.0/tests/unit/test_step_command.py +0 -468
  31. {lely-0.2.0 → lely-0.3.0}/.gitignore +0 -0
  32. {lely-0.2.0 → lely-0.3.0}/LICENSE +0 -0
  33. {lely-0.2.0 → lely-0.3.0}/src/lely/__init__.py +0 -0
  34. {lely-0.2.0 → lely-0.3.0}/src/lely/__main__.py +0 -0
  35. {lely-0.2.0 → lely-0.3.0}/src/lely/approval.py +0 -0
  36. {lely-0.2.0 → lely-0.3.0}/src/lely/config.py +0 -0
  37. {lely-0.2.0 → lely-0.3.0}/src/lely/databricks.py +0 -0
  38. {lely-0.2.0 → lely-0.3.0}/src/lely/errors.py +0 -0
  39. {lely-0.2.0 → lely-0.3.0}/src/lely/github.py +0 -0
  40. {lely-0.2.0 → lely-0.3.0}/src/lely/model.py +0 -0
  41. {lely-0.2.0 → lely-0.3.0}/src/lely/options.py +0 -0
  42. {lely-0.2.0 → lely-0.3.0}/src/lely/planning.py +0 -0
  43. {lely-0.2.0 → lely-0.3.0}/src/lely/py.typed +0 -0
  44. {lely-0.2.0 → lely-0.3.0}/src/lely/refs.py +0 -0
  45. {lely-0.2.0 → lely-0.3.0}/src/lely/registry.py +0 -0
  46. {lely-0.2.0 → lely-0.3.0}/src/lely/render/__init__.py +0 -0
  47. {lely-0.2.0 → lely-0.3.0}/src/lely/render/html.py +0 -0
  48. {lely-0.2.0 → lely-0.3.0}/src/lely/render/words.py +0 -0
  49. {lely-0.2.0 → lely-0.3.0}/src/lely/running.py +0 -0
  50. {lely-0.2.0 → lely-0.3.0}/src/lely/schema.py +0 -0
  51. {lely-0.2.0 → lely-0.3.0}/src/lely/source.py +0 -0
  52. {lely-0.2.0 → lely-0.3.0}/src/lely/steps/__init__.py +0 -0
  53. {lely-0.2.0 → lely-0.3.0}/src/lely/steps/bundle.py +0 -0
  54. {lely-0.2.0 → lely-0.3.0}/src/lely/steps/bundle_run.py +0 -0
  55. {lely-0.2.0 → lely-0.3.0}/src/lely/steps/stevin.py +0 -0
  56. {lely-0.2.0 → lely-0.3.0}/src/lely/testing.py +0 -0
  57. {lely-0.2.0 → lely-0.3.0}/tests/browser/test_frame_in_a_browser.py +0 -0
  58. {lely-0.2.0 → lely-0.3.0}/tests/fake_databricks.py +0 -0
  59. {lely-0.2.0 → lely-0.3.0}/tests/fake_github.py +0 -0
  60. {lely-0.2.0 → lely-0.3.0}/tests/fake_stevin.py +0 -0
  61. {lely-0.2.0 → lely-0.3.0}/tests/fakes.py +0 -0
  62. {lely-0.2.0 → lely-0.3.0}/tests/fixtures/cli/README.md +0 -0
  63. {lely-0.2.0 → lely-0.3.0}/tests/fixtures/cli/plan-create.json +0 -0
  64. {lely-0.2.0 → lely-0.3.0}/tests/fixtures/cli/plan-delete-update.json +0 -0
  65. {lely-0.2.0 → lely-0.3.0}/tests/fixtures/cli/plan-update-recreate.json +0 -0
  66. {lely-0.2.0 → lely-0.3.0}/tests/fixtures/cli/summary-default-python.json +0 -0
  67. {lely-0.2.0 → lely-0.3.0}/tests/fixtures/cli/validate-default-python.json +0 -0
  68. {lely-0.2.0 → lely-0.3.0}/tests/fixtures/stevin-create.json +0 -0
  69. {lely-0.2.0 → lely-0.3.0}/tests/fixtures/stevin-destroy.json +0 -0
  70. {lely-0.2.0 → lely-0.3.0}/tests/fixtures/stevin-empty.json +0 -0
  71. {lely-0.2.0 → lely-0.3.0}/tests/unit/conftest.py +0 -0
  72. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_approval.py +0 -0
  73. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_databricks.py +0 -0
  74. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_github.py +0 -0
  75. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_options.py +0 -0
  76. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_planfile.py +0 -0
  77. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_process.py +0 -0
  78. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_quietly.py +0 -0
  79. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_refs.py +0 -0
  80. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_render_html.py +0 -0
  81. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_render_markdown.py +0 -0
  82. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_source.py +0 -0
  83. {lely-0.2.0 → lely-0.3.0}/tests/unit/test_step_bundle.py +0 -0
  84. {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.2.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: command
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` and `command` come with lely. `./ops/steps.py:LatestModel` is a plugin of your own —
131
- a class in a file in the repo. This one runs as it stands; your lookup goes where the `14` is:
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 command
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. `lely.testing` checks it
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 plugin in the repo, a `command` step's plan command.
267
- On a pull request, give `lely plan` credentials that can read and nothing more.
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: command
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` and `command` come with lely. `./ops/steps.py:LatestModel` is a plugin of your own —
100
- a class in a file in the repo. This one runs as it stands; your lookup goes where the `14` is:
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 command
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. `lely.testing` checks it
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 plugin in the repo, a `command` step's plan command.
236
- On a pull request, give `lely plan` credentials that can read and nothing more.
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.2.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 plugin in the repo, a `command` "
961
- "step's plan command. On a pull request, give `lely plan` credentials that "
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 has the same shape here as a `command` step's plan command
15
- prints, so `step_plan_from_json` reads both. What a run leaves behind — a
16
- `Result`, a `Status` — is written here too, and never read back.
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 (or a plan command's output) that can't be read."""
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 `command` step.
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 apply command — can
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
  """
@@ -18,7 +18,7 @@
18
18
  - replace pipelines.ingest destructive
19
19
  replaced: storage (immutable)
20
20
  run uploads the bundle's files
21
- notify command
21
+ notify ./ops/steps.py:Notify
22
22
  waiting for app.resources.jobs.backfill.id
23
23
  ```
24
24
 
@@ -10,9 +10,9 @@
10
10
  ± pipelines.ingest destructive
11
11
  replaced: storage (immutable)
12
12
  ▶ uploads the bundle's files
13
- notify command
13
+ notify ./ops/steps.py:Notify
14
14
  ⏸ waiting for app.resources.jobs.backfill.id
15
- warm command
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