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.
- roadie-0.1.0/.github/workflows/ci.yml +28 -0
- roadie-0.1.0/.github/workflows/release.yml +33 -0
- roadie-0.1.0/.gitignore +10 -0
- roadie-0.1.0/LICENSE +21 -0
- roadie-0.1.0/PKG-INFO +73 -0
- roadie-0.1.0/README.md +62 -0
- roadie-0.1.0/docs/spec.md +107 -0
- roadie-0.1.0/pyproject.toml +29 -0
- roadie-0.1.0/src/roadie/__init__.py +27 -0
- roadie-0.1.0/src/roadie/cargo.py +205 -0
- roadie-0.1.0/src/roadie/cli.py +92 -0
- roadie-0.1.0/src/roadie/config.py +73 -0
- roadie-0.1.0/src/roadie/errors.py +7 -0
- roadie-0.1.0/src/roadie/fingerprint.py +112 -0
- roadie-0.1.0/src/roadie/graph.py +149 -0
- roadie-0.1.0/src/roadie/inputs.py +96 -0
- roadie-0.1.0/src/roadie/output.py +60 -0
- roadie-0.1.0/src/roadie/process.py +275 -0
- roadie-0.1.0/src/roadie/py.typed +0 -0
- roadie-0.1.0/src/roadie/records.py +41 -0
- roadie-0.1.0/src/roadie/root.py +41 -0
- roadie-0.1.0/src/roadie/run.py +384 -0
- roadie-0.1.0/src/roadie/step.py +181 -0
- roadie-0.1.0/src/roadie/steps.py +95 -0
- roadie-0.1.0/src/roadie/testing.py +65 -0
- roadie-0.1.0/tests/fixtures/cargo-build-jammer-server.jsonl +6 -0
- roadie-0.1.0/tests/fixtures/cargo-build-sample.jsonl +6 -0
- roadie-0.1.0/tests/fixtures/http_service.py +41 -0
- roadie-0.1.0/tests/test_roadie_cargo.py +205 -0
- roadie-0.1.0/tests/test_roadie_graph.py +323 -0
- roadie-0.1.0/tests/test_roadie_process.py +219 -0
- roadie-0.1.0/tests/test_roadie_run.py +1378 -0
- roadie-0.1.0/tests/test_roadie_testing.py +61 -0
- roadie-0.1.0/uv.lock +436 -0
|
@@ -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
|
roadie-0.1.0/.gitignore
ADDED
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()
|