Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 8 additions & 4 deletions src/invoke_toolkit/collections.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Extended collection with package inspection"""

import importlib
import hashlib
import importlib.util
import pkgutil
import sys
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions tests/examples/local_tasks/local_tasks.py
Original file line number Diff line number Diff line change
@@ -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]")
18 changes: 18 additions & 0 deletions tests/examples/local_tasks/tasks.py
Original file line number Diff line number Diff line change
@@ -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]")
70 changes: 70 additions & 0 deletions tests/test_collection.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import ast
import os
import subprocess
import sys
from pathlib import Path
from textwrap import dedent
Expand Down Expand Up @@ -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
Loading