brake 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.
- brake-0.1.0/PKG-INFO +219 -0
- brake-0.1.0/README.md +201 -0
- brake-0.1.0/brake/cli.py +87 -0
- brake-0.1.0/brake/colors.py +16 -0
- brake-0.1.0/brake/exceptions.py +19 -0
- brake-0.1.0/brake/graph.py +233 -0
- brake-0.1.0/brake/model.py +51 -0
- brake-0.1.0/brake/parser.py +90 -0
- brake-0.1.0/brake/runner.py +65 -0
- brake-0.1.0/pyproject.toml +40 -0
brake-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: brake
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Minimalistic yet powerful build tool
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Balthazar Rouberol
|
|
7
|
+
Author-email: br@imap.cc
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Requires-Dist: parsimonious (>=0.11.0,<0.12.0)
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Brake
|
|
19
|
+
|
|
20
|
+
A minimalistic build system.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
## Why another build system?
|
|
24
|
+
I've been using `make` for years, and probably use 10% of what it can really do. Over time, I have established patterns that I'm reusing in all (or most) projects:
|
|
25
|
+
|
|
26
|
+
- [autodocumenting](https://blog.balthazar-rouberol.com/just-enough-makefile-to-be-dangerous#makefile-auto-documentation-as-the-default-step) the public facing targets
|
|
27
|
+
- providing a target in charge of visually rendering the make graph, to help debug dependencies
|
|
28
|
+
|
|
29
|
+
These patterns rely on a [number](https://git.balthazar-rouberol.com/brouberol/5esheets/src/branch/main/Makefile#L189) of [hacks](https://gitlab.wikimedia.org/repos/data-engineering/airflow-dags/-/merge_requests/2084) that I've been cargo culting in different projetcs, because `make` does not provide me with the level of annotation and introspection capabilities required to implement these features simply.
|
|
30
|
+
|
|
31
|
+
I've also grown tired about some `make` behaviors over the years:
|
|
32
|
+
|
|
33
|
+
- the implicitness of whether a target runs a task or builds a file
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
$ cat Makefile
|
|
37
|
+
test:
|
|
38
|
+
echo "testing"
|
|
39
|
+
$ make test
|
|
40
|
+
echo "testing"
|
|
41
|
+
testing
|
|
42
|
+
$ touch test
|
|
43
|
+
$ make test
|
|
44
|
+
make: `test' is up to date.
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- more generally, the sheer amount of implict behavior (run `make -p` and stare into the horizon)
|
|
48
|
+
- the lack of builtin way to publicy document targets
|
|
49
|
+
- the crazy [syntax](https://devhints.io/makefile) that looks like bash but really isn't
|
|
50
|
+
|
|
51
|
+
I set out to write my own built system that would be based on the following principles:
|
|
52
|
+
- no implicit behavior
|
|
53
|
+
- builtin target introspection and documentation
|
|
54
|
+
- automatic parallel builds
|
|
55
|
+
- heavily tested
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
## How does it work
|
|
59
|
+
|
|
60
|
+
All targets are defined in a file, called `Brakefile` by default.
|
|
61
|
+
|
|
62
|
+
TLDR: `brake` itself is built with itself, so have a look at the `Brakefile` in this project to see what features it has (or not).
|
|
63
|
+
|
|
64
|
+
### Defining a target
|
|
65
|
+
|
|
66
|
+
The simplest `break` task you can define is
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
@task
|
|
70
|
+
test:
|
|
71
|
+
pytest .
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
This defines a target of type `task`, that runs `pytest .` when executed.
|
|
75
|
+
|
|
76
|
+
Commands are assumed to be bash, and are executed _line by line_, instead of in a single go. Although this may change in the future, the design goal is to only have the simplest commands be part of the `Brakefile`. Anything more than a oneliner should go into a script (whether python, bash or anything else) and be called from the `Brakefile`.
|
|
77
|
+
|
|
78
|
+
### Target inter-dependencies
|
|
79
|
+
|
|
80
|
+
You can define interdependant targets using the `deps` target argument:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
@task
|
|
84
|
+
test:
|
|
85
|
+
pytest .
|
|
86
|
+
|
|
87
|
+
@task
|
|
88
|
+
check:
|
|
89
|
+
ruff check .
|
|
90
|
+
|
|
91
|
+
@task(deps=[test, check])
|
|
92
|
+
ci:
|
|
93
|
+
```
|
|
94
|
+
This way, when running `break run ci`, both `test` and `check` tasks will be executed. As the `ci` target has no associated command, it only acts as a dependency placeholder.
|
|
95
|
+
|
|
96
|
+
### Documenting targets
|
|
97
|
+
|
|
98
|
+
You can document each target by annotating them with a `description` argument.
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
@task(description="Run unit tests")
|
|
102
|
+
test:
|
|
103
|
+
pytest .
|
|
104
|
+
|
|
105
|
+
@task(description="Run linter checks")
|
|
106
|
+
check:
|
|
107
|
+
ruff check .
|
|
108
|
+
|
|
109
|
+
@task(deps=[test, check], description="Run all tests and linters")
|
|
110
|
+
ci:
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
You can then get the help for your targets by running `brake help`
|
|
114
|
+
```
|
|
115
|
+
check Run linter checks
|
|
116
|
+
ci Run all tests and linters
|
|
117
|
+
test Run unit tests
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### `@task` vs `@file`
|
|
121
|
+
|
|
122
|
+
`brake` can deal with 2 types of targets:
|
|
123
|
+
|
|
124
|
+
- `task`: defines what a task does (ex: running tests, formatting the codebase, applying database migrations, etc)
|
|
125
|
+
- `file`: defines how a file gets built (ex: compiling source code, running some codegen script, etc)
|
|
126
|
+
|
|
127
|
+
The following rules apply:
|
|
128
|
+
|
|
129
|
+
- A `task` target can depend on both `file` or `task` targets
|
|
130
|
+
- A `file` target can **only depend on other `file` target(s)**
|
|
131
|
+
- A `file` target will be rebuilt if it does not exist on disk, or if any of its `file` dependencis was modified _after_ the file itself.
|
|
132
|
+
- A `file` target name can be composed of `*`, which will be expanded as a simple [glob](https://en.wikipedia.org/wiki/Glob_(programming)) pattern
|
|
133
|
+
|
|
134
|
+
Take a look at [`example_c/Brakefile`](./example_c/Brakefile) to see an example of a `Brakefile` mixing both `task` and `file` targets, aiming at building a very simple C program.
|
|
135
|
+
|
|
136
|
+
### Defining a default target
|
|
137
|
+
|
|
138
|
+
In the same way that `make` lets you define a default targt with `.DEFAULT_GOAL`, you can define which target will be built by default if no argument is provided to `brake run`.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
@task(description="Run unit tests")
|
|
142
|
+
test:
|
|
143
|
+
pytest .
|
|
144
|
+
|
|
145
|
+
@task(description="Run linter checks")
|
|
146
|
+
check:
|
|
147
|
+
ruff check .
|
|
148
|
+
|
|
149
|
+
@task(deps=[test, check], description="Run all tests and linters", default=true)
|
|
150
|
+
ci:
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Visualizing the target graph
|
|
154
|
+
|
|
155
|
+
You can use the `brake graph` command to export the target graph into a format that can itself be exported to an image. The default format is [dot](https://graphviz.org/doc/info/lang.html), but [mermaid](https://mermaid.js.org/) is also supported by passing `--syntax=mermaid`.
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
$ brake graph > brake.dot
|
|
159
|
+
$ dot -Tsvg brake.dot -o brake.svg
|
|
160
|
+
```
|
|
161
|
+

|
|
162
|
+
|
|
163
|
+
### Parallel target builds
|
|
164
|
+
|
|
165
|
+
Looking at the target graph form the previous section, we can see that running the `lint` task would run both the `lint.check` and `lint.format` dependency tasks. As each of these tasks are independant, they are run in parallel, through a process pool of available number of CPUs by default (configurable via the `-j` argument).
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
$ brake run lint
|
|
169
|
+
[task:lint.check] poetry run ruff check .
|
|
170
|
+
[task:lint.format] poetry run ruff format --check .
|
|
171
|
+
11 files already formatted
|
|
172
|
+
All checks passed!
|
|
173
|
+
```
|
|
174
|
+
By setting `-j1`, you can ensure that each task gets executed serially instead.
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
$ brake -j1 run lint
|
|
178
|
+
[task:lint.check] poetry run ruff check .
|
|
179
|
+
All checks passed!
|
|
180
|
+
[task:lint.format] poetry run ruff format --check .
|
|
181
|
+
11 files already formatted
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Usage
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
brake --help
|
|
188
|
+
usage: brake [-h] [-j MAX_JOBS] [-f FILE] {run,help,graph} ...
|
|
189
|
+
|
|
190
|
+
A minimalistic yet powerful build tool
|
|
191
|
+
|
|
192
|
+
positional arguments:
|
|
193
|
+
{run,help,graph}
|
|
194
|
+
run Run a task
|
|
195
|
+
help Display the targets help
|
|
196
|
+
graph Display the targets as a graph
|
|
197
|
+
|
|
198
|
+
options:
|
|
199
|
+
-h, --help show this help message and exit
|
|
200
|
+
-j, --max-jobs MAX_JOBS
|
|
201
|
+
The maximum number of jobs to run in parallel (default: 10)
|
|
202
|
+
-f, --file FILE Path to the file containing the brake targets (default: Brakefile)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Roadmap
|
|
206
|
+
|
|
207
|
+
- [ ] Defining variables
|
|
208
|
+
- [ ] Adding a `--explain` mode that wouldn't build the targets, but only explain what would get built and what wouldn't
|
|
209
|
+
- [ ] Release publicly
|
|
210
|
+
- [ ] Write some syntax highlighters for the Brake grammar
|
|
211
|
+
|
|
212
|
+
## Why the name `brake`?
|
|
213
|
+
|
|
214
|
+
There are at least 3 reasons. Use the one you prefer.
|
|
215
|
+
|
|
216
|
+
1. So I can be able to say "this is a make-or-break" tool and sound smart
|
|
217
|
+
1. It sounds like `break`, which is the semantic opposite to `make`
|
|
218
|
+
1. Balthazar Rouberol's `make`.
|
|
219
|
+
|
brake-0.1.0/README.md
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# Brake
|
|
2
|
+
|
|
3
|
+
A minimalistic build system.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
## Why another build system?
|
|
7
|
+
I've been using `make` for years, and probably use 10% of what it can really do. Over time, I have established patterns that I'm reusing in all (or most) projects:
|
|
8
|
+
|
|
9
|
+
- [autodocumenting](https://blog.balthazar-rouberol.com/just-enough-makefile-to-be-dangerous#makefile-auto-documentation-as-the-default-step) the public facing targets
|
|
10
|
+
- providing a target in charge of visually rendering the make graph, to help debug dependencies
|
|
11
|
+
|
|
12
|
+
These patterns rely on a [number](https://git.balthazar-rouberol.com/brouberol/5esheets/src/branch/main/Makefile#L189) of [hacks](https://gitlab.wikimedia.org/repos/data-engineering/airflow-dags/-/merge_requests/2084) that I've been cargo culting in different projetcs, because `make` does not provide me with the level of annotation and introspection capabilities required to implement these features simply.
|
|
13
|
+
|
|
14
|
+
I've also grown tired about some `make` behaviors over the years:
|
|
15
|
+
|
|
16
|
+
- the implicitness of whether a target runs a task or builds a file
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
$ cat Makefile
|
|
20
|
+
test:
|
|
21
|
+
echo "testing"
|
|
22
|
+
$ make test
|
|
23
|
+
echo "testing"
|
|
24
|
+
testing
|
|
25
|
+
$ touch test
|
|
26
|
+
$ make test
|
|
27
|
+
make: `test' is up to date.
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- more generally, the sheer amount of implict behavior (run `make -p` and stare into the horizon)
|
|
31
|
+
- the lack of builtin way to publicy document targets
|
|
32
|
+
- the crazy [syntax](https://devhints.io/makefile) that looks like bash but really isn't
|
|
33
|
+
|
|
34
|
+
I set out to write my own built system that would be based on the following principles:
|
|
35
|
+
- no implicit behavior
|
|
36
|
+
- builtin target introspection and documentation
|
|
37
|
+
- automatic parallel builds
|
|
38
|
+
- heavily tested
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
## How does it work
|
|
42
|
+
|
|
43
|
+
All targets are defined in a file, called `Brakefile` by default.
|
|
44
|
+
|
|
45
|
+
TLDR: `brake` itself is built with itself, so have a look at the `Brakefile` in this project to see what features it has (or not).
|
|
46
|
+
|
|
47
|
+
### Defining a target
|
|
48
|
+
|
|
49
|
+
The simplest `break` task you can define is
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
@task
|
|
53
|
+
test:
|
|
54
|
+
pytest .
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
This defines a target of type `task`, that runs `pytest .` when executed.
|
|
58
|
+
|
|
59
|
+
Commands are assumed to be bash, and are executed _line by line_, instead of in a single go. Although this may change in the future, the design goal is to only have the simplest commands be part of the `Brakefile`. Anything more than a oneliner should go into a script (whether python, bash or anything else) and be called from the `Brakefile`.
|
|
60
|
+
|
|
61
|
+
### Target inter-dependencies
|
|
62
|
+
|
|
63
|
+
You can define interdependant targets using the `deps` target argument:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
@task
|
|
67
|
+
test:
|
|
68
|
+
pytest .
|
|
69
|
+
|
|
70
|
+
@task
|
|
71
|
+
check:
|
|
72
|
+
ruff check .
|
|
73
|
+
|
|
74
|
+
@task(deps=[test, check])
|
|
75
|
+
ci:
|
|
76
|
+
```
|
|
77
|
+
This way, when running `break run ci`, both `test` and `check` tasks will be executed. As the `ci` target has no associated command, it only acts as a dependency placeholder.
|
|
78
|
+
|
|
79
|
+
### Documenting targets
|
|
80
|
+
|
|
81
|
+
You can document each target by annotating them with a `description` argument.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
@task(description="Run unit tests")
|
|
85
|
+
test:
|
|
86
|
+
pytest .
|
|
87
|
+
|
|
88
|
+
@task(description="Run linter checks")
|
|
89
|
+
check:
|
|
90
|
+
ruff check .
|
|
91
|
+
|
|
92
|
+
@task(deps=[test, check], description="Run all tests and linters")
|
|
93
|
+
ci:
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
You can then get the help for your targets by running `brake help`
|
|
97
|
+
```
|
|
98
|
+
check Run linter checks
|
|
99
|
+
ci Run all tests and linters
|
|
100
|
+
test Run unit tests
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### `@task` vs `@file`
|
|
104
|
+
|
|
105
|
+
`brake` can deal with 2 types of targets:
|
|
106
|
+
|
|
107
|
+
- `task`: defines what a task does (ex: running tests, formatting the codebase, applying database migrations, etc)
|
|
108
|
+
- `file`: defines how a file gets built (ex: compiling source code, running some codegen script, etc)
|
|
109
|
+
|
|
110
|
+
The following rules apply:
|
|
111
|
+
|
|
112
|
+
- A `task` target can depend on both `file` or `task` targets
|
|
113
|
+
- A `file` target can **only depend on other `file` target(s)**
|
|
114
|
+
- A `file` target will be rebuilt if it does not exist on disk, or if any of its `file` dependencis was modified _after_ the file itself.
|
|
115
|
+
- A `file` target name can be composed of `*`, which will be expanded as a simple [glob](https://en.wikipedia.org/wiki/Glob_(programming)) pattern
|
|
116
|
+
|
|
117
|
+
Take a look at [`example_c/Brakefile`](./example_c/Brakefile) to see an example of a `Brakefile` mixing both `task` and `file` targets, aiming at building a very simple C program.
|
|
118
|
+
|
|
119
|
+
### Defining a default target
|
|
120
|
+
|
|
121
|
+
In the same way that `make` lets you define a default targt with `.DEFAULT_GOAL`, you can define which target will be built by default if no argument is provided to `brake run`.
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
@task(description="Run unit tests")
|
|
125
|
+
test:
|
|
126
|
+
pytest .
|
|
127
|
+
|
|
128
|
+
@task(description="Run linter checks")
|
|
129
|
+
check:
|
|
130
|
+
ruff check .
|
|
131
|
+
|
|
132
|
+
@task(deps=[test, check], description="Run all tests and linters", default=true)
|
|
133
|
+
ci:
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Visualizing the target graph
|
|
137
|
+
|
|
138
|
+
You can use the `brake graph` command to export the target graph into a format that can itself be exported to an image. The default format is [dot](https://graphviz.org/doc/info/lang.html), but [mermaid](https://mermaid.js.org/) is also supported by passing `--syntax=mermaid`.
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
$ brake graph > brake.dot
|
|
142
|
+
$ dot -Tsvg brake.dot -o brake.svg
|
|
143
|
+
```
|
|
144
|
+

|
|
145
|
+
|
|
146
|
+
### Parallel target builds
|
|
147
|
+
|
|
148
|
+
Looking at the target graph form the previous section, we can see that running the `lint` task would run both the `lint.check` and `lint.format` dependency tasks. As each of these tasks are independant, they are run in parallel, through a process pool of available number of CPUs by default (configurable via the `-j` argument).
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
$ brake run lint
|
|
152
|
+
[task:lint.check] poetry run ruff check .
|
|
153
|
+
[task:lint.format] poetry run ruff format --check .
|
|
154
|
+
11 files already formatted
|
|
155
|
+
All checks passed!
|
|
156
|
+
```
|
|
157
|
+
By setting `-j1`, you can ensure that each task gets executed serially instead.
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
$ brake -j1 run lint
|
|
161
|
+
[task:lint.check] poetry run ruff check .
|
|
162
|
+
All checks passed!
|
|
163
|
+
[task:lint.format] poetry run ruff format --check .
|
|
164
|
+
11 files already formatted
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Usage
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
brake --help
|
|
171
|
+
usage: brake [-h] [-j MAX_JOBS] [-f FILE] {run,help,graph} ...
|
|
172
|
+
|
|
173
|
+
A minimalistic yet powerful build tool
|
|
174
|
+
|
|
175
|
+
positional arguments:
|
|
176
|
+
{run,help,graph}
|
|
177
|
+
run Run a task
|
|
178
|
+
help Display the targets help
|
|
179
|
+
graph Display the targets as a graph
|
|
180
|
+
|
|
181
|
+
options:
|
|
182
|
+
-h, --help show this help message and exit
|
|
183
|
+
-j, --max-jobs MAX_JOBS
|
|
184
|
+
The maximum number of jobs to run in parallel (default: 10)
|
|
185
|
+
-f, --file FILE Path to the file containing the brake targets (default: Brakefile)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Roadmap
|
|
189
|
+
|
|
190
|
+
- [ ] Defining variables
|
|
191
|
+
- [ ] Adding a `--explain` mode that wouldn't build the targets, but only explain what would get built and what wouldn't
|
|
192
|
+
- [ ] Release publicly
|
|
193
|
+
- [ ] Write some syntax highlighters for the Brake grammar
|
|
194
|
+
|
|
195
|
+
## Why the name `brake`?
|
|
196
|
+
|
|
197
|
+
There are at least 3 reasons. Use the one you prefer.
|
|
198
|
+
|
|
199
|
+
1. So I can be able to say "this is a make-or-break" tool and sound smart
|
|
200
|
+
1. It sounds like `break`, which is the semantic opposite to `make`
|
|
201
|
+
1. Balthazar Rouberol's `make`.
|
brake-0.1.0/brake/cli.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from brake.colors import Color, colorize
|
|
7
|
+
from brake.graph import TargetGraph
|
|
8
|
+
from brake.runner import TargetGraphRunner
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_args() -> argparse.Namespace:
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
description="A minimalistic yet powerful build tool",
|
|
14
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"-j",
|
|
18
|
+
"--max-jobs",
|
|
19
|
+
type=int,
|
|
20
|
+
default=os.cpu_count(),
|
|
21
|
+
help="The maximum number of jobs to run in parallel",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"-f",
|
|
25
|
+
"--file",
|
|
26
|
+
help="Path to the file containing the brake targets",
|
|
27
|
+
type=Path,
|
|
28
|
+
default="Brakefile",
|
|
29
|
+
)
|
|
30
|
+
subparsers = parser.add_subparsers()
|
|
31
|
+
run_parser = subparsers.add_parser("run", help="Run a task")
|
|
32
|
+
run_parser.add_argument("target", help="The target to run", nargs="?", default=None)
|
|
33
|
+
run_parser.set_defaults(func=run_target)
|
|
34
|
+
|
|
35
|
+
help_parser = subparsers.add_parser("help", help="Display the targets help")
|
|
36
|
+
help_parser.set_defaults(func=show_targets_help)
|
|
37
|
+
|
|
38
|
+
mermaid_parser = subparsers.add_parser("graph", help="Display the targets as a graph")
|
|
39
|
+
mermaid_parser.add_argument(
|
|
40
|
+
"--syntax",
|
|
41
|
+
help="The graph syntax to display the graph in",
|
|
42
|
+
choices=("dot", "mermaid"),
|
|
43
|
+
default="dot",
|
|
44
|
+
)
|
|
45
|
+
mermaid_parser.set_defaults(func=show_targets_graph)
|
|
46
|
+
args = parser.parse_args()
|
|
47
|
+
if not hasattr(args, "func") or not args.func:
|
|
48
|
+
print(parser.format_help())
|
|
49
|
+
sys.exit(0)
|
|
50
|
+
return args
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_target(graph: TargetGraph, args: argparse.Namespace):
|
|
54
|
+
runner = TargetGraphRunner(target_graph=graph)
|
|
55
|
+
try:
|
|
56
|
+
runner.run(args.target, max_workers=args.max_jobs)
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
print(colorize(str(exc), color=Color.RED))
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def show_targets_help(graph: TargetGraph, args: argparse.Namespace):
|
|
63
|
+
print("\n".join(graph.help()))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def show_targets_graph(graph: TargetGraph, args: argparse.Namespace):
|
|
67
|
+
if args.syntax == "dot":
|
|
68
|
+
lines = graph.as_dot()
|
|
69
|
+
else:
|
|
70
|
+
lines = graph.as_mermaid()
|
|
71
|
+
print("\n".join(lines))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run():
|
|
75
|
+
args = parse_args()
|
|
76
|
+
graph_str = args.file.read_text()
|
|
77
|
+
graph = TargetGraph.from_str(graph_str)
|
|
78
|
+
os.chdir(args.file.parent)
|
|
79
|
+
if errors := graph.validate():
|
|
80
|
+
for error in errors:
|
|
81
|
+
print(colorize(str(error), color=Color.RED))
|
|
82
|
+
sys.exit(1)
|
|
83
|
+
args.func(graph, args)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
run()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Color(StrEnum):
|
|
5
|
+
RED = "\033[0;31m"
|
|
6
|
+
GREEN = "\033[0;32m"
|
|
7
|
+
BROWN = "\033[0;33m"
|
|
8
|
+
BLUE = "\033[0;34m"
|
|
9
|
+
PURPLE = "\033[0;35m"
|
|
10
|
+
CYAN = "\033[0;36m"
|
|
11
|
+
YELLOW = "\033[1;33m"
|
|
12
|
+
RESET = "\x1b[0m"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def colorize(s: str, color: Color) -> str:
|
|
16
|
+
return f"{color}{s}{Color.RESET}"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class BaseBrakeException(Exception):
|
|
2
|
+
def __eq__(self, other) -> bool:
|
|
3
|
+
return type(self) is type(other) and self.args == other.args
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CyclicGraph(BaseBrakeException):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InvalidDependency(BaseBrakeException):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DuplicatedTarget(BaseBrakeException):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MultipleDefaultTargets(BaseBrakeException):
|
|
19
|
+
pass
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import operator as op
|
|
2
|
+
from collections import Counter, defaultdict
|
|
3
|
+
|
|
4
|
+
from brake.colors import Color, colorize
|
|
5
|
+
from brake.exceptions import CyclicGraph, DuplicatedTarget, InvalidDependency, MultipleDefaultTargets
|
|
6
|
+
from brake.model import Target
|
|
7
|
+
from brake.parser import TargetVisitor, grammar
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TargetGraph:
|
|
11
|
+
"""Core object representing the graph of targets to run"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, targets: list[Target]):
|
|
14
|
+
self.targets = targets
|
|
15
|
+
self.target_maps = {t.name: t for t in self.targets}
|
|
16
|
+
|
|
17
|
+
def __getitem__(self, target_name: str):
|
|
18
|
+
return self.target_maps[target_name]
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def from_str(cls, target_str: str):
|
|
22
|
+
"""Parse the argument target graph string into a Graph object"""
|
|
23
|
+
tree = grammar.parse(target_str)
|
|
24
|
+
return cls(targets=TargetVisitor().visit(tree))
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def default_target(self) -> Target | None:
|
|
28
|
+
for target in self.targets:
|
|
29
|
+
if target.default:
|
|
30
|
+
return target
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
def validate(self) -> list[Exception]:
|
|
34
|
+
"""Return a list of validation errors when the target file is invalid"""
|
|
35
|
+
errors, defaults = [], []
|
|
36
|
+
target_names = Counter([target.name for target in self.targets])
|
|
37
|
+
duplicated_targets = {
|
|
38
|
+
target_name: count for target_name, count in target_names.items() if count > 1
|
|
39
|
+
}
|
|
40
|
+
for duplicated_target, count in duplicated_targets.items():
|
|
41
|
+
errors.append(DuplicatedTarget(f"Target {duplicated_target} is defined {count} times"))
|
|
42
|
+
|
|
43
|
+
for target in self.targets:
|
|
44
|
+
if target.default:
|
|
45
|
+
defaults.append(defaults)
|
|
46
|
+
if target.type == "file" and not target.exists() and not target.commands:
|
|
47
|
+
errors.append(
|
|
48
|
+
InvalidDependency(f"{target.name} has no build command and does not exist")
|
|
49
|
+
)
|
|
50
|
+
for dep in target.deps:
|
|
51
|
+
try:
|
|
52
|
+
dep_task = self.target_maps[dep]
|
|
53
|
+
except KeyError:
|
|
54
|
+
errors.append(InvalidDependency(f"{target.name} depends on unknown target {dep}"))
|
|
55
|
+
continue
|
|
56
|
+
if dep == target.name:
|
|
57
|
+
errors.append(CyclicGraph(f"{target.name} depends on itself"))
|
|
58
|
+
elif target.type == "file" and dep_task.type == "task":
|
|
59
|
+
errors.append(
|
|
60
|
+
InvalidDependency(
|
|
61
|
+
f"{target.name}->{dep_task.name}: a file target cannot depend on a task"
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
if len(defaults) > 1:
|
|
65
|
+
errors.append(
|
|
66
|
+
MultipleDefaultTargets(
|
|
67
|
+
f"Multiple tasks ({', '.join(defaults)}) are defined as defaults"
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
return errors
|
|
71
|
+
|
|
72
|
+
def target_subgraph(self, root_target_name: str):
|
|
73
|
+
"""Return a set of target names that belong to the depdency subgraph of a given target.
|
|
74
|
+
|
|
75
|
+
For example, if we had a graph like this:
|
|
76
|
+
A
|
|
77
|
+
B -> A
|
|
78
|
+
C -> B
|
|
79
|
+
D -> A
|
|
80
|
+
D -> E
|
|
81
|
+
E
|
|
82
|
+
|
|
83
|
+
target_subgraph("A") returns {"A"}
|
|
84
|
+
target_subgraph("B") returns {"A", "B"}
|
|
85
|
+
target_subgraph("C") returns {"A", "B", "C"}
|
|
86
|
+
target_subgraph("D") returns {"A", "E", "D"}
|
|
87
|
+
target_subgraph("E") returns {"E"}
|
|
88
|
+
|
|
89
|
+
This allows to us select the subset of targets to execute for a given
|
|
90
|
+
target root.
|
|
91
|
+
|
|
92
|
+
"""
|
|
93
|
+
needed = set()
|
|
94
|
+
|
|
95
|
+
def visit(target_name: str):
|
|
96
|
+
if target_name in needed:
|
|
97
|
+
return
|
|
98
|
+
target = self.target_maps[target_name]
|
|
99
|
+
|
|
100
|
+
if not target.deps and target.commands:
|
|
101
|
+
needed.add(target_name)
|
|
102
|
+
|
|
103
|
+
for dep in target.deps:
|
|
104
|
+
dep_task = self.target_maps[dep]
|
|
105
|
+
if dep_task.should_rebuild(target):
|
|
106
|
+
needed.add(target_name)
|
|
107
|
+
visit(dep)
|
|
108
|
+
|
|
109
|
+
visit(root_target_name)
|
|
110
|
+
return needed
|
|
111
|
+
|
|
112
|
+
def target_subgraph_dependency_counters(self, root_target_name: str) -> dict[str, int]:
|
|
113
|
+
"""Returns a dependency counter for each target in the subgraph of argument root target.
|
|
114
|
+
|
|
115
|
+
For example, if we had a graph like this:
|
|
116
|
+
A
|
|
117
|
+
B -> A
|
|
118
|
+
C -> B
|
|
119
|
+
D -> A
|
|
120
|
+
D -> E
|
|
121
|
+
E
|
|
122
|
+
|
|
123
|
+
target_subgraph_dependency_counters("A") returns {"A": 0}
|
|
124
|
+
-> when A is the root target, the subgraph is just {"A"}, which means
|
|
125
|
+
0 dependencies on A itself.
|
|
126
|
+
|
|
127
|
+
target_subgraph_dependency_counters("B") returns {"A": 0, "B": 1}
|
|
128
|
+
-> when B is the root target, the subgraph is B -> A, which means
|
|
129
|
+
- B has one dependency
|
|
130
|
+
- A has no dependency
|
|
131
|
+
|
|
132
|
+
target_subgraph_dependency_counters("C") returns {"A": 0, "B": 1, "C": 1}
|
|
133
|
+
-> when B is the root target, the subgraph is C -> B -> A, which means
|
|
134
|
+
- C has one dependency
|
|
135
|
+
- B has one dependency
|
|
136
|
+
- A has no dependency
|
|
137
|
+
|
|
138
|
+
target_subgraph_dependency_counters("D") returns {"A": 0, "E": 0, "D": 2}
|
|
139
|
+
-> when B is the root target, the subgraph is
|
|
140
|
+
D -> A;
|
|
141
|
+
D -> E;
|
|
142
|
+
which means
|
|
143
|
+
- D has 2 dependencies
|
|
144
|
+
- A has no dependency
|
|
145
|
+
- E has no dependency
|
|
146
|
+
|
|
147
|
+
target_subgraph_dependency_counters("E") returns {"E": 0}
|
|
148
|
+
-> same as A
|
|
149
|
+
|
|
150
|
+
"""
|
|
151
|
+
remaining_deps = {}
|
|
152
|
+
subgraph = self.target_subgraph(root_target_name)
|
|
153
|
+
for subgraph_target_name in subgraph:
|
|
154
|
+
subgraph_target = self.target_maps[subgraph_target_name]
|
|
155
|
+
remaining_deps[subgraph_target_name] = sum(
|
|
156
|
+
1 for d in subgraph_target.deps if d in subgraph
|
|
157
|
+
)
|
|
158
|
+
return remaining_deps
|
|
159
|
+
|
|
160
|
+
def target_dependency_graph(self, target_name: str) -> dict[str, set[str]]:
|
|
161
|
+
"""Return a dependency set for each target in the subgraph of argument root target
|
|
162
|
+
|
|
163
|
+
Note that this returns the dependency for all nodes in the subgraph to a given target,
|
|
164
|
+
not the depdendencies a target has onto others.
|
|
165
|
+
|
|
166
|
+
For example, if we had a graph like this:
|
|
167
|
+
A
|
|
168
|
+
B -> A
|
|
169
|
+
C -> B
|
|
170
|
+
D -> A
|
|
171
|
+
D -> E
|
|
172
|
+
E
|
|
173
|
+
|
|
174
|
+
target_dependency_graph("A") == {"A": set()}
|
|
175
|
+
target_dependency_graph("A") == {"A": {"B"}, "B": set()}
|
|
176
|
+
-> A has a depoendency FROM B
|
|
177
|
+
|
|
178
|
+
"""
|
|
179
|
+
target = self.target_maps[target_name]
|
|
180
|
+
dependents = defaultdict(set)
|
|
181
|
+
|
|
182
|
+
# build dependency info only for needed targets
|
|
183
|
+
subgraph = self.target_subgraph(target_name)
|
|
184
|
+
for subgraph_target_name in subgraph:
|
|
185
|
+
subgraph_target = self.target_maps[subgraph_target_name]
|
|
186
|
+
for dep in subgraph_target.deps:
|
|
187
|
+
if dep in subgraph:
|
|
188
|
+
dependents[dep].add(subgraph_target_name)
|
|
189
|
+
|
|
190
|
+
dependents[target.name] = set()
|
|
191
|
+
for dep in target.deps:
|
|
192
|
+
dependents[dep].add(target.name)
|
|
193
|
+
|
|
194
|
+
return dict(dependents)
|
|
195
|
+
|
|
196
|
+
def help(self) -> list[str]:
|
|
197
|
+
"""Return the sorted list of targets along with their description"""
|
|
198
|
+
lines = []
|
|
199
|
+
sorted_targets = sorted(self.targets, key=op.attrgetter("name"))
|
|
200
|
+
longest_target_name = max([len(target.name) for target in sorted_targets])
|
|
201
|
+
for target in sorted_targets:
|
|
202
|
+
padded_target_name = f"{target.name:<{longest_target_name + 3}}"
|
|
203
|
+
line = f"{colorize(padded_target_name, color=Color.YELLOW)}{target.description}"
|
|
204
|
+
if target.default:
|
|
205
|
+
line = f"{line} {colorize('[default]', color=Color.GREEN)}"
|
|
206
|
+
lines.append(line)
|
|
207
|
+
return lines
|
|
208
|
+
|
|
209
|
+
def as_mermaid(self) -> list[str]:
|
|
210
|
+
"""Return the target graph formatted in mermaid syntax"""
|
|
211
|
+
lines = ["graph LR"]
|
|
212
|
+
for target in self.targets:
|
|
213
|
+
target_node = f"{target.type}:{target.name}"
|
|
214
|
+
if not target.deps:
|
|
215
|
+
lines.append(target_node)
|
|
216
|
+
else:
|
|
217
|
+
for dep in target.deps:
|
|
218
|
+
dep_task = self.target_maps[dep]
|
|
219
|
+
lines.append(f"{target_node} --> {dep_task.type}:{dep_task.name}")
|
|
220
|
+
return lines
|
|
221
|
+
|
|
222
|
+
def as_dot(self):
|
|
223
|
+
"""Return the target graph formatted in dot syntax"""
|
|
224
|
+
lines = ["digraph targets {"]
|
|
225
|
+
for target in self.targets:
|
|
226
|
+
target_node = f"{target.type}:{target.name}"
|
|
227
|
+
if not target.deps:
|
|
228
|
+
lines.append(f' "{target_node}";')
|
|
229
|
+
for dep in target.deps:
|
|
230
|
+
dep_task = self.target_maps[dep]
|
|
231
|
+
lines.append(f' "{dep_task.type}:{dep_task.name}" -> "{target_node}";')
|
|
232
|
+
lines.append("}")
|
|
233
|
+
return lines
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from glob import glob
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class TargetDecorator:
|
|
9
|
+
type: Literal["task", "file"]
|
|
10
|
+
args: dict[str, Any]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Target:
|
|
15
|
+
name: str
|
|
16
|
+
type: Literal["task", "file"]
|
|
17
|
+
commands: list[str]
|
|
18
|
+
deps: list[str] = field(default_factory=list)
|
|
19
|
+
description: str = field(default_factory=str)
|
|
20
|
+
default: bool = field(default_factory=bool)
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def file(self) -> Path:
|
|
24
|
+
if self.type == "task":
|
|
25
|
+
raise TypeError("A task Target has no file")
|
|
26
|
+
return Path(self.name)
|
|
27
|
+
|
|
28
|
+
def exists(self) -> bool:
|
|
29
|
+
if self.type == "task":
|
|
30
|
+
return True
|
|
31
|
+
if "*" in self.file.name: # glob pattern:
|
|
32
|
+
return all([Path(p).exists() for p in glob(self.file.name)])
|
|
33
|
+
return self.file.exists()
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def last_build_timestamp(self) -> float:
|
|
37
|
+
return self.file.stat().st_mtime
|
|
38
|
+
|
|
39
|
+
def should_rebuild(self, target_depending_on_self: "Target") -> bool:
|
|
40
|
+
if target_depending_on_self.type == "task":
|
|
41
|
+
return True
|
|
42
|
+
elif not target_depending_on_self.exists():
|
|
43
|
+
return True
|
|
44
|
+
elif "*" in self.name:
|
|
45
|
+
return any(
|
|
46
|
+
[
|
|
47
|
+
target_depending_on_self.last_build_timestamp < Path(p).stat().st_mtime
|
|
48
|
+
for p in glob(self.file.name)
|
|
49
|
+
]
|
|
50
|
+
)
|
|
51
|
+
return target_depending_on_self.last_build_timestamp < self.last_build_timestamp
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from parsimonious.grammar import Grammar
|
|
2
|
+
from parsimonious.nodes import NodeVisitor
|
|
3
|
+
|
|
4
|
+
from brake.model import Target, TargetDecorator
|
|
5
|
+
|
|
6
|
+
grammar = Grammar("""
|
|
7
|
+
start = target+
|
|
8
|
+
target = target_decorator name ":" NEWLINE command_block NEWLINE*
|
|
9
|
+
target_decorator = "@" ("task"/"file") target_args* NEWLINE
|
|
10
|
+
target_args = "(" kv* ")"
|
|
11
|
+
kv = name "=" (list / string / bool) ","? " "? NEWLINE?
|
|
12
|
+
command_block = command*
|
|
13
|
+
command = INDENT LINE NEWLINE
|
|
14
|
+
|
|
15
|
+
NEWLINE = ~r"\\n"
|
|
16
|
+
string = ~'"[^\"]*"'
|
|
17
|
+
list = "[" list_member* "]"
|
|
18
|
+
list_member = (" "* name " "* ","?)
|
|
19
|
+
bool = ("true"/"false")
|
|
20
|
+
LINE = ~"[^\\n]+" # Match any line not containing a newline
|
|
21
|
+
name = ~r"[a-zA-Z*][a-zA-Z0-9_\\-./*]*"
|
|
22
|
+
INDENT = ~r"\\s{4}" # 4 spaces for indentation
|
|
23
|
+
DEDENT = ~r"\\s{0,3}" # Allow variable indentation sizes after a block
|
|
24
|
+
|
|
25
|
+
""")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TargetVisitor(NodeVisitor):
|
|
29
|
+
def visit_start(self, node, visited_children):
|
|
30
|
+
return visited_children
|
|
31
|
+
|
|
32
|
+
def visit_target(self, node, visited_children):
|
|
33
|
+
decorator, target_name, *_, command_block, _ = visited_children
|
|
34
|
+
kwargs = decorator.args.copy()
|
|
35
|
+
if kwargs.get("default"):
|
|
36
|
+
kwargs["default"] = True
|
|
37
|
+
return Target(
|
|
38
|
+
name=target_name,
|
|
39
|
+
type=decorator.type,
|
|
40
|
+
commands=command_block,
|
|
41
|
+
**kwargs,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def visit_target_decorator(self, node, visited_children):
|
|
45
|
+
_, target_type, task_args, _ = visited_children
|
|
46
|
+
task_args = task_args[0] if (isinstance(task_args, list) and task_args) else {}
|
|
47
|
+
return TargetDecorator(type=target_type[0].text, args=task_args)
|
|
48
|
+
|
|
49
|
+
def visit_target_args(self, node, visited_children):
|
|
50
|
+
args = {}
|
|
51
|
+
openinig_bracket, kvs, closing_bracket = visited_children
|
|
52
|
+
for kv in kvs:
|
|
53
|
+
args.update(kv)
|
|
54
|
+
return args
|
|
55
|
+
|
|
56
|
+
def visit_kv(self, node, visited_children):
|
|
57
|
+
kv = {}
|
|
58
|
+
key, _, val, *_ = visited_children
|
|
59
|
+
kv[key] = val[0] # not sure why I have to index here
|
|
60
|
+
return kv
|
|
61
|
+
|
|
62
|
+
def visit_command_block(self, node, visited_children):
|
|
63
|
+
return visited_children
|
|
64
|
+
|
|
65
|
+
def visit_command(self, node, visited_children):
|
|
66
|
+
indent, cmd, newline = node.children
|
|
67
|
+
return cmd.text
|
|
68
|
+
|
|
69
|
+
def visit_list(self, node, visited_children):
|
|
70
|
+
opening_bracket, members, closing_bracket = visited_children
|
|
71
|
+
if isinstance(members, list):
|
|
72
|
+
return members
|
|
73
|
+
return members.children
|
|
74
|
+
|
|
75
|
+
def visit_string(self, node, visited_children):
|
|
76
|
+
return node.text.strip('"')
|
|
77
|
+
|
|
78
|
+
def visit_bool(self, node, visited_children):
|
|
79
|
+
return eval(node.text.capitalize())
|
|
80
|
+
|
|
81
|
+
def visit_list_member(self, node, visited_children):
|
|
82
|
+
_, member, *_ = visited_children
|
|
83
|
+
return member
|
|
84
|
+
|
|
85
|
+
def visit_name(self, node, visited_children):
|
|
86
|
+
return node.text
|
|
87
|
+
|
|
88
|
+
def generic_visit(self, node, visited_children):
|
|
89
|
+
# For any node we don't explicitly handle
|
|
90
|
+
return visited_children or node
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from collections import deque
|
|
3
|
+
from concurrent.futures import Future, ProcessPoolExecutor
|
|
4
|
+
|
|
5
|
+
from brake.graph import TargetGraph
|
|
6
|
+
from brake.model import Target
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TargetGraphRunner:
|
|
10
|
+
def __init__(self, target_graph: TargetGraph):
|
|
11
|
+
self.target_graph = target_graph
|
|
12
|
+
self.pool: ProcessPoolExecutor | None = None
|
|
13
|
+
|
|
14
|
+
def submit_target(self, target_name: str) -> Future:
|
|
15
|
+
"""Submit a target by name to the worker pool"""
|
|
16
|
+
if self.pool:
|
|
17
|
+
return self.pool.submit(self.run_target, self.target_graph[target_name])
|
|
18
|
+
raise RuntimeError("The process pool has not yet been initialized")
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def run_target(target: Target):
|
|
22
|
+
for cmd in target.commands:
|
|
23
|
+
print(f"[{target.type}:{target.name}] {cmd}")
|
|
24
|
+
subprocess.run(cmd, shell=True, check=True)
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def wait_for_one_command_to_complete(commands: dict[Future, str]) -> str | None:
|
|
28
|
+
try:
|
|
29
|
+
done = next(f for f in commands if f.done())
|
|
30
|
+
except StopIteration:
|
|
31
|
+
return
|
|
32
|
+
else:
|
|
33
|
+
return commands.pop(done)
|
|
34
|
+
|
|
35
|
+
def run(self, target_name: str | None, max_workers: int):
|
|
36
|
+
if not target_name:
|
|
37
|
+
if default_target := self.target_graph.default_target:
|
|
38
|
+
target_name = default_target.name
|
|
39
|
+
else:
|
|
40
|
+
raise ValueError("A target is required to run as the graph has no default target")
|
|
41
|
+
|
|
42
|
+
dependents = self.target_graph.target_dependency_graph(target_name)
|
|
43
|
+
remaining_deps = self.target_graph.target_subgraph_dependency_counters(target_name)
|
|
44
|
+
|
|
45
|
+
# targets ready to run
|
|
46
|
+
ready = deque(name for name, n in remaining_deps.items() if n == 0)
|
|
47
|
+
|
|
48
|
+
running, completed = {}, set()
|
|
49
|
+
|
|
50
|
+
with ProcessPoolExecutor(max_workers=max_workers) as self.pool:
|
|
51
|
+
while ready or running:
|
|
52
|
+
# schedule new targets
|
|
53
|
+
while ready and len(running) < max_workers:
|
|
54
|
+
name = ready.popleft()
|
|
55
|
+
future = self.submit_target(name)
|
|
56
|
+
running[future] = name
|
|
57
|
+
|
|
58
|
+
if completed_target_name := self.wait_for_one_command_to_complete(running):
|
|
59
|
+
completed.add(completed_target_name)
|
|
60
|
+
|
|
61
|
+
# unlock dependents
|
|
62
|
+
for dep in dependents[completed_target_name]:
|
|
63
|
+
remaining_deps[dep] -= 1
|
|
64
|
+
if remaining_deps[dep] == 0:
|
|
65
|
+
ready.append(dep)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "brake"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Minimalistic yet powerful build tool"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Balthazar Rouberol",email = "br@imap.cc"}
|
|
7
|
+
]
|
|
8
|
+
license = {text = "MIT"}
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"parsimonious (>=0.11.0,<0.12.0)"
|
|
13
|
+
]
|
|
14
|
+
[dependency-groups]
|
|
15
|
+
dev = [
|
|
16
|
+
"pytest (>=9.0.2,<10.0.0)",
|
|
17
|
+
"pytest-cov (>=7.0.0,<8.0.0)",
|
|
18
|
+
"ruff (>=0.15.6,<0.16.0)"
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
23
|
+
build-backend = "poetry.core.masonry.api"
|
|
24
|
+
|
|
25
|
+
[tool.poetry.scripts]
|
|
26
|
+
brake = 'brake.cli:run'
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
addopts = "-vv --cov=brake"
|
|
30
|
+
testpaths = [
|
|
31
|
+
"tests"
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[tool.ruff]
|
|
35
|
+
line-length = 102
|
|
36
|
+
|
|
37
|
+
[tool.coverage.run]
|
|
38
|
+
omit = [
|
|
39
|
+
"brake/cli.py"
|
|
40
|
+
]
|