diff --git a/hancho.py b/hancho.py index 7614c28..50a2f02 100755 --- a/hancho.py +++ b/hancho.py @@ -145,28 +145,24 @@ def flatten(variant, rule=None, depth=0): if depth > MAX_EXPAND_DEPTH: raise ValueError(f"Flattening '{variant}' failed to terminate") - if isinstance(variant, asyncio.CancelledError): - raise variant - - if inspect.isfunction(variant): - return [variant] - - if variant is None: - return [] - - if isinstance(variant, Task): - return flatten(variant.promise, rule, depth + 1) - - if isinstance(variant, Path): - return [Path(stringize(str(variant), rule, depth + 1))] - - if isinstance(variant, list): - result = [] - for element in variant: - result.extend(flatten(element, rule, depth + 1)) - return result - - return [stringize(variant, rule, depth + 1)] + match variant: + case None: + return [] + case asyncio.CancelledError(): + raise variant + case Task(): + return flatten(variant.promise, rule, depth + 1) + case Path(): + return [Path(stringize(str(variant), rule, depth + 1))] + case list(): + result = [] + for element in variant: + result.extend(flatten(element, rule, depth + 1)) + return result + case _ if inspect.isfunction(variant): + return [variant] + case _: + return [stringize(variant, rule, depth + 1)] def stringize(variant, rule=None, depth=0): @@ -176,44 +172,40 @@ def stringize(variant, rule=None, depth=0): if depth > MAX_EXPAND_DEPTH: raise ValueError(f"Stringizing '{variant}' failed to terminate") - if isinstance(variant, asyncio.CancelledError): - raise variant - - if isinstance(variant, str): - if template_regex.search(variant): - return expand(variant, rule, depth + 1) - return variant - - if variant is None: - return "" - - if isinstance(variant, Task): - return stringize(variant.promise, rule, depth + 1) - - if isinstance(variant, Path): - return stringize(str(variant), rule, depth + 1) - - if isinstance(variant, list): - variant = flatten(variant, rule, depth + 1) - variant = [str(s) for s in variant if s is not None] - variant = " ".join(variant) - return variant - - return str(variant) + match variant: + case None: + return "" + case asyncio.CancelledError(): + raise variant + case Task(): + return stringize(variant.promise, rule, depth + 1) + case Path(): + return stringize(str(variant), rule, depth + 1) + case list(): + variant = flatten(variant, rule, depth + 1) + variant = [str(s) for s in variant if s is not None] + variant = " ".join(variant) + return variant + case str(): + if template_regex.search(variant): + return expand(variant, rule, depth + 1) + return variant + case _: + return str(variant) def expand(template, rule=None, depth=0): """Expands all templates to produce a non-templated string.""" - if rule is None: - rule = config - if depth > MAX_EXPAND_DEPTH: raise ValueError(f"Expanding '{template}' failed to terminate") if not isinstance(template, str): raise ValueError(f"Don't know how to expand {type(template)}") + if rule is None: + rule = config + result = "" while span := template_regex.search(template): result += template[0 : span.start()] @@ -237,20 +229,20 @@ def expand(template, rule=None, depth=0): async def await_variant(variant): """Recursively replaces every awaitable in the variant with its awaited value.""" - if isinstance(variant, Task): - # We don't iterate through subtasks because they should await themselves except for their - # own promise. - if inspect.isawaitable(variant.promise): - variant.promise = await variant.promise - elif isinstance(variant, dict): - for key in variant: - variant[key] = await await_variant(variant[key]) - elif isinstance(variant, list): - for index, value in enumerate(variant): - variant[index] = await await_variant(value) - elif inspect.isawaitable(variant): - variant = await variant - + match variant: + case Task(): + # We don't iterate through subtasks because they should await themselves except for + # their own promise. + if inspect.isawaitable(variant.promise): + variant.promise = await variant.promise + case dict(): + for key in variant: + variant[key] = await await_variant(variant[key]) + case list(): + for index, value in enumerate(variant): + variant[index] = await await_variant(value) + case _ if inspect.isawaitable(variant): + variant = await variant return variant @@ -266,13 +258,13 @@ def load(file=None, root=None): if root is not None: file = Path(stringize(root, config)) / file + else: + file = Path(app.mod_stack[-1].__file__).parent / file - test_path = abspath(Path(app.mod_stack[-1].__file__).parent / file) - if test_path.exists(): - # print(f"load_module({test_path})") - result = app.load_module(test_path, root) - return result - raise FileNotFoundError(f"Could not load module {file}") + if not file.exists(): + raise FileNotFoundError(f"Could not load module {file}") + + return app.load_module(file, root) class Chdir: @@ -367,7 +359,6 @@ def __call__(self, files_in, files_out=None, **kwargs): coroutine = task.run_async() task.promise = asyncio.create_task(coroutine) - app.all_tasks.append(task) return task @@ -398,9 +389,10 @@ async def run_async(self): # Run the commands if we need to. if self.reason: result = await self.run_commands() + app.tasks_pass += 1 else: - app.tasks_skip += 1 result = self.abs_files_out + app.tasks_skip += 1 return result @@ -417,7 +409,7 @@ async def run_async(self): # If any of this tasks's dependencies were cancelled, we propagate the # cancellation to downstream tasks. except asyncio.CancelledError as cancel: - app.tasks_skip += 1 + app.tasks_cancel += 1 return cancel finally: @@ -514,6 +506,77 @@ def strip(f): # Check if we need a rebuild self.reason = self.needs_rerun(self.force) + def needs_rerun(self, force=False): + """Checks if a task needs to be re-run, and returns a non-empty reason if so.""" + + # Pylint really doesn't like this function, lol. + # pylint: disable=too-many-return-statements + # pylint: disable=too-many-branches + + files_in = self.abs_files_in + files_out = self.abs_files_out + + if force: + return f"Files {self.files_out} forced to rebuild" + if not files_in: + return "Always rebuild a target with no inputs" + if not files_out: + return "Always rebuild a target with no outputs" + + # Tasks with missing outputs always run. + for file_out in files_out: + if not file_out.exists(): + return f"Rebuilding {self.files_out} because some are missing" + + # Check if any task inputs are newer than any outputs. + min_out = min(mtime(f) for f in files_out) + if files_in and max(mtime(f) for f in files_in) >= min_out: + return f"Rebuilding {self.files_out} because an input has changed" + + # Check if the hancho file(s) that generated the task have changed. + if max(mtime(f) for f in app.hancho_mods) >= min_out: + return f"Rebuilding {self.files_out} because its .hancho files have changed" + + # Check if any user-specified deps have changed. + if self.deps and max(mtime(f) for f in self.deps) >= min_out: + return f"Rebuilding {self.files_out} because a dependency has changed" + + for key in self.named_deps: + if mtime(self.named_deps[key]) >= min_out: + return f"Rebuilding {self.files_out} because a named dependency has changed" + + # Check all dependencies in the depfile, if present. + if self.depfile: + abs_depfile = abspath(self.root_dir / self.depfile) + if abs_depfile.exists(): + if self.debug: + log(f"Found depfile {abs_depfile}") + with open(abs_depfile, encoding="utf-8") as depfile: + deplines = None + if self.depformat == "msvc": + # MSVC /sourceDependencies json depfile + deplines = json.load(depfile)["Data"]["Includes"] + elif self.depformat == "gcc": + # GCC .d depfile + deplines = depfile.read().split() + deplines = [d for d in deplines[1:] if d != "\\"] + else: + raise ValueError(f"Invalid depformat {self.depformat}") + + # The contents of the depfile are RELATIVE TO THE WORKING DIRECTORY + deplines = [self.work_dir / Path(d) for d in deplines] + if deplines and max(mtime(f) for f in deplines) >= min_out: + return ( + f"Rebuilding {self.files_out} because a dependency in " + + f"{abs_depfile} has changed" + ) + + # All checks passed; we don't need to rebuild this output. + if self.debug: + log(f"Files {self.files_out} are up to date") + + return None + async def run_commands(self): """Grabs a lock on the jobs needed to run this task's commands, then runs all of them.""" @@ -559,7 +622,6 @@ async def run_commands(self): + f"Reason: {second_reason}" ) - app.tasks_pass += 1 return result async def run_command(self, command): @@ -571,10 +633,7 @@ async def run_command(self, command): # Custom commands just get called and then early-out'ed. if callable(command): - result = command(self) - if result is None: - raise ValueError(f"Command {command} returned None") - return result + return command(self) # Non-string non-callable commands are not valid if not isinstance(command, str): @@ -609,77 +668,6 @@ async def run_command(self, command): # Task passed, return the output file list return self.abs_files_out - def needs_rerun(self, force=False): - """Checks if a task needs to be re-run, and returns a non-empty reason if so.""" - - # Pylint really doesn't like this function, lol. - # pylint: disable=too-many-return-statements - # pylint: disable=too-many-branches - - files_in = self.abs_files_in - files_out = self.abs_files_out - - if force: - return f"Files {self.files_out} forced to rebuild" - if not files_in: - return "Always rebuild a target with no inputs" - if not files_out: - return "Always rebuild a target with no outputs" - - # Tasks with missing outputs always run. - for file_out in files_out: - if not file_out.exists(): - return f"Rebuilding {self.files_out} because some are missing" - - # Check if any task inputs are newer than any outputs. - min_out = min(mtime(f) for f in files_out) - if files_in and max(mtime(f) for f in files_in) >= min_out: - return f"Rebuilding {self.files_out} because an input has changed" - - # Check if the hancho file(s) that generated the task have changed. - if max(mtime(f) for f in app.hancho_mods) >= min_out: - return f"Rebuilding {self.files_out} because its .hancho files have changed" - - # Check if any user-specified deps have changed. - if self.deps and max(mtime(f) for f in self.deps) >= min_out: - return f"Rebuilding {self.files_out} because a dependency has changed" - - for key in self.named_deps: - if mtime(self.named_deps[key]) >= min_out: - return f"Rebuilding {self.files_out} because a named dependency has changed" - - # Check all dependencies in the depfile, if present. - if self.depfile: - abs_depfile = abspath(self.root_dir / self.depfile) - if abs_depfile.exists(): - if self.debug: - log(f"Found depfile {abs_depfile}") - with open(abs_depfile, encoding="utf-8") as depfile: - deplines = None - if self.depformat == "msvc": - # MSVC /sourceDependencies json depfile - deplines = json.load(depfile)["Data"]["Includes"] - elif self.depformat == "gcc": - # GCC .d depfile - deplines = depfile.read().split() - deplines = [d for d in deplines[1:] if d != "\\"] - else: - raise ValueError(f"Invalid depformat {self.depformat}") - - # The contents of the depfile are RELATIVE TO THE WORKING DIRECTORY - deplines = [self.work_dir / Path(d) for d in deplines] - if deplines and max(mtime(f) for f in deplines) >= min_out: - return ( - f"Rebuilding {self.files_out} because a dependency in " - + f"{abs_depfile} has changed" - ) - - # All checks passed; we don't need to rebuild this output. - if self.debug: - log(f"Files {self.files_out} are up to date") - - return None - class App: """The application state. Mostly here so that the linter will stop complaining about my use of @@ -689,12 +677,12 @@ class App: def __init__(self): self.hancho_mods = {} self.mod_stack = [] - self.all_tasks = [] self.all_files_out = set() self.tasks_total = 0 self.tasks_pass = 0 self.tasks_fail = 0 self.tasks_skip = 0 + self.tasks_cancel = 0 self.task_counter = 0 self.mtime_calls = 0 self.line_dirty = False @@ -769,18 +757,19 @@ async def async_main(self): # Done, print status info if needed if config.debug or config.verbose: - log(f"tasks total: {self.tasks_total}") - log(f"tasks passed: {self.tasks_pass}") - log(f"tasks failed: {self.tasks_fail}") - log(f"tasks skipped: {self.tasks_skip}") - log(f"mtime calls: {self.mtime_calls}") + log(f"tasks total: {self.tasks_total}") + log(f"tasks passed: {self.tasks_pass}") + log(f"tasks failed: {self.tasks_fail}") + log(f"tasks skipped: {self.tasks_skip}") + log(f"tasks cancelled: {self.tasks_cancel}") + log(f"mtime calls: {self.mtime_calls}") if self.tasks_fail: - log(f"hancho: {color(255, 0, 0)}BUILD FAILED{color()}") + log(f"hancho: {color(255, 128, 128)}BUILD FAILED{color()}") elif self.tasks_pass: - log(f"hancho: {color(0, 255, 0)}BUILD PASSED{color()}") + log(f"hancho: {color(128, 255, 128)}BUILD PASSED{color()}") else: - log(f"hancho: {color(255, 255, 0)}BUILD CLEAN{color()}") + log(f"hancho: {color(128, 128, 255)}BUILD CLEAN{color()}") return -1 if self.tasks_fail else 0