diff --git a/.gitignore b/.gitignore index ecc3506..8cc6708 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ invoke.yaml *.md !docs/**/*.md .worktrees/ + +# Ship the local_tasks discovery example (local_tasks.py is globally ignored) +!tests/examples/local_tasks/local_tasks.py diff --git a/src/invoke_toolkit/collections.py b/src/invoke_toolkit/collections.py index e3b5aba..51b9e7d 100644 --- a/src/invoke_toolkit/collections.py +++ b/src/invoke_toolkit/collections.py @@ -1,6 +1,6 @@ """Extended collection with package inspection""" -import importlib +import hashlib import importlib.util import pkgutil import sys @@ -257,12 +257,16 @@ def load_local_tasks(self, search_path: str | Path | None = None) -> None: sys.path.insert(0, search_path_str) try: - spec = importlib.util.spec_from_file_location( - "local_tasks", local_tasks_file + module_name = ( + "_invoke_toolkit_local_tasks_" + + hashlib.sha256( + str(local_tasks_file.resolve()).encode("utf-8") + ).hexdigest() ) + spec = importlib.util.spec_from_file_location(module_name, local_tasks_file) if spec and spec.loader: local_tasks_module = importlib.util.module_from_spec(spec) - sys.modules["local_tasks"] = local_tasks_module + sys.modules[module_name] = local_tasks_module spec.loader.exec_module(local_tasks_module) # Create a collection from the local_tasks module diff --git a/tests/examples/local_tasks/local_tasks.py b/tests/examples/local_tasks/local_tasks.py new file mode 100644 index 0000000..984bd5d --- /dev/null +++ b/tests/examples/local_tasks/local_tasks.py @@ -0,0 +1,15 @@ +"""Project-local tasks for the local_tasks discovery example. + +These tasks are discovered from ``local_tasks.py`` next to ``tasks.py`` and are +added under the ``local`` namespace (for example ``local.deploy``). Keeping them +here lets you separate machine- or checkout-specific tasks from the shared +``tasks.py`` collection. +""" + +from invoke_toolkit import Context, task + + +@task +def deploy(ctx: Context) -> None: + """Deploy the project (defined in local_tasks.py).""" + ctx.print("[cyan]Deploying project from local tasks[/cyan]") diff --git a/tests/examples/local_tasks/tasks.py b/tests/examples/local_tasks/tasks.py new file mode 100644 index 0000000..8dfaca2 --- /dev/null +++ b/tests/examples/local_tasks/tasks.py @@ -0,0 +1,18 @@ +"""Main task collection for the local_tasks discovery example. + +This example demonstrates how ``local_tasks.py`` is discovered alongside a +project ``tasks.py`` and exposed under the ``local`` namespace. + +Run with: + intk --search-root tests/examples/local_tasks --list + intk --search-root tests/examples/local_tasks build + intk --search-root tests/examples/local_tasks local.deploy +""" + +from invoke_toolkit import Context, task + + +@task +def build(ctx: Context) -> None: + """Build the project (defined in tasks.py).""" + ctx.print("[green]Building project[/green]") diff --git a/tests/test_collection.py b/tests/test_collection.py index 5bf2464..92851f8 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -1,4 +1,6 @@ import ast +import os +import subprocess import sys from pathlib import Path from textwrap import dedent @@ -212,3 +214,71 @@ def standalone_task(ctx): # Verify task is in the local collection assert "standalone-task" in local_col.tasks + + +def test_load_local_tasks_from_multiple_directories_without_module_collision( + tmp_path: Path, +): + """Loading local task files from different directories keeps both modules distinct.""" + first = tmp_path / "first" + second = tmp_path / "second" + first.mkdir() + second.mkdir() + (first / "local_tasks.py").write_text( + "from invoke_toolkit import task\n\n@task()\ndef first_task(ctx):\n pass\n" + ) + (second / "local_tasks.py").write_text( + "from invoke_toolkit import task\n\n@task()\ndef second_task(ctx):\n pass\n" + ) + + local_module_names_before = { + name for name in sys.modules if name.startswith("_invoke_toolkit_local_tasks_") + } + first_collection = ToolkitCollection() + second_collection = ToolkitCollection() + first_collection.load_local_tasks(search_path=first) + second_collection.load_local_tasks(search_path=second) + + assert "first-task" in first_collection.collections["local"].tasks + assert "second-task" in second_collection.collections["local"].tasks + local_module_names_after = { + name for name in sys.modules if name.startswith("_invoke_toolkit_local_tasks_") + } + assert len(local_module_names_after - local_module_names_before) == 2 + + +def test_local_tasks_discovery_prefers_project_over_home(tmp_path: Path): + """A project's local_tasks.py wins over an unrelated one in the home directory. + + Reproduces ``~/code/project_1/{tasks.py,local_tasks.py}`` while ``~`` also + holds a ``local_tasks.py``. Running from the project must load the project's + tasks and local tasks, never the home-directory local tasks. + """ + fake_home = tmp_path / "home" + project = fake_home / "code" / "project_1" + project.mkdir(parents=True) + + (fake_home / "local_tasks.py").write_text( + "from invoke_toolkit import task\n\n@task()\ndef home_local(ctx):\n pass\n" + ) + (project / "tasks.py").write_text( + "from invoke_toolkit import task\n\n@task()\ndef project_main(ctx):\n pass\n" + ) + (project / "local_tasks.py").write_text( + "from invoke_toolkit import task\n\n@task()\ndef project_local(ctx):\n pass\n" + ) + subprocess.run(["git", "init", "-q"], cwd=project, check=True) + + env = {**os.environ, "HOME": str(fake_home)} + result = subprocess.run( + [sys.executable, "-m", "invoke_toolkit", "--list"], + cwd=project, + env=env, + capture_output=True, + text=True, + check=True, + ) + + assert "project-main" in result.stdout + assert "local.project-local" in result.stdout + assert "home-local" not in result.stdout