roadie 0.1.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.
@@ -0,0 +1,28 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ check:
12
+ name: ${{ matrix.os }}
13
+ runs-on: ${{ matrix.os }}
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ os: [ubuntu-latest, macos-latest]
18
+ steps:
19
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
20
+
21
+ - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
22
+
23
+ # The hosted images' preinstalled rustup and stable toolchain serve the
24
+ # Cargo tests.
25
+ - run: uv sync --locked
26
+ - run: uv run ruff check
27
+ - run: uv run mypy src
28
+ - run: uv run pytest
@@ -0,0 +1,33 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ build:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
15
+ - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
16
+ - run: uv build
17
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
18
+ with:
19
+ name: distributions
20
+ path: dist/*
21
+ if-no-files-found: error
22
+
23
+ publish:
24
+ needs: build
25
+ runs-on: ubuntu-latest
26
+ permissions:
27
+ id-token: write
28
+ steps:
29
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
30
+ with:
31
+ name: distributions
32
+ path: dist
33
+ - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
@@ -0,0 +1,10 @@
1
+ /.venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ *.egg-info/
8
+ /build/
9
+ /dist/
10
+ /target/
roadie-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sasha Ovsankin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
roadie-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.5
2
+ Name: roadie
3
+ Version: 0.1.0
4
+ Summary: Wire up multi-language, incremental builds in a few lines of Python.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.13
8
+ Requires-Dist: cyclopts<5.0.0,>=4.20.0
9
+ Requires-Dist: python-dotenv<2.0.0,>=1.2.1
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Roadie
13
+
14
+ Wire up multi-language, incremental builds in a few lines of Python.
15
+
16
+ A step that does not need to run does not run. Roadie knows each step's
17
+ inputs, so when they are unchanged since the step last succeeded, it reuses
18
+ that result instead of running the step; a step whose result cannot be
19
+ reused, such as a fuzzing run, says so.
20
+
21
+ Like a roadie on stage, it sets up and connects the native tools (cargo,
22
+ pnpm, uv) and lets them play, instead of replacing them.
23
+
24
+ ## Install
25
+
26
+ ```sh
27
+ uv add roadie
28
+ uvx roadie
29
+ ```
30
+
31
+ ## A two-step build file
32
+
33
+ `roadie` runs the last step defined in `<directory>/build.py`, after the
34
+ steps it depends on. The second run reuses a step whose inputs have not
35
+ changed.
36
+
37
+ ```python
38
+ from dataclasses import dataclass
39
+ from pathlib import Path
40
+
41
+ from roadie import *
42
+ from roadie.run import Run
43
+
44
+
45
+ @dataclass(eq=False)
46
+ class Emitter(File):
47
+ text: str = "hello"
48
+
49
+ def build(self, run: Run) -> Path:
50
+ path = run.artifact(self, ".txt")
51
+ path.write_text(self.text)
52
+ return path
53
+
54
+
55
+ @dataclass(eq=False)
56
+ class Copy(File):
57
+ source: File
58
+
59
+ def build(self, run: Run) -> Path:
60
+ path = run.artifact(self, ".txt")
61
+ path.write_text(run[self.source].read_text())
62
+ return path
63
+
64
+
65
+ api = Emitter()
66
+ copy = Copy(api)
67
+ ```
68
+
69
+ ```sh
70
+ roadie .
71
+ ```
72
+
73
+ The design is in [docs/spec.md](docs/spec.md).
roadie-0.1.0/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # Roadie
2
+
3
+ Wire up multi-language, incremental builds in a few lines of Python.
4
+
5
+ A step that does not need to run does not run. Roadie knows each step's
6
+ inputs, so when they are unchanged since the step last succeeded, it reuses
7
+ that result instead of running the step; a step whose result cannot be
8
+ reused, such as a fuzzing run, says so.
9
+
10
+ Like a roadie on stage, it sets up and connects the native tools (cargo,
11
+ pnpm, uv) and lets them play, instead of replacing them.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ uv add roadie
17
+ uvx roadie
18
+ ```
19
+
20
+ ## A two-step build file
21
+
22
+ `roadie` runs the last step defined in `<directory>/build.py`, after the
23
+ steps it depends on. The second run reuses a step whose inputs have not
24
+ changed.
25
+
26
+ ```python
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+
30
+ from roadie import *
31
+ from roadie.run import Run
32
+
33
+
34
+ @dataclass(eq=False)
35
+ class Emitter(File):
36
+ text: str = "hello"
37
+
38
+ def build(self, run: Run) -> Path:
39
+ path = run.artifact(self, ".txt")
40
+ path.write_text(self.text)
41
+ return path
42
+
43
+
44
+ @dataclass(eq=False)
45
+ class Copy(File):
46
+ source: File
47
+
48
+ def build(self, run: Run) -> Path:
49
+ path = run.artifact(self, ".txt")
50
+ path.write_text(run[self.source].read_text())
51
+ return path
52
+
53
+
54
+ api = Emitter()
55
+ copy = Copy(api)
56
+ ```
57
+
58
+ ```sh
59
+ roadie .
60
+ ```
61
+
62
+ The design is in [docs/spec.md](docs/spec.md).
@@ -0,0 +1,107 @@
1
+ # Roadie
2
+
3
+ ## Goal
4
+
5
+ A step that does not need to run does not run. Roadie knows each step's
6
+ inputs, so when they are unchanged since the step last succeeded, it reuses
7
+ that result instead of running the step; a step whose result cannot be
8
+ reused, such as a fuzzing run, says so. Everything else in this spec serves
9
+ that goal.
10
+
11
+ **Roadie**: "Wire up multi-language, incremental builds in a
12
+ few lines of Python." Like a roadie on stage, it sets up and connects the
13
+ native tools (cargo, pnpm, uv) and lets them play, instead of replacing
14
+ them.
15
+
16
+ ## Concepts
17
+
18
+ Roadie defines and executes graphs of steps.
19
+
20
+ - Job: a directed acyclic graph of connected steps.
21
+ - Step: a class whose `build(run)` produces a value and whose
22
+ `teardown(value)` releases it. Attributes that are steps declare dependencies.
23
+ - Run: one execution of a plan. `run[step]` is a dependency's value;
24
+ `run.artifact(step, suffix)` names a file under `target/roadie/`.
25
+ - Result: the value a step produces. Kinds: `Binary` and `File` produce
26
+ paths, `Service` produces a running deployment, and `Tests` produces
27
+ `TestResults` containing failing test names and messages plus log messages.
28
+ Any failing test fails its step.
29
+
30
+ The executor builds dependencies first and tears down each step immediately
31
+ following its last consumer in the plan. On failure, SIGTERM or Ctrl-C it
32
+ tears down the remaining steps in reverse order. A failed step names itself
33
+ and its reason; a command failure includes the command, exit status and end
34
+ of stderr.
35
+
36
+ `CargoPackage(name)` names one crate by its Cargo name. Its input set is
37
+ the crate's local dependency closure.
38
+ A binary is fingerprinted from its inputs, not its bytes.
39
+
40
+ ## User experience
41
+
42
+ ```
43
+ roadie [--dry-run | --dry] [--locked] [--base_dir DIR] [directory] [result]
44
+ ```
45
+
46
+ - A build file is `<directory>/build.py`: Python step instances assigned to
47
+ variables. It starts with `from roadie import *`.
48
+ - Loading a build file constructs its steps and runs none. Roadie loads it
49
+ with `runpy`, so the file's directory is not on `sys.path`.
50
+ - `result` names a variable of the build file. Without it, Roadie selects the
51
+ step instantiated last. Reassigning a name does not move its original step.
52
+ - `include(directory)` loads `<directory>/build.py`, relative to the
53
+ including file, once per load, and returns its steps by variable name.
54
+ Included steps are named `<directory>:<name>`.
55
+ - A result needs only its dependencies: `api` builds the server and generates
56
+ the contract without deploying it. `--dry-run` (`--dry`) prints the plan
57
+ and runs none.
58
+ - Commands belong in step classes and Roadie utilities. `Step._cmd` echoes
59
+ and runs a command; `Step._start_cmd` starts a process, waits for its port,
60
+ and returns a `RunningProcess` to stop at teardown.
61
+
62
+ ## Enforcing declared inputs
63
+
64
+ Planned; not implemented.
65
+
66
+ A missing declaration has one of two effects:
67
+
68
+ - An input the step receives through its signature (a path, a value, an
69
+ environment variable, an earlier step's Output) is simply absent, and the
70
+ step fails.
71
+ - An input the step finds in the working tree is still on disk, so the step
72
+ passes and the fingerprint misses it. A later run replays a stale verdict.
73
+
74
+ `Files` separates a step's code (its tests and scripts) from the data it
75
+ checks: `Files(code=..., data=...)`. Undeclared reads enter through code
76
+ changes, such as a new test that opens a new file.
77
+
78
+ Enforcement runs, with the cache off, in two situations:
79
+
80
+ - nightly, for every step;
81
+ - on a change to Roadie or to the step definitions (all steps), or to a
82
+ step's code inputs (that step).
83
+
84
+ An enforcement run uses either of two modes:
85
+
86
+ - Restricted tree: the step runs in a directory holding only its declared
87
+ inputs, its Outputs-as-Inputs, and its toolchain environment. It catches a
88
+ read of a named file. A step that enumerates files can pass on the smaller
89
+ tree.
90
+ - Read tracing: the step runs in the full checkout under a file-access
91
+ tracer, and any read outside its declared inputs fails it. It catches
92
+ enumeration too. Linux only for now.
93
+
94
+ When an enforcement run finds an undeclared read, it fails and names the step
95
+ and the file. Every cached verdict for that step since the last clean
96
+ enforcement run becomes unverified, so the next run executes the step again.
97
+ Between enforcement runs, a missing declaration can replay a stale verdict
98
+ for that one step for at most a day.
99
+
100
+ ## Backlog
101
+
102
+ - `AuthorTask`: a `Tests` step that waits for the author to run checks and
103
+ report the results.
104
+ - `AuthorApproval`: an author task that continues the run or cancels it.
105
+ - Enforce declared inputs (the design above).
106
+ - A project-root override (`config(root=...)`) when a project's root is not
107
+ its git top level.
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "roadie"
3
+ version = "0.1.0"
4
+ description = "Wire up multi-language, incremental builds in a few lines of Python."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.13"
9
+ dependencies = [
10
+ "cyclopts>=4.20.0,<5.0.0",
11
+ "python-dotenv>=1.2.1,<2.0.0",
12
+ ]
13
+
14
+ [project.scripts]
15
+ roadie = "roadie.cli:main"
16
+
17
+ [dependency-groups]
18
+ dev = [
19
+ "mypy",
20
+ "pytest",
21
+ "ruff",
22
+ ]
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/roadie"]
@@ -0,0 +1,27 @@
1
+ from roadie.cargo import CargoPackage
2
+ from roadie.config import config
3
+ from roadie.errors import RoadieError
4
+ from roadie.graph import include
5
+ from roadie.inputs import Files
6
+ from roadie.step import Binary, Deployment, File, HttpService, Service, Step, Tests
7
+ from roadie.steps import Group, RustBinary
8
+ from roadie.testing import TestFailure, TestResults
9
+
10
+ __all__ = [
11
+ "Binary",
12
+ "CargoPackage",
13
+ "Deployment",
14
+ "File",
15
+ "Files",
16
+ "Group",
17
+ "HttpService",
18
+ "RoadieError",
19
+ "RustBinary",
20
+ "Service",
21
+ "Step",
22
+ "TestFailure",
23
+ "TestResults",
24
+ "Tests",
25
+ "config",
26
+ "include",
27
+ ]
@@ -0,0 +1,205 @@
1
+ """Cargo workspace packages and the local dependency closure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import subprocess
9
+ from collections.abc import Iterator, Sequence
10
+ from contextlib import contextmanager
11
+ from contextvars import ContextVar
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from roadie.errors import RoadieError
16
+ from roadie.inputs import Input, digest
17
+
18
+ _CARGO_ENV_EXACT = frozenset({"RUSTFLAGS", "RUSTDOCFLAGS", "CARGO_ENCODED_RUSTFLAGS"})
19
+ _CARGO_ENV_PREFIXES = ("CARGO_PROFILE_", "CARGO_BUILD_")
20
+ _ROOT_FILES = ("Cargo.toml", "Cargo.lock", "rust-toolchain.toml", ".cargo/config.toml")
21
+ _cache: ContextVar[dict[Path, CargoSnapshot] | None] = ContextVar(
22
+ "roadie_cargo_snapshot", default=None
23
+ )
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class RustPackage:
28
+ name: str
29
+ root: Path
30
+ local_dependencies: tuple[str, ...]
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class CargoWorkspace:
35
+ root: Path
36
+ packages: tuple[RustPackage, ...]
37
+
38
+
39
+ def cargo_rust_packages(directory: Path) -> CargoWorkspace:
40
+ result = subprocess.run(
41
+ ("cargo", "metadata", "--no-deps", "--format-version", "1"),
42
+ cwd=directory,
43
+ check=True,
44
+ capture_output=True,
45
+ text=True,
46
+ )
47
+ metadata = json.loads(result.stdout)
48
+ root = Path(metadata["workspace_root"]).resolve()
49
+ workspace_names = {package["name"] for package in metadata["packages"]}
50
+ packages = []
51
+ for package in metadata["packages"]:
52
+ manifest = Path(package["manifest_path"])
53
+ try:
54
+ package_root = manifest.parent.resolve().relative_to(root)
55
+ except ValueError:
56
+ continue
57
+ local_dependencies = tuple(
58
+ dependency["name"]
59
+ for dependency in package["dependencies"]
60
+ if dependency["name"] in workspace_names
61
+ )
62
+ packages.append(RustPackage(package["name"], package_root, local_dependencies))
63
+ return CargoWorkspace(root, tuple(packages))
64
+
65
+
66
+ def select_rust_packages(
67
+ packages: Sequence[RustPackage],
68
+ changed_paths: Sequence[Path],
69
+ ) -> tuple[str, ...]:
70
+ """Packages whose files changed, plus every workspace package that depends on them."""
71
+ if any(path.as_posix() in {"Cargo.toml", "Cargo.lock"} for path in changed_paths):
72
+ return tuple(package.name for package in packages)
73
+
74
+ selected = {
75
+ package.name
76
+ for package in packages
77
+ if any(
78
+ path == package.root or package.root in path.parents
79
+ for path in changed_paths
80
+ )
81
+ }
82
+ changed = True
83
+ while changed:
84
+ changed = False
85
+ for package in packages:
86
+ if package.name in selected:
87
+ continue
88
+ if selected.intersection(package.local_dependencies):
89
+ selected.add(package.name)
90
+ changed = True
91
+ return tuple(package.name for package in packages if package.name in selected)
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class CargoSnapshot:
96
+ workspace: CargoWorkspace
97
+ rustc: str
98
+
99
+
100
+ @contextmanager
101
+ def cargo_snapshot() -> Iterator[None]:
102
+ """Reuse cargo metadata and rustc --version for every fingerprint in the block."""
103
+ token = _cache.set({})
104
+ try:
105
+ yield
106
+ finally:
107
+ _cache.reset(token)
108
+
109
+
110
+ def _load_snapshot(directory: Path) -> CargoSnapshot:
111
+ version = subprocess.run(
112
+ ("rustc", "--version"),
113
+ cwd=directory,
114
+ check=True,
115
+ capture_output=True,
116
+ text=True,
117
+ )
118
+ return CargoSnapshot(cargo_rust_packages(directory), version.stdout.strip())
119
+
120
+
121
+ def _snapshot(directory: Path) -> CargoSnapshot:
122
+ directory = directory.resolve()
123
+ current = _cache.get()
124
+ if isinstance(current, dict):
125
+ cached = _cached_snapshot(current, directory)
126
+ if cached is not None:
127
+ return cached
128
+ loaded = _load_snapshot(directory)
129
+ if isinstance(current, dict):
130
+ current.setdefault(loaded.workspace.root, loaded)
131
+ return current[loaded.workspace.root]
132
+ return loaded
133
+
134
+
135
+ def _cached_snapshot(
136
+ cache: dict[Path, CargoSnapshot], directory: Path
137
+ ) -> CargoSnapshot | None:
138
+ for root, snapshot in cache.items():
139
+ if directory.is_relative_to(root):
140
+ return snapshot
141
+ return None
142
+
143
+
144
+ def _cargo_environment() -> dict[str, str]:
145
+ return {
146
+ key: value
147
+ for key, value in sorted(os.environ.items())
148
+ if key in _CARGO_ENV_EXACT or key.startswith(_CARGO_ENV_PREFIXES)
149
+ }
150
+
151
+
152
+ def _closure(packages: Sequence[RustPackage], name: str) -> tuple[RustPackage, ...]:
153
+ by_name = {package.name: package for package in packages}
154
+ if name not in by_name:
155
+ raise RoadieError(f"cargo package {name!r} is not in the workspace")
156
+ selected: set[str] = set()
157
+ pending = [name]
158
+ while pending:
159
+ current = pending.pop()
160
+ if current in selected:
161
+ continue
162
+ selected.add(current)
163
+ pending.extend(by_name[current].local_dependencies)
164
+ return tuple(package for package in packages if package.name in selected)
165
+
166
+
167
+ def _git_files(root: Path, paths: Sequence[str]) -> list[str]:
168
+ if not paths:
169
+ return []
170
+ result = subprocess.run(
171
+ ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", *paths],
172
+ cwd=root,
173
+ check=True,
174
+ capture_output=True,
175
+ )
176
+ return [item for item in result.stdout.decode().split("\0") if item]
177
+
178
+
179
+ def _content(root: Path, relative: str) -> str:
180
+ path = root / relative
181
+ if not path.is_file():
182
+ return "missing"
183
+ return hashlib.sha256(path.read_bytes()).hexdigest()
184
+
185
+
186
+ @dataclass(frozen=True)
187
+ class CargoPackage(Input):
188
+ name: str
189
+
190
+ def fingerprint(self, base_dir: Path) -> str:
191
+ snapshot = _snapshot(base_dir)
192
+ root = snapshot.workspace.root
193
+ packages = _closure(snapshot.workspace.packages, self.name)
194
+ listed = set(_git_files(root, [package.root.as_posix() for package in packages]))
195
+ for name in _ROOT_FILES:
196
+ if (root / name).exists():
197
+ listed.add(name)
198
+ return digest(
199
+ {
200
+ "package": self.name,
201
+ "rustc": snapshot.rustc,
202
+ "env": _cargo_environment(),
203
+ "files": {path: _content(root, path) for path in listed},
204
+ }
205
+ )
@@ -0,0 +1,92 @@
1
+ """Run a result of a Roadie build file and the steps it depends on."""
2
+
3
+ from collections.abc import Sequence
4
+ from dataclasses import fields, is_dataclass
5
+ from pathlib import Path
6
+ from typing import Annotated, Any
7
+
8
+ from cyclopts import App, Parameter
9
+
10
+ from roadie.graph import RoadieError, load
11
+ from roadie.run import execute, reuse_markers
12
+ from roadie.step import Step
13
+ from roadie.steps import Group
14
+
15
+ app = App(help=__doc__, version_flags=())
16
+
17
+
18
+ @app.default
19
+ def run(
20
+ directory: Path = Path("."),
21
+ result: str | None = None,
22
+ *,
23
+ base_dir: Annotated[Path | None, Parameter(name="--base_dir", help="Resolve step paths and commands from this directory")] = None,
24
+ locked: Annotated[bool, Parameter(negative=False, help="Fail when generated tracked files would change")] = False,
25
+ env: Annotated[
26
+ Sequence[Path] | None,
27
+ Parameter(name="--env", help="KEY=VALUE environment file; may be repeated", negative=False),
28
+ ] = None,
29
+ dry_run: Annotated[
30
+ bool,
31
+ Parameter(
32
+ name=["--dry-run", "--dry"],
33
+ negative=False,
34
+ help="Print the steps the result needs and run none",
35
+ ),
36
+ ] = False,
37
+ ) -> None:
38
+ """Run `result` of `<directory>/build.py`, by default the last one defined."""
39
+ try:
40
+ graph = load(directory, base_dir=base_dir)
41
+ steps = graph.plan(result)
42
+ except RoadieError as error:
43
+ raise SystemExit(str(error)) from error
44
+ if dry_run:
45
+ try:
46
+ marks = reuse_markers(steps, root=graph.root, env=env or ())
47
+ except RoadieError as error:
48
+ raise SystemExit(str(error)) from error
49
+ for step, reused in zip(steps, marks, strict=True):
50
+ line = f"{step.name} = {call(step)}" if step.name else call(step)
51
+ print(f"{line} # {'reuse' if reused else 'run'}")
52
+ return
53
+ try:
54
+ execute(steps, root=graph.root, locked=locked, env=env or ())
55
+ except RoadieError as error:
56
+ raise SystemExit(str(error)) from error
57
+
58
+
59
+ def call(result: Step[Any]) -> str:
60
+ if isinstance(result, Group):
61
+ arguments = [argument(value) for value in result.steps]
62
+ else:
63
+ attributes = result.attributes()
64
+ keywords: set[str] = {item.name for item in fields(result) if item.kw_only} if is_dataclass(result) else set()
65
+ arguments = [
66
+ argument(value)
67
+ for key, value in attributes.items()
68
+ if key not in keywords and value is not None
69
+ ]
70
+ arguments.extend(
71
+ f"{key}={argument(value)}"
72
+ for key, value in attributes.items()
73
+ if key in keywords and value is not None
74
+ )
75
+ return f"{type(result).__name__}({', '.join(arguments)})"
76
+
77
+
78
+ def argument(value: object) -> str:
79
+ if isinstance(value, Step):
80
+ return reference(value)
81
+ if isinstance(value, tuple):
82
+ items = ", ".join(argument(item) for item in value)
83
+ return f"({items}{',' if len(value) == 1 else ''})"
84
+ return repr(value)
85
+
86
+
87
+ def reference(result: Step[Any]) -> str:
88
+ return result.name or call(result)
89
+
90
+
91
+ def main() -> None:
92
+ app()