Skip to content

Commit 46a61b3

Browse files
committed
feat: add module support to JustfileFixture
Flattens the nested module tree from just --dump into a flat dict keyed by recipe namepaths (e.g. module::submodule::recipe). All existing methods (is_private, dependencies, parameters, etc.) work transparently with full module paths. Adds all_modules property for module discovery. Backwards compatible — justfiles without modules work unchanged.
1 parent 25fd4c5 commit 46a61b3

2 files changed

Lines changed: 179 additions & 7 deletions

File tree

src/pytest_just/fixture.py

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,15 @@ def _dump(self) -> dict[str, Any]:
4242
raise JustJsonFormatError("Unexpected just JSON format: top-level value must be an object.")
4343
return loaded
4444

45+
@cached_property
46+
def _flattened(self) -> dict[str, Any]:
47+
"""Cached result of ``_flatten`` — recipes and modules."""
48+
return self._flatten(self._dump)
49+
4550
@property
4651
def _recipes(self) -> dict[str, dict[str, Any]]:
47-
"""Return the validated recipe mapping from the parsed just dump."""
48-
recipes = self._dump.get("recipes", {})
49-
if not isinstance(recipes, dict):
50-
raise JustJsonFormatError("Unexpected just JSON format: `recipes` must be a mapping.")
51-
if not all(isinstance(value, dict) for value in recipes.values()):
52-
raise JustJsonFormatError("Unexpected just JSON format: each recipe payload must be an object.")
53-
return recipes
52+
"""Recipes flattened across all modules, keyed by namepath."""
53+
return self._flattened["recipes"]
5454

5555
def recipe_names(self, *, include_private: bool = False) -> list[str]:
5656
"""List recipe names, optionally including private recipes."""
@@ -233,6 +233,45 @@ def assert_dry_run_contains(
233233
f"Output:\n{combined_output}"
234234
)
235235

236+
@property
237+
def module_namepaths(self) -> list[str]:
238+
"""All module paths from the dump, sorted alphabetically."""
239+
return self._flattened["modules"]
240+
241+
@staticmethod
242+
def _flatten(node: dict[str, Any]) -> dict[str, Any]:
243+
"""Flatten the nested module tree from a just JSON dump.
244+
245+
Returns a dict with ``recipes`` (flat {namepath: recipe_data}) and
246+
``modules`` (sorted list of module paths).
247+
248+
Args:
249+
node: A just JSON dump (root level).
250+
251+
Returns:
252+
``{"recipes": {namepath: data, ...}, "modules": ["mod", "mod::sub", ...]}``
253+
"""
254+
recipes: dict[str, dict[str, Any]] = {}
255+
modules: list[str] = []
256+
257+
def walk(n: dict[str, Any], prefix: str = "") -> None:
258+
raw = n.get("recipes", {})
259+
if not isinstance(raw, dict):
260+
raise JustJsonFormatError("Unexpected just JSON format: `recipes` must be a mapping.")
261+
for name, data in raw.items():
262+
if not isinstance(data, dict):
263+
raise JustJsonFormatError("Unexpected just JSON format: each recipe payload must be an object.")
264+
recipes[data.get("namepath", name)] = data
265+
for mod_name, mod_data in n.get("modules", {}).items():
266+
if not isinstance(mod_data, dict):
267+
continue
268+
mod_path = f"{prefix}::{mod_name}" if prefix else mod_name
269+
modules.append(mod_path)
270+
walk(mod_data, mod_path)
271+
272+
walk(node)
273+
return {"recipes": recipes, "modules": sorted(modules)}
274+
236275
def _run(
237276
self,
238277
*args: str,

tests/test_module_fixture.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Tests for ``JustfileFixture`` module-aware recipe resolution."""
2+
3+
from __future__ import annotations
4+
5+
import shutil
6+
from textwrap import dedent
7+
from pathlib import Path
8+
9+
import pytest
10+
11+
from pytest_just import JustfileFixture
12+
from pytest_just.errors import UnknownRecipeError
13+
14+
15+
pytestmark = pytest.mark.skipif(shutil.which("just") is None, reason="just binary is required")
16+
17+
18+
@pytest.fixture
19+
def module_tree(tmp_path: Path) -> JustfileFixture:
20+
"""Justfile with a module and submodule."""
21+
(tmp_path / "justfile").write_text(dedent("""
22+
mod infra
23+
24+
root:
25+
@echo root
26+
"""), encoding="utf-8")
27+
mod = tmp_path / "infra"
28+
mod.mkdir()
29+
(mod / "mod.just").write_text(dedent("""
30+
mod deploy
31+
32+
_require-infra:
33+
@true
34+
35+
status: _require-infra
36+
@echo ok
37+
"""), encoding="utf-8")
38+
sub = mod / "deploy"
39+
sub.mkdir()
40+
(sub / "mod.just").write_text(dedent("""
41+
[private]
42+
up profile='default': _require-deploy
43+
@echo up {{ profile }}
44+
45+
_require-deploy:
46+
@true
47+
"""), encoding="utf-8")
48+
return JustfileFixture(root=tmp_path)
49+
50+
51+
def test_recipes_flattened_by_namepath(module_tree: JustfileFixture) -> None:
52+
"""Recipes from modules appear keyed by full namepath."""
53+
names = module_tree.recipe_names(include_private=True)
54+
assert "root" in names
55+
assert "infra::status" in names
56+
assert "infra::_require-infra" in names
57+
assert "infra::deploy::up" in names
58+
assert "infra::deploy::_require-deploy" in names
59+
60+
61+
def test_is_private_with_namepath(module_tree: JustfileFixture) -> None:
62+
"""Privacy checks work with full module paths."""
63+
assert not module_tree.is_private("root")
64+
assert not module_tree.is_private("infra::status")
65+
assert module_tree.is_private("infra::_require-infra")
66+
assert module_tree.is_private("infra::deploy::up")
67+
68+
69+
def test_dependencies_with_namepath(module_tree: JustfileFixture) -> None:
70+
"""Dependency inspection works with full module paths.
71+
72+
Note: just stores dependency names as module-local names (not namepaths),
73+
so dependencies() returns local names like ``_require-infra``.
74+
"""
75+
assert "_require-infra" in module_tree.dependencies("infra::status")
76+
assert "_require-deploy" in module_tree.dependencies("infra::deploy::up")
77+
78+
79+
def test_parameters_with_namepath(module_tree: JustfileFixture) -> None:
80+
"""Parameter inspection works with full module paths."""
81+
assert "profile" in module_tree.parameter_names("infra::deploy::up")
82+
83+
84+
def test_unknown_namepath_raises(module_tree: JustfileFixture) -> None:
85+
"""Unknown namepath raises UnknownRecipeError."""
86+
with pytest.raises(UnknownRecipeError):
87+
module_tree.dependencies("infra::nonexistent")
88+
89+
90+
def test_module_namepaths(module_tree: JustfileFixture) -> None:
91+
"""module_namepaths discovers module paths from the dump."""
92+
assert "infra" in module_tree.module_namepaths
93+
assert "infra::deploy" in module_tree.module_namepaths
94+
95+
96+
def test_recipe_names_excludes_private_by_default(module_tree: JustfileFixture) -> None:
97+
"""Public recipe listing excludes private recipes across modules."""
98+
public = module_tree.recipe_names()
99+
assert "infra::status" in public
100+
assert "infra::_require-infra" not in public
101+
assert "infra::deploy::up" not in public # marked [private]
102+
103+
104+
def test_flatten_static_method() -> None:
105+
"""_flatten works as a standalone static method."""
106+
dump = {
107+
"recipes": {
108+
"root": {"namepath": "root", "private": False},
109+
},
110+
"modules": {
111+
"foo": {
112+
"recipes": {
113+
"bar": {"namepath": "foo::bar", "private": False},
114+
},
115+
"modules": {
116+
"baz": {
117+
"recipes": {
118+
"qux": {"namepath": "foo::baz::qux", "private": True},
119+
},
120+
"modules": {},
121+
}
122+
},
123+
}
124+
},
125+
}
126+
127+
result = JustfileFixture._flatten(dump)
128+
recipes = result["recipes"]
129+
assert set(recipes.keys()) == {"root", "foo::bar", "foo::baz::qux"}
130+
assert recipes["root"]["private"] is False
131+
assert recipes["foo::bar"]["private"] is False
132+
assert recipes["foo::baz::qux"]["private"] is True
133+
assert result["modules"] == ["foo", "foo::baz"]

0 commit comments

Comments
 (0)