chorelib 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.
- chorelib/.ruff_cache/.gitignore +2 -0
- chorelib/.ruff_cache/0.14.11/17628432600115339624 +0 -0
- chorelib/.ruff_cache/CACHEDIR.TAG +1 -0
- chorelib/__init__.py +48 -0
- chorelib/depgraph.py +122 -0
- chorelib/depmain.py +209 -0
- chorelib/deprunner.py +279 -0
- chorelib/errors.py +27 -0
- chorelib/ruledef.py +641 -0
- chorelib/utils.py +189 -0
- chorelib-0.1.0.dist-info/METADATA +170 -0
- chorelib-0.1.0.dist-info/RECORD +14 -0
- chorelib-0.1.0.dist-info/WHEEL +4 -0
- chorelib-0.1.0.dist-info/licenses/LICENSE +21 -0
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Signature: 8a477f597d28d172789f06886806bc55
|
chorelib/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Chorelib: An async-first Python build automation framework.
|
|
2
|
+
|
|
3
|
+
Provides a decorator-based DSL for defining build rules and tasks with
|
|
4
|
+
dependency management, parallel execution, and mtime-based rebuild detection.
|
|
5
|
+
|
|
6
|
+
Typical usage::
|
|
7
|
+
|
|
8
|
+
import chorelib
|
|
9
|
+
|
|
10
|
+
@chorelib.rule("output.txt", depends="input.txt")
|
|
11
|
+
def build_output(target, depends, needs):
|
|
12
|
+
chorelib.shell(f"cp {depends[0]} {target}")
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
chorelib.Main().run()
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .depmain import Main
|
|
19
|
+
from .deprunner import schedule
|
|
20
|
+
from .ruledef import RuleSet
|
|
21
|
+
from .utils import command, message, shell
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
# Default RuleSet instance used by the module-level decorators.
|
|
26
|
+
# Users can use `@chorelib.rule(...)` and `@chorelib.task` directly
|
|
27
|
+
# without creating a RuleSet manually.
|
|
28
|
+
_default_rules = RuleSet()
|
|
29
|
+
rule = _default_rules.rule
|
|
30
|
+
task = _default_rules.task
|
|
31
|
+
mtime = _default_rules.mtime
|
|
32
|
+
|
|
33
|
+
# Global verbosity level controlling message output.
|
|
34
|
+
# 0 = normal, 1 = verbose, 2 = debug messages, 3+ = logging debug.
|
|
35
|
+
verbose: int = 0
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"RuleSet",
|
|
39
|
+
"rule",
|
|
40
|
+
"task",
|
|
41
|
+
"mtime",
|
|
42
|
+
"Main",
|
|
43
|
+
"schedule",
|
|
44
|
+
"verbose",
|
|
45
|
+
"command",
|
|
46
|
+
"message",
|
|
47
|
+
"shell",
|
|
48
|
+
]
|
chorelib/depgraph.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Dependency graph construction and cycle detection.
|
|
2
|
+
|
|
3
|
+
Builds a directed acyclic graph (DAG) of build targets and their
|
|
4
|
+
dependencies. Detects circular dependencies via depth-first search
|
|
5
|
+
before any build execution begins.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .errors import RuleError
|
|
11
|
+
from .ruledef import RuleBase, RuleSet, Task
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BuildInfo:
|
|
15
|
+
"""A node in the dependency graph representing a single build target.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
target: The target name (file path or task name).
|
|
19
|
+
rule: The Rule or Task that produces this target.
|
|
20
|
+
depends: List of dependency target names that trigger rebuilds.
|
|
21
|
+
needs: List of order-only prerequisite target names.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, target: str, rule: RuleBase, depends: list[str], needs: list[str]) -> None:
|
|
25
|
+
self.rule = rule
|
|
26
|
+
self.target = target
|
|
27
|
+
self.depends = depends
|
|
28
|
+
self.needs = needs
|
|
29
|
+
|
|
30
|
+
def __repr__(self) -> str:
|
|
31
|
+
return f"<BuildInfo> {self.rule}:{self.target}:{self.depends}:{self.needs}"
|
|
32
|
+
|
|
33
|
+
def is_task(self) -> bool:
|
|
34
|
+
"""Return True if this node represents a Task (always-execute)."""
|
|
35
|
+
return isinstance(self.rule, Task)
|
|
36
|
+
|
|
37
|
+
def run_builder(self) -> Any:
|
|
38
|
+
"""Execute the builder function for this target."""
|
|
39
|
+
return self.rule.run_builder(target=self.target, depends=self.depends, needs=self.needs)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DepGraph:
|
|
43
|
+
"""Dependency graph that manages BuildInfo nodes and detects cycles.
|
|
44
|
+
|
|
45
|
+
Nodes are registered by recursively resolving each target's dependencies
|
|
46
|
+
through the RuleSet. After registration, ``detectloop`` should be called
|
|
47
|
+
to verify the graph is acyclic.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self) -> None:
|
|
51
|
+
self._nodes: dict[str, BuildInfo] = {}
|
|
52
|
+
|
|
53
|
+
def addtarget(self, ruleset: RuleSet, target: str) -> bool:
|
|
54
|
+
"""Add a target and all its transitive dependencies to the graph.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
True if the target was newly registered, False if already present.
|
|
58
|
+
"""
|
|
59
|
+
return self._register(ruleset, target)
|
|
60
|
+
|
|
61
|
+
def get(self, target: str) -> BuildInfo | None:
|
|
62
|
+
"""Return the BuildInfo node for a target, or None if not registered."""
|
|
63
|
+
return self._nodes.get(target)
|
|
64
|
+
|
|
65
|
+
def _register(self, ruleset: RuleSet, target: str) -> bool:
|
|
66
|
+
"""Recursively register a target and its dependencies.
|
|
67
|
+
|
|
68
|
+
Looks up the matching rule in the ruleset, creates a BuildInfo node,
|
|
69
|
+
and recurses into each dependency and need.
|
|
70
|
+
"""
|
|
71
|
+
if target in self._nodes:
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
ret = ruleset.select_rule(target)
|
|
75
|
+
if ret:
|
|
76
|
+
rule, deps, needs = ret
|
|
77
|
+
node = BuildInfo(target, rule, deps, needs)
|
|
78
|
+
self._nodes[target] = node
|
|
79
|
+
|
|
80
|
+
# Recursively register all dependencies
|
|
81
|
+
for dep in deps:
|
|
82
|
+
self._register(ruleset, dep)
|
|
83
|
+
|
|
84
|
+
# Recursively register all order-only prerequisites
|
|
85
|
+
for need in needs:
|
|
86
|
+
self._register(ruleset, need)
|
|
87
|
+
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
def detectloop(self) -> None:
|
|
91
|
+
"""Detect circular dependencies in the graph using DFS.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
RuleError: If a dependency cycle is found.
|
|
95
|
+
"""
|
|
96
|
+
done: set[BuildInfo] = set()
|
|
97
|
+
|
|
98
|
+
def _walk(node: BuildInfo, seen: set[BuildInfo]) -> None:
|
|
99
|
+
"""DFS traversal tracking the current path in ``seen``."""
|
|
100
|
+
if node in seen:
|
|
101
|
+
raise RuleError(f"Dependency cycle detected: {node}")
|
|
102
|
+
|
|
103
|
+
if node in done:
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
seen.add(node)
|
|
107
|
+
try:
|
|
108
|
+
for dep in node.depends:
|
|
109
|
+
depnode = self._nodes.get(dep)
|
|
110
|
+
if depnode:
|
|
111
|
+
_walk(depnode, seen)
|
|
112
|
+
for need in node.needs:
|
|
113
|
+
neednode = self._nodes.get(need)
|
|
114
|
+
if neednode:
|
|
115
|
+
_walk(neednode, seen)
|
|
116
|
+
finally:
|
|
117
|
+
seen.remove(node)
|
|
118
|
+
done.add(node)
|
|
119
|
+
|
|
120
|
+
for node in self._nodes.values():
|
|
121
|
+
seen: set[BuildInfo] = set()
|
|
122
|
+
_walk(node, seen)
|
chorelib/depmain.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""CLI entry point for chorelib build scripts.
|
|
2
|
+
|
|
3
|
+
Provides the ``Main`` class that parses command-line arguments, selects
|
|
4
|
+
targets, and drives the async build execution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import textwrap
|
|
13
|
+
import traceback
|
|
14
|
+
from types import ModuleType
|
|
15
|
+
|
|
16
|
+
import chorelib
|
|
17
|
+
from chorelib.utils import message
|
|
18
|
+
|
|
19
|
+
from .deprunner import run
|
|
20
|
+
from .ruledef import RuleSet
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Main:
|
|
26
|
+
"""CLI driver for chorelib build scripts.
|
|
27
|
+
|
|
28
|
+
Parses command-line arguments (targets, verbosity, workers, rebuild
|
|
29
|
+
flags) and runs the async build process. Subclass this to add custom
|
|
30
|
+
arguments via ``add_arguments``.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, rules: RuleSet | None = None) -> None:
|
|
34
|
+
if rules is None:
|
|
35
|
+
rules = chorelib._default_rules
|
|
36
|
+
self.rules = rules
|
|
37
|
+
self.args: argparse.Namespace
|
|
38
|
+
self._load_args()
|
|
39
|
+
|
|
40
|
+
def _load_args(self):
|
|
41
|
+
self.parser = self._build_parser()
|
|
42
|
+
self.args = self.parse_args(self.parser)
|
|
43
|
+
|
|
44
|
+
chorelib.verbose = self.args.verbose
|
|
45
|
+
if self.args.verbose >= 3:
|
|
46
|
+
logging.basicConfig(level=logging.DEBUG, format="%(message)s")
|
|
47
|
+
|
|
48
|
+
def parse_args(self, parser: argparse.ArgumentParser) -> argparse.Namespace:
|
|
49
|
+
"""Parse command-line arguments. Override to customize parsing."""
|
|
50
|
+
return parser.parse_args()
|
|
51
|
+
|
|
52
|
+
def _build_parser(self) -> argparse.ArgumentParser:
|
|
53
|
+
"""Create and configure the ArgumentParser with standard options."""
|
|
54
|
+
default_target = self.rules.select_default_target()
|
|
55
|
+
target_names = [n for n in self.rules.get_target_names()]
|
|
56
|
+
|
|
57
|
+
parser = argparse.ArgumentParser(
|
|
58
|
+
formatter_class=argparse.RawDescriptionHelpFormatter, add_help=False
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"-h",
|
|
63
|
+
"--help",
|
|
64
|
+
dest="showhelp",
|
|
65
|
+
action="store_true",
|
|
66
|
+
help="show this help message and exit",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"-C",
|
|
71
|
+
"--directory",
|
|
72
|
+
dest="directory",
|
|
73
|
+
help="Change to DIRECTORY before performing any operations",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"-w",
|
|
78
|
+
"--workers",
|
|
79
|
+
type=int,
|
|
80
|
+
default=1,
|
|
81
|
+
help="Allow up to N workers to run simultaneously (default: 1)",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"-r", "--rebuild", dest="rebuild", action="store_true", help="Rebuild all"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"-l", "--list-targets", dest="list", action="store_true", help="List targets"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"-v",
|
|
94
|
+
dest="verbose",
|
|
95
|
+
action="count",
|
|
96
|
+
default=0,
|
|
97
|
+
help="Increase verbosity level (default: 0)",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
parser.add_argument(
|
|
101
|
+
"-V",
|
|
102
|
+
"--version",
|
|
103
|
+
dest="version",
|
|
104
|
+
action="store_true",
|
|
105
|
+
default=False,
|
|
106
|
+
help="Show version",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
targetdesc = "Build targets"
|
|
110
|
+
if target_names:
|
|
111
|
+
targetdesc += ":" + " ".join(f"[{n}]" for n in target_names)
|
|
112
|
+
if default_target:
|
|
113
|
+
targetdesc += f" (Default: {default_target})"
|
|
114
|
+
|
|
115
|
+
parser.add_argument("targets", nargs="*", help=targetdesc)
|
|
116
|
+
|
|
117
|
+
self.add_arguments(parser)
|
|
118
|
+
return parser
|
|
119
|
+
|
|
120
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
121
|
+
"""Hook for subclasses to add custom command-line arguments."""
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
def _get_version(self) -> str:
|
|
125
|
+
"""Return the version string for --version output."""
|
|
126
|
+
progname = os.path.basename(sys.argv[0])
|
|
127
|
+
version = getattr(chorelib, "__version__", "unknown")
|
|
128
|
+
return f"{progname} (chorelib version {version})"
|
|
129
|
+
|
|
130
|
+
def _list_targets(self):
|
|
131
|
+
for rule in self.rules.rules:
|
|
132
|
+
print(rule.get_descr())
|
|
133
|
+
|
|
134
|
+
def _build_doc(self, module: ModuleType) -> str:
|
|
135
|
+
"""Build the help text from the module docstring and rule documentation."""
|
|
136
|
+
moduledoc = (module.__doc__ or "").strip()
|
|
137
|
+
lines: list[str] = []
|
|
138
|
+
for title, doc in self.rules.get_docs():
|
|
139
|
+
text = f"{title}:"
|
|
140
|
+
if doc:
|
|
141
|
+
doc = doc.lstrip("\n")
|
|
142
|
+
doc = textwrap.indent(textwrap.dedent(doc).rstrip(), " " * 4)
|
|
143
|
+
text = f"{text}\n{doc}"
|
|
144
|
+
lines.append(text)
|
|
145
|
+
targetsdoc = ""
|
|
146
|
+
if lines:
|
|
147
|
+
targetsdoc = "\n\n".join(lines)
|
|
148
|
+
|
|
149
|
+
return "\n\n".join(s for s in [moduledoc, targetsdoc] if s)
|
|
150
|
+
|
|
151
|
+
def _get_command_module(self) -> ModuleType:
|
|
152
|
+
"""Return the __main__ module (the user's build script)."""
|
|
153
|
+
return sys.modules["__main__"]
|
|
154
|
+
|
|
155
|
+
def build(self, targets: list[str]) -> None:
|
|
156
|
+
"""Execute the async build for the given targets."""
|
|
157
|
+
asyncio.run(
|
|
158
|
+
run(self.rules, targets, num_workers=self.args.workers, rebuild=self.args.rebuild)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def get_default_targets(self) -> list[str]:
|
|
162
|
+
"""Return the default target(s) when none are specified on the command line."""
|
|
163
|
+
default_target = self.rules.select_default_target()
|
|
164
|
+
if not default_target:
|
|
165
|
+
return []
|
|
166
|
+
return [str(default_target)]
|
|
167
|
+
|
|
168
|
+
def run(self) -> None:
|
|
169
|
+
"""Main entry point: parse args, configure logging, and execute the build."""
|
|
170
|
+
if self.args.showhelp:
|
|
171
|
+
doc = self._build_doc(self._get_command_module())
|
|
172
|
+
self.parser.description = doc
|
|
173
|
+
self.parser.print_help()
|
|
174
|
+
sys.exit(0)
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
if self.args.version:
|
|
178
|
+
print(self._get_version())
|
|
179
|
+
sys.exit(0)
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
if self.args.list:
|
|
183
|
+
self._list_targets()
|
|
184
|
+
sys.exit(0)
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
targets: list[str] = self.args.targets
|
|
188
|
+
if not targets:
|
|
189
|
+
targets = self.get_default_targets()
|
|
190
|
+
message(f"No targets specified. Using default targets: {targets}", 2)
|
|
191
|
+
if not targets:
|
|
192
|
+
sys.exit("No default target defined.")
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
curdir = os.getcwd()
|
|
196
|
+
if self.args.directory:
|
|
197
|
+
os.chdir(self.args.directory)
|
|
198
|
+
logger.debug(f"Changed directory to {self.args.directory}")
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
self.build(targets)
|
|
202
|
+
except Exception as e:
|
|
203
|
+
print("Error:", e, file=sys.stderr)
|
|
204
|
+
if self.args.verbose >= 3:
|
|
205
|
+
traceback.print_exc()
|
|
206
|
+
sys.exit(1)
|
|
207
|
+
finally:
|
|
208
|
+
if self.args.directory:
|
|
209
|
+
os.chdir(curdir)
|
chorelib/deprunner.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Asynchronous build runner with parallel execution support.
|
|
2
|
+
|
|
3
|
+
Executes build targets based on the dependency graph, comparing mtimes
|
|
4
|
+
to skip up-to-date targets. Builder functions run in a ThreadPoolExecutor
|
|
5
|
+
for parallelism.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import errno
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
14
|
+
from threading import get_ident, local
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .depgraph import BuildInfo, DepGraph
|
|
18
|
+
from .errors import RuleNotFoundError, TargetNotFoundError
|
|
19
|
+
from .ruledef import MTimeFunc, RuleSet
|
|
20
|
+
from .utils import message, to_timestamp
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Runner:
|
|
26
|
+
"""Asynchronous build runner that manages building targets based on dependencies.
|
|
27
|
+
|
|
28
|
+
Coordinates the build process: resolves the dependency graph, compares
|
|
29
|
+
mtimes to determine what needs rebuilding, and executes builder functions
|
|
30
|
+
in parallel using a ThreadPoolExecutor.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
rules: The RuleSet containing all rule, task, and mtime definitions.
|
|
34
|
+
graph: The DepGraph tracking registered targets.
|
|
35
|
+
build_tasks: Dict mapping target names to their asyncio Tasks.
|
|
36
|
+
skipped_targets: Set of targets that were up-to-date and skipped.
|
|
37
|
+
build_always: If True, rebuild all targets regardless of mtime.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self, rules: RuleSet, num_workers: int | None = 0, build_always: bool = False
|
|
42
|
+
) -> None:
|
|
43
|
+
self.loop = asyncio.get_event_loop()
|
|
44
|
+
self.threadid = get_ident()
|
|
45
|
+
self.running = False
|
|
46
|
+
self.num_workers = num_workers
|
|
47
|
+
self.build_always = build_always
|
|
48
|
+
|
|
49
|
+
self.rules = rules
|
|
50
|
+
self.graph = DepGraph()
|
|
51
|
+
self.build_tasks: dict[str, asyncio.Task[Any]] = {}
|
|
52
|
+
self.skipped_targets: set[str] = set()
|
|
53
|
+
|
|
54
|
+
logger.debug(
|
|
55
|
+
f"Initializing Runner with num_workers={self.num_workers}, "
|
|
56
|
+
f"build_always={self.build_always}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Use a thread pool for parallel builds when multiple workers requested
|
|
60
|
+
self.executor: ThreadPoolExecutor | None = None
|
|
61
|
+
if self.num_workers is None or self.num_workers > 1:
|
|
62
|
+
self.executor = ThreadPoolExecutor(max_workers=self.num_workers)
|
|
63
|
+
|
|
64
|
+
async def _run_in_executor(self, func: Any, *args: Any, **kwargs: Any) -> Any:
|
|
65
|
+
"""Run a function in the thread pool executor, or inline if single-threaded.
|
|
66
|
+
|
|
67
|
+
Sets up thread-local state so that ``schedule()`` can find the
|
|
68
|
+
active runner from within builder functions.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def wrapper() -> Any:
|
|
72
|
+
if getattr(_runner, "running", None):
|
|
73
|
+
raise RuntimeError(f"Nested calls to _run_in_executor are not allowed: {func}")
|
|
74
|
+
|
|
75
|
+
_runner.running = self
|
|
76
|
+
try:
|
|
77
|
+
logger.debug(f"Running {func} in executor")
|
|
78
|
+
return func(*args, **kwargs)
|
|
79
|
+
finally:
|
|
80
|
+
_runner.running = None
|
|
81
|
+
|
|
82
|
+
if self.executor:
|
|
83
|
+
loop = asyncio.get_running_loop()
|
|
84
|
+
return await loop.run_in_executor(self.executor, wrapper)
|
|
85
|
+
else:
|
|
86
|
+
return func(*args, **kwargs)
|
|
87
|
+
|
|
88
|
+
async def _get_mtime(self, mtimefunc: MTimeFunc, target: str) -> Any:
|
|
89
|
+
"""Retrieve the mtime of a target using its mtime function.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
TargetNotFoundError: If the mtime function returns None
|
|
93
|
+
(target does not exist).
|
|
94
|
+
"""
|
|
95
|
+
message(f"Running mtime function {mtimefunc} for target: {target}", 2)
|
|
96
|
+
ret = await self._run_in_executor(mtimefunc, target)
|
|
97
|
+
ret = to_timestamp(ret)
|
|
98
|
+
if ret is None:
|
|
99
|
+
raise TargetNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), target)
|
|
100
|
+
return ret
|
|
101
|
+
|
|
102
|
+
async def _run_build_deps(self, node: BuildInfo, build_always: bool) -> Any | None:
|
|
103
|
+
"""Build dependencies of the node and return the latest timestamp among them."""
|
|
104
|
+
deps = [self._ensure_target(t, build_always) for t in node.depends]
|
|
105
|
+
needs = [self._ensure_target(t, False) for t in node.needs]
|
|
106
|
+
dep_timestamps: list[Any] = list(await asyncio.gather(*deps))
|
|
107
|
+
await asyncio.gather(*needs)
|
|
108
|
+
if dep_timestamps:
|
|
109
|
+
return max(dep_timestamps)
|
|
110
|
+
else:
|
|
111
|
+
# No dependencies
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
async def _run_builder(self, node: BuildInfo) -> Any:
|
|
115
|
+
"""Run the builder function for the node."""
|
|
116
|
+
logger.debug(f"Starting {node.run_builder} for `{node.target}`")
|
|
117
|
+
ts = await self._run_in_executor(node.run_builder)
|
|
118
|
+
timestamp = to_timestamp(ts)
|
|
119
|
+
if timestamp is None:
|
|
120
|
+
timestamp = time.time()
|
|
121
|
+
return timestamp
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def default_get_file_mtime(name: str) -> Any:
|
|
125
|
+
"""default function to get file modification time on local filesystem."""
|
|
126
|
+
return os.path.getmtime(name)
|
|
127
|
+
|
|
128
|
+
async def _build_target(self, target: str, build_always: bool) -> Any:
|
|
129
|
+
"""Build a single target and return its timestamp.
|
|
130
|
+
|
|
131
|
+
For tasks or forced rebuilds, the builder runs unconditionally.
|
|
132
|
+
For rules, the target's mtime is compared against its dependencies'
|
|
133
|
+
mtimes to decide whether a rebuild is needed.
|
|
134
|
+
"""
|
|
135
|
+
message(f"Building target: {target}", 1)
|
|
136
|
+
|
|
137
|
+
if not self.running:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
node = self.graph.get(target)
|
|
141
|
+
|
|
142
|
+
# Tasks and forced rebuilds always execute the builder
|
|
143
|
+
if node and (build_always or node.is_task()):
|
|
144
|
+
await self._run_build_deps(node, build_always)
|
|
145
|
+
timestamp = await self._run_builder(node)
|
|
146
|
+
return timestamp
|
|
147
|
+
|
|
148
|
+
# Check the current mtime of the target
|
|
149
|
+
mtimefunc = self.rules.get_mtime_func(target)
|
|
150
|
+
timestamp_or_none: Any
|
|
151
|
+
try:
|
|
152
|
+
timestamp_or_none = await self._get_mtime(mtimefunc, target)
|
|
153
|
+
except (FileNotFoundError, TargetNotFoundError) as e:
|
|
154
|
+
message(f"File not found for target '{target}': {e!s}", 2)
|
|
155
|
+
if not node:
|
|
156
|
+
# No builder defined for this target
|
|
157
|
+
raise RuleNotFoundError(target) from e
|
|
158
|
+
timestamp_or_none = None
|
|
159
|
+
|
|
160
|
+
if node:
|
|
161
|
+
# Build dependencies and compare mtimes
|
|
162
|
+
dep_timestamp = await self._run_build_deps(node, build_always)
|
|
163
|
+
if (timestamp_or_none is None) or (
|
|
164
|
+
(dep_timestamp is not None) and (timestamp_or_none < dep_timestamp)
|
|
165
|
+
):
|
|
166
|
+
# Target is missing or older than dependencies — rebuild
|
|
167
|
+
message(f"Start building '{target}'", 2)
|
|
168
|
+
timestamp_or_none = await self._run_builder(node)
|
|
169
|
+
else:
|
|
170
|
+
self.skipped_targets.add(target)
|
|
171
|
+
message(f"Skipping build for target '{target}'; up to date.", 1)
|
|
172
|
+
|
|
173
|
+
return timestamp_or_none
|
|
174
|
+
|
|
175
|
+
def _ensure_target(self, target: str, build_always: bool) -> None:
|
|
176
|
+
"""Ensure that the target is built and return its timestamp."""
|
|
177
|
+
task = self.build_tasks.get(target)
|
|
178
|
+
if not task:
|
|
179
|
+
task = asyncio.create_task(self._build_target(target, build_always))
|
|
180
|
+
task.set_name(target)
|
|
181
|
+
self.build_tasks[target] = task
|
|
182
|
+
return task
|
|
183
|
+
|
|
184
|
+
def _addtargets(self, targets: list[str]) -> list[str]:
|
|
185
|
+
"""Add targets to the build graph."""
|
|
186
|
+
added: list[str] = []
|
|
187
|
+
for target in targets:
|
|
188
|
+
if self.graph.addtarget(self.rules, target):
|
|
189
|
+
added.append(target)
|
|
190
|
+
|
|
191
|
+
if added:
|
|
192
|
+
self.graph.detectloop()
|
|
193
|
+
|
|
194
|
+
for target in added:
|
|
195
|
+
self._ensure_target(target, self.build_always)
|
|
196
|
+
|
|
197
|
+
return added
|
|
198
|
+
|
|
199
|
+
async def wait_until_done(self) -> None:
|
|
200
|
+
"""Wait until all registered build tasks have completed."""
|
|
201
|
+
try:
|
|
202
|
+
while True:
|
|
203
|
+
tasks = [task for task in self.build_tasks.values() if not task.done()]
|
|
204
|
+
if not tasks:
|
|
205
|
+
break
|
|
206
|
+
await asyncio.gather(*tasks)
|
|
207
|
+
except Exception:
|
|
208
|
+
self.runnning = False
|
|
209
|
+
tasks = [task for task in self.build_tasks.values()]
|
|
210
|
+
for task in tasks:
|
|
211
|
+
task.cancel()
|
|
212
|
+
raise
|
|
213
|
+
|
|
214
|
+
async def run(self, targets: list[str]) -> None:
|
|
215
|
+
"""Run the build for the specified targets."""
|
|
216
|
+
self.running = True
|
|
217
|
+
_runner.running = self
|
|
218
|
+
self.skipped_targets = set()
|
|
219
|
+
|
|
220
|
+
# start building
|
|
221
|
+
self._addtargets(targets)
|
|
222
|
+
await self.wait_until_done()
|
|
223
|
+
|
|
224
|
+
for target in targets:
|
|
225
|
+
if target in self.skipped_targets:
|
|
226
|
+
message(f"{target!r} is up to date.")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# Thread-local storage for tracking the active Runner instance.
|
|
230
|
+
# Used by schedule() to find the runner from within builder functions.
|
|
231
|
+
_runner = local()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def schedule(targets: list[str]) -> None:
|
|
235
|
+
"""Dynamically add new targets to the running build from within a builder.
|
|
236
|
+
|
|
237
|
+
This function can be called from a builder function to request that
|
|
238
|
+
additional targets be built. It is thread-safe and works from both
|
|
239
|
+
the event loop thread and worker threads.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
targets: A list of target names to schedule for building.
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
RuntimeError: If no build runner is currently active.
|
|
246
|
+
"""
|
|
247
|
+
runner: Runner | None = getattr(_runner, "running", None)
|
|
248
|
+
if not runner:
|
|
249
|
+
raise RuntimeError("No build runner is running.")
|
|
250
|
+
|
|
251
|
+
if runner.threadid != get_ident():
|
|
252
|
+
# Called from a worker thread — schedule via the event loop
|
|
253
|
+
async def _schedule_targets() -> None:
|
|
254
|
+
runner._addtargets(targets) # noqa: F841
|
|
255
|
+
|
|
256
|
+
fut = asyncio.run_coroutine_threadsafe(_schedule_targets(), runner.loop)
|
|
257
|
+
fut.result()
|
|
258
|
+
else:
|
|
259
|
+
# Called from the event loop thread directly
|
|
260
|
+
runner._addtargets(targets)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def run(
|
|
264
|
+
rules: RuleSet,
|
|
265
|
+
targets: list[str],
|
|
266
|
+
num_workers: int | None = 0,
|
|
267
|
+
rebuild: bool = False,
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Run the build process for the specified targets.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
rules: The RuleSet containing rule, task, and mtime definitions.
|
|
273
|
+
targets: A list of target names to build.
|
|
274
|
+
num_workers: Maximum number of parallel worker threads.
|
|
275
|
+
0 or 1 for single-threaded, None for unlimited.
|
|
276
|
+
rebuild: If True, rebuild all targets regardless of mtime.
|
|
277
|
+
"""
|
|
278
|
+
runner = Runner(rules, num_workers=num_workers, build_always=rebuild)
|
|
279
|
+
await runner.run(targets)
|
chorelib/errors.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Exception classes for chorelib."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RuleError(Exception):
|
|
5
|
+
"""General error related to rule definitions or dependency cycles."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RuleNotFoundError(Exception):
|
|
11
|
+
"""Raised when no rule is found to build a requested target."""
|
|
12
|
+
|
|
13
|
+
def __repr__(self) -> str:
|
|
14
|
+
return f"RuleNotFoundError: {self!s}"
|
|
15
|
+
|
|
16
|
+
def __str__(self) -> str:
|
|
17
|
+
return f"No rule found to build target '{self.args[0]}'"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TargetNotFoundError(Exception):
|
|
21
|
+
"""Raised when a target file or resource does not exist and has no builder."""
|
|
22
|
+
|
|
23
|
+
def __repr__(self) -> str:
|
|
24
|
+
return f"TargetNotFoundError: {self!s}"
|
|
25
|
+
|
|
26
|
+
def __str__(self) -> str:
|
|
27
|
+
return f"Target '{self.args[0]}' not found"
|