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 +10 -0
- facere/__main__.py +5 -0
- facere/_api.py +137 -0
- facere/_build.py +226 -0
- facere/_cli.py +146 -0
- facere/_collect.py +625 -0
- facere/_command.py +186 -0
- facere/_context.py +89 -0
- facere/_diagnostics.py +85 -0
- facere/_hash.py +176 -0
- facere/_state.py +87 -0
- facere/_version.py +1 -0
- facere/doc/best-practices.md +220 -0
- facere/doc/getting-started.md +112 -0
- facere/doc/terminology.md +41 -0
- facere/py.typed +0 -0
- facere-0.1.0.dist-info/METADATA +23 -0
- facere-0.1.0.dist-info/RECORD +22 -0
- facere-0.1.0.dist-info/WHEEL +4 -0
- facere-0.1.0.dist-info/entry_points.txt +3 -0
- facere-0.1.0.dist-info/licenses/COPYING +674 -0
- facere-0.1.0.dist-info/licenses/LICENSE +165 -0
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
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]
|