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 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
+ ![brake tasks](https://f003.backblazeb2.com/file/brouberol-blog/public/brake.svg)
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
+ ![brake tasks](https://f003.backblazeb2.com/file/brouberol-blog/public/brake.svg)
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`.
@@ -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
+ ]