facere 0.1.0__py3-none-any.whl

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.
facere/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Facere, a build tool configured in plain Python. A fac.py imports it as `import facere as fac`.
2
+
3
+ The user documentation ships as Markdown in this package's `doc/` directory, beside this file: read `doc/getting-started.md` first, then `doc/best-practices.md` before writing rules for a real project, and `doc/terminology.md` for any unfamiliar term.
4
+ """
5
+
6
+ from facere._api import Input, Output, minimum_version, out, rule, task
7
+ from facere._cli import main
8
+ from facere._command import run
9
+
10
+ __all__ = ["Input", "Output", "main", "minimum_version", "out", "rule", "run", "task"]
facere/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from facere._cli import console_main
4
+
5
+ sys.exit(console_main())
facere/_api.py ADDED
@@ -0,0 +1,137 @@
1
+ """The decorators and helpers a fac.py calls to define rules and tasks."""
2
+
3
+ import os
4
+ import posixpath
5
+ from collections.abc import Callable, Iterable
6
+ from pathlib import PurePosixPath
7
+ from typing import Annotated, Any, TypeVar, overload
8
+
9
+ from facere._context import Declaration, Kind, find_context, get_context, normalize
10
+ from facere._diagnostics import Diagnostic, FacereError, find_caller_location
11
+ from facere._version import VERSION
12
+
13
+ Input = Annotated[str, Kind.INPUT]
14
+ Output = Annotated[str, Kind.OUTPUT]
15
+
16
+ _F = TypeVar("_F", bound=Callable[..., object])
17
+
18
+
19
+ @overload
20
+ def rule(fn: _F, /) -> _F: ...
21
+
22
+
23
+ @overload
24
+ def rule(
25
+ *,
26
+ each: Iterable[tuple[Any, ...]] | None = None,
27
+ name: str | None = None,
28
+ default: bool = False,
29
+ **values: Any,
30
+ ) -> Callable[[_F], _F]: ...
31
+
32
+
33
+ def rule(
34
+ fn: _F | None = None,
35
+ /,
36
+ *,
37
+ each: Iterable[tuple[Any, ...]] | None = None,
38
+ name: str | None = None,
39
+ default: bool = False,
40
+ **values: Any,
41
+ ) -> _F | Callable[[_F], _F]:
42
+ """Define rules from a function, returned unchanged.
43
+
44
+ The function's signature declares what each rule reads and writes: a parameter annotated `fac.Input` or `list[fac.Input]` holds the files or directories it reads, one annotated `fac.Output` or `list[fac.Output]` files or directories it writes, and any other parameter a value. `each=` takes an iterable of tuples, read once here, and defines one rule per tuple, filling the leading parameters in order. Keyword arguments fill the parameters they name in every rule, and defaults fill the rest. `name=` replaces the function's name, less one trailing underscore, on the command line, and `default=True` builds the rules when `fac` gets no targets.
45
+ """
46
+ rows = None if each is None else tuple(each)
47
+ return _declarator(fn, is_task=False, name=name, is_default=default, rows=rows, values=values)
48
+
49
+
50
+ @overload
51
+ def task(fn: _F, /) -> _F: ...
52
+
53
+
54
+ @overload
55
+ def task(
56
+ *, name: str | None = None, default: bool = False, **values: Any
57
+ ) -> Callable[[_F], _F]: ...
58
+
59
+
60
+ def task(
61
+ fn: _F | None = None, /, *, name: str | None = None, default: bool = False, **values: Any
62
+ ) -> _F | Callable[[_F], _F]:
63
+ """Define a task from a function, returned unchanged.
64
+
65
+ A task takes inputs and values only, and executes whenever a target reaches it, after the rules that output its inputs, with its commands sharing Facere's standard streams. `name=`, `default=True`, and keyword arguments mean what they mean for `fac.rule`.
66
+ """
67
+ return _declarator(fn, is_task=True, name=name, is_default=default, rows=None, values=values)
68
+
69
+
70
+ def out(path: str, suffix: str | None = None) -> str:
71
+ """Return the mirror of `path` inside the output directory, with its suffix swapped for `suffix` when given.
72
+
73
+ `fac.out("src/a.c", ".o")` is `build/src/a.o`, and `fac.out(".")` is the output directory itself. A path already inside the output directory keeps its place.
74
+ """
75
+ context = get_context("fac.out")
76
+ relative = normalize(path, context.root)
77
+ if relative == context.out_dir or relative.startswith(f"{context.out_dir}/"):
78
+ mirrored = relative
79
+ elif os.path.isabs(relative) or relative == ".." or relative.startswith("../"):
80
+ raise ValueError(f"fac.out takes a path under the root, not {path!r}")
81
+ else:
82
+ mirrored = posixpath.normpath(posixpath.join(context.out_dir, relative))
83
+ if suffix is None:
84
+ return mirrored
85
+ return PurePosixPath(mirrored).with_suffix(suffix).as_posix()
86
+
87
+
88
+ def minimum_version(version: str) -> None:
89
+ """Fail collection unless the running Facere is `version` or newer, as in `fac.minimum_version("0.1")`."""
90
+ required = _parse_version(version)
91
+ running = _parse_version(VERSION)
92
+ width = max(len(required), len(running))
93
+ if running + (0,) * (width - len(running)) >= required + (0,) * (width - len(required)):
94
+ return
95
+ context = find_context()
96
+ location = find_caller_location(context.fac_py if context else None)
97
+ raise FacereError(
98
+ Diagnostic(
99
+ location,
100
+ f"this project needs Facere {version} or newer, and Facere {VERSION} is running: upgrade it with `uv tool upgrade facere`",
101
+ )
102
+ )
103
+
104
+
105
+ def _parse_version(version: str) -> tuple[int, ...]:
106
+ parts = version.split(".")
107
+ if not all(part.isdecimal() for part in parts):
108
+ raise ValueError(f'fac.minimum_version takes a version such as "0.1", not {version!r}')
109
+ return tuple(int(part) for part in parts)
110
+
111
+
112
+ def _declarator(
113
+ fn: _F | None,
114
+ *,
115
+ is_task: bool,
116
+ name: str | None,
117
+ is_default: bool,
118
+ rows: tuple[object, ...] | None,
119
+ values: dict[str, object],
120
+ ) -> _F | Callable[[_F], _F]:
121
+ def declare(fn: _F) -> _F:
122
+ context = get_context("@fac.task" if is_task else "@fac.rule")
123
+ location = find_caller_location(context.fac_py)
124
+ context.declarations.append(
125
+ Declaration(
126
+ is_task=is_task,
127
+ fn=fn,
128
+ name=name,
129
+ is_default=is_default,
130
+ rows=rows,
131
+ values=values,
132
+ location=location,
133
+ )
134
+ )
135
+ return fn
136
+
137
+ return declare if fn is None else declare(fn)
facere/_build.py ADDED
@@ -0,0 +1,226 @@
1
+ """Building targets: deciding which rules are stale, and executing recipes one at a time in dependency order."""
2
+
3
+ import hashlib
4
+ import os
5
+ import posixpath
6
+ import shlex
7
+ import subprocess
8
+ import sys
9
+ import traceback
10
+ from dataclasses import dataclass
11
+
12
+ from facere import _state
13
+ from facere._collect import Definition, Project
14
+ from facere._command import write_output
15
+ from facere._context import Context, Running, normalize
16
+ from facere._diagnostics import Diagnostic, FacereError, Note, print_error
17
+ from facere._hash import feed, hash_path
18
+
19
+
20
+ class RecipeFailure(Exception):
21
+ def __init__(
22
+ self, definition: Definition, *, error: BaseException | None = None, reason: str = ""
23
+ ) -> None:
24
+ super().__init__(definition.label)
25
+ self.definition = definition
26
+ self.error = error
27
+ self.reason = reason
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class Summary:
32
+ executed: int = 0
33
+ up_to_date: int = 0
34
+
35
+
36
+ def resolve_targets(project: Project, root: str, targets: list[str]) -> list[Definition]:
37
+ """Return the definitions `targets` resolve to, in order, or the defaults when `targets` is empty."""
38
+ names_note = Note(None, f"names: {', '.join(sorted(project.by_name))}")
39
+ if not targets:
40
+ defaults = [definition for definition in project.definitions if definition.is_default]
41
+ if not defaults:
42
+ raise FacereError(
43
+ Diagnostic(
44
+ None,
45
+ "fac.py declares no default, so name a target, or pass default=True to fac.rule or fac.task",
46
+ (names_note,),
47
+ )
48
+ )
49
+ return defaults
50
+ resolved: list[Definition] = []
51
+ unknown: list[str] = []
52
+ for target in targets:
53
+ if "/" in target or "\\" in target:
54
+ rule = project.producers.get(normalize(target.replace("\\", "/"), root))
55
+ found = [rule] if rule else []
56
+ else:
57
+ found = project.by_name.get(target, [])
58
+ resolved.extend(found)
59
+ if not found:
60
+ unknown.append(target)
61
+ if unknown:
62
+ listed = ", ".join(repr(target) for target in unknown)
63
+ raise FacereError(
64
+ Diagnostic(
65
+ None,
66
+ f"unknown target {listed}: name a rule or task, or give the path a rule outputs, relative to the root",
67
+ (names_note,),
68
+ )
69
+ )
70
+ return resolved
71
+
72
+
73
+ def build(context: Context, project: Project, targets: list[Definition]) -> Summary:
74
+ """Build `targets` in order, raising RecipeFailure on the first failure."""
75
+ state_path = posixpath.join(context.state_dir, "state.db")
76
+ records = _state.load(state_path)
77
+ declared = {
78
+ _state.calc_key(definition.outputs)
79
+ for definition in project.definitions
80
+ if not definition.is_task
81
+ }
82
+ _state.remove(state_path, [key for key in records if key not in declared])
83
+ builder = _Builder(context, project, records, state_path)
84
+ for target in targets:
85
+ builder.build(target)
86
+ return builder.summary
87
+
88
+
89
+ def report_failure(failure: RecipeFailure, start_dir: str) -> None:
90
+ """Print the failing command or exception, with the command's captured stderr and stdout, then name the rule or task."""
91
+ error = failure.error
92
+ if isinstance(error, subprocess.CalledProcessError | subprocess.TimeoutExpired):
93
+ if isinstance(error, subprocess.TimeoutExpired):
94
+ status = f"timed out after {error.timeout:g} seconds"
95
+ elif error.returncode < 0:
96
+ status = f"was killed by signal {-error.returncode}"
97
+ else:
98
+ status = f"exited with status {error.returncode}"
99
+ command = (
100
+ shlex.join(str(arg) for arg in error.cmd)
101
+ if isinstance(error.cmd, list | tuple)
102
+ else str(error.cmd)
103
+ )
104
+ print(f"fac: command {status}: {command}", file=sys.stderr, flush=True)
105
+ write_output(_to_bytes(error.stderr), _to_bytes(error.output))
106
+ elif error is not None:
107
+ print(_format_exception(error), end="", file=sys.stderr, flush=True)
108
+ message = f"{failure.definition.label} failed" + (
109
+ f": {failure.reason}" if failure.reason else ""
110
+ )
111
+ print_error(FacereError(Diagnostic(failure.definition.location, message)), start_dir)
112
+
113
+
114
+ def _to_bytes(data: bytes | str | None) -> bytes:
115
+ if data is None:
116
+ return b""
117
+ return data if isinstance(data, bytes) else data.encode("utf-8", "surrogateescape")
118
+
119
+
120
+ def _format_exception(error: BaseException) -> str:
121
+ package_dir = os.path.dirname(__file__)
122
+ report = traceback.TracebackException.from_exception(error)
123
+ report.stack = traceback.StackSummary.from_list(
124
+ [frame for frame in report.stack if os.path.dirname(frame.filename) != package_dir]
125
+ )
126
+ return "".join(report.format())
127
+
128
+
129
+ class _Builder:
130
+ def __init__(
131
+ self,
132
+ context: Context,
133
+ project: Project,
134
+ records: dict[tuple[str, ...], _state.Record],
135
+ state_path: str,
136
+ ) -> None:
137
+ self.context = context
138
+ self.project = project
139
+ self.records = records
140
+ self.state_path = state_path
141
+ self.checked: set[Definition] = set()
142
+ self.summary = Summary()
143
+
144
+ def build(self, target: Definition) -> None:
145
+ for rule in self._order(target):
146
+ try:
147
+ self._ensure_up_to_date(rule)
148
+ except OSError as error:
149
+ raise RecipeFailure(
150
+ rule, reason=f"{error}: fix the path or its permissions"
151
+ ) from error
152
+ if target.is_task:
153
+ self._execute(target)
154
+ self.summary.executed += 1
155
+ self.checked.clear()
156
+
157
+ def _order(self, target: Definition) -> list[Definition]:
158
+ """Return the rules `target` needs that this batch hasn't checked, each after the rules that output its inputs."""
159
+ if target in self.checked:
160
+ return []
161
+ order = []
162
+ seen = {*self.checked, target}
163
+ stack = [(target, iter(target.inputs))]
164
+ while stack:
165
+ definition, inputs = stack[-1]
166
+ path = next(inputs, None)
167
+ if path is None:
168
+ stack.pop()
169
+ if not definition.is_task:
170
+ order.append(definition)
171
+ continue
172
+ rule = self.project.producers.get(path)
173
+ if rule is not None and rule not in seen:
174
+ seen.add(rule)
175
+ stack.append((rule, iter(rule.inputs)))
176
+ return order
177
+
178
+ def _ensure_up_to_date(self, rule: Definition) -> None:
179
+ self.checked.add(rule)
180
+ fingerprint = self._calc_fingerprint(rule)
181
+ key = _state.calc_key(rule.outputs)
182
+ record = self.records.get(key)
183
+ if record is not None:
184
+ if record == (fingerprint, tuple(hash_path(output) for output in key)):
185
+ self.summary.up_to_date += 1
186
+ return
187
+ _state.remove(self.state_path, [key])
188
+ for output in rule.outputs:
189
+ os.makedirs(os.path.dirname(output) or ".", exist_ok=True)
190
+ self._execute(rule)
191
+ output_hashes = {output: hash_path(output) for output in key}
192
+ missing = [output for output, content in output_hashes.items() if content is None]
193
+ if missing:
194
+ raise RecipeFailure(
195
+ rule,
196
+ reason=f"its recipe succeeded without writing {', '.join(missing)}: write every declared output, or fix the path",
197
+ )
198
+ record = self.records[key] = _state.Record(
199
+ fingerprint, tuple(content for content in output_hashes.values() if content is not None)
200
+ )
201
+ _state.save(self.state_path, key, record)
202
+ self.summary.executed += 1
203
+
204
+ def _calc_fingerprint(self, rule: Definition) -> str:
205
+ digest = hashlib.sha256(self.project.code_digests[rule])
206
+ for path in rule.inputs:
207
+ content = hash_path(path)
208
+ if content is None:
209
+ raise RecipeFailure(
210
+ rule,
211
+ reason=f"its input {path} is missing: restore it, or fix the rule that outputs it",
212
+ )
213
+ feed(digest, b"path", path.encode("utf-8", "surrogateescape"))
214
+ feed(digest, b"content", content.encode())
215
+ return digest.hexdigest()
216
+
217
+ def _execute(self, definition: Definition) -> None:
218
+ self.context.running = Running.TASK if definition.is_task else Running.RULE
219
+ try:
220
+ definition.fn(*definition.args, **definition.kwargs)
221
+ except KeyboardInterrupt:
222
+ raise
223
+ except BaseException as error:
224
+ raise RecipeFailure(definition, error=error) from error
225
+ finally:
226
+ self.context.running = None
facere/_cli.py ADDED
@@ -0,0 +1,146 @@
1
+ """The command line, `fac [TARGET...]`, also run in-process through fac.main."""
2
+
3
+ import argparse
4
+ import contextlib
5
+ import os
6
+ import sys
7
+ import types
8
+ from collections.abc import Generator
9
+
10
+ from facere import _build, _collect
11
+ from facere._context import Context, activate, normalize
12
+ from facere._diagnostics import Diagnostic, FacereError, Note, format_path, print_error
13
+ from facere._version import VERSION
14
+
15
+
16
+ def main(args: list[str]) -> int:
17
+ """Run the fac command line in-process with `args`, the arguments after the program name, and return its exit status."""
18
+ parser = argparse.ArgumentParser(
19
+ prog="fac",
20
+ description="Build the targets the fac.py in the root defines.",
21
+ allow_abbrev=False,
22
+ )
23
+ parser.add_argument(
24
+ "targets",
25
+ nargs="*",
26
+ metavar="TARGET",
27
+ help="a rule or task name, or a path a rule outputs relative to the root (default: the defaults fac.py declares)",
28
+ )
29
+ parser.add_argument(
30
+ "-C",
31
+ dest="directory",
32
+ metavar="DIR",
33
+ help="run as if started in DIR, which becomes the root",
34
+ )
35
+ parser.add_argument(
36
+ "--out",
37
+ default="build",
38
+ metavar="DIR",
39
+ help="output directory, relative to the root (default: build)",
40
+ )
41
+ parser.add_argument(
42
+ "-q", "--quiet", action="store_true", help="hide the output of every successful command"
43
+ )
44
+ parser.add_argument("--version", action="version", version=VERSION)
45
+ try:
46
+ options = parser.parse_intermixed_args(args)
47
+ if "" in (options.out, options.directory):
48
+ parser.error("-C and --out take a directory, not an empty string")
49
+ except SystemExit as exit_:
50
+ return exit_.code if isinstance(exit_.code, int) else 1
51
+ return _invoke(options.directory, options.out, options.quiet, options.targets)
52
+
53
+
54
+ def console_main() -> int:
55
+ return main(sys.argv[1:])
56
+
57
+
58
+ def _invoke(directory: str | None, out: str, is_quiet: bool, targets: list[str]) -> int:
59
+ start_dir = os.getcwd()
60
+ root = os.path.abspath(os.path.join(start_dir, directory)) if directory else start_dir
61
+ fac_py = os.path.join(root, "fac.py")
62
+ if not os.path.isfile(fac_py):
63
+ print_error(FacereError(_build_missing_root_error(root, start_dir)), start_dir)
64
+ return 1
65
+ context = Context(root=root, out_dir=normalize(out, root), fac_py=fac_py, is_quiet=is_quiet)
66
+ pycache_prefix = os.path.abspath(os.path.join(root, context.state_dir, "pycache"))
67
+ with _enter_root(root, pycache_prefix), activate(context):
68
+ return _run(context, targets, start_dir)
69
+
70
+
71
+ @contextlib.contextmanager
72
+ def _enter_root(root: str, pycache_prefix: str) -> Generator[None]:
73
+ """Run from `root`, importable and caching bytecode under `pycache_prefix`, then restore the interpreter and forget the modules imported from `root`."""
74
+ start_dir = os.getcwd()
75
+ saved_path = list(sys.path)
76
+ saved_prefix = sys.pycache_prefix
77
+ saved_modules = dict(sys.modules)
78
+ try:
79
+ os.chdir(root)
80
+ sys.path.append(root)
81
+ sys.pycache_prefix = pycache_prefix
82
+ yield
83
+ finally:
84
+ os.chdir(start_dir)
85
+ sys.path[:] = saved_path
86
+ sys.pycache_prefix = saved_prefix
87
+ _forget_modules(root, saved_modules)
88
+
89
+
90
+ def _run(context: Context, targets: list[str], start_dir: str) -> int:
91
+ if sys.flags.optimize:
92
+ print(
93
+ "fac: warning: Python runs with assertions disabled, so no recipe's assert checks anything: drop -O and PYTHONOPTIMIZE",
94
+ file=sys.stderr,
95
+ )
96
+ try:
97
+ project = _collect.collect(context)
98
+ summary = _build.build(
99
+ context, project, _build.resolve_targets(project, context.root, targets)
100
+ )
101
+ except FacereError as error:
102
+ print_error(error, start_dir)
103
+ return 1
104
+ except _build.RecipeFailure as failure:
105
+ _build.report_failure(failure, start_dir)
106
+ return 1
107
+ except KeyboardInterrupt:
108
+ print("fac: interrupted", file=sys.stderr)
109
+ return 130
110
+ print(f"fac: {summary.executed} executed, {summary.up_to_date} up to date", file=sys.stderr)
111
+ return 0
112
+
113
+
114
+ def _build_missing_root_error(root: str, start_dir: str) -> Diagnostic:
115
+ where = "the working directory" if root == start_dir else format_path(root, start_dir)
116
+ message = f"no fac.py in {where}: run fac in the directory holding the project's fac.py, or name that directory with -C"
117
+ ancestor = _find_ancestor_with_fac_py(root)
118
+ if ancestor is None:
119
+ return Diagnostic(None, message)
120
+ relative = format_path(ancestor, start_dir)
121
+ note = f"the nearest directory holding a fac.py is {relative}: run fac -C {relative}"
122
+ return Diagnostic(None, message, (Note(None, note),))
123
+
124
+
125
+ def _find_ancestor_with_fac_py(directory: str) -> str | None:
126
+ while True:
127
+ parent = os.path.dirname(directory)
128
+ if parent == directory:
129
+ return None
130
+ directory = parent
131
+ if os.path.isfile(os.path.join(directory, "fac.py")):
132
+ return directory
133
+
134
+
135
+ def _forget_modules(root: str, saved_modules: dict[str, types.ModuleType]) -> None:
136
+ prefix = os.path.join(root, "")
137
+ for name, module in list(sys.modules.items()):
138
+ if name in saved_modules:
139
+ continue
140
+ path = getattr(module, "__file__", None)
141
+ if name == _collect.MODULE_NAME or (
142
+ isinstance(path, str) and os.path.abspath(path).startswith(prefix)
143
+ ):
144
+ del sys.modules[name]
145
+ if _collect.MODULE_NAME in saved_modules:
146
+ sys.modules[_collect.MODULE_NAME] = saved_modules[_collect.MODULE_NAME]