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.
@@ -0,0 +1,2 @@
1
+ # Automatically created by ruff.
2
+ *
@@ -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"