Skip to content

Commit 944eeeb

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 944eeeb

2 files changed

Lines changed: 209 additions & 7 deletions

File tree

src/pytest_just/fixture.py

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,22 @@ 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-
@property
46-
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):
45+
@cached_property
46+
def _flattened(self) -> dict[str, Any]:
47+
"""Flatten the dump once, returning recipes and modules."""
48+
raw = self._dump.get("recipes", {})
49+
if not isinstance(raw, dict):
5050
raise JustJsonFormatError("Unexpected just JSON format: `recipes` must be a mapping.")
51-
if not all(isinstance(value, dict) for value in recipes.values()):
51+
if not all(isinstance(value, dict) for value in raw.values()):
5252
raise JustJsonFormatError("Unexpected just JSON format: each recipe payload must be an object.")
53-
return recipes
53+
modules: list[str] = []
54+
recipes = self._flatten(self._dump, modules)
55+
return {"recipes": recipes, "modules": sorted(modules)}
56+
57+
@property
58+
def _recipes(self) -> dict[str, dict[str, Any]]:
59+
"""Recipes flattened across all modules, keyed by namepath."""
60+
return self._flattened["recipes"]
5461

5562
def recipe_names(self, *, include_private: bool = False) -> list[str]:
5663
"""List recipe names, optionally including private recipes."""
@@ -233,6 +240,48 @@ def assert_dry_run_contains(
233240
f"Output:\n{combined_output}"
234241
)
235242

243+
@property
244+
def all_modules(self) -> list[str]:
245+
"""All module paths from the dump, sorted alphabetically.
246+
247+
Example:
248+
``["module_a", "module_a::submodule_a"]``
249+
"""
250+
return self._flattened["modules"]
251+
252+
@staticmethod
253+
def _flatten(
254+
node: dict[str, Any],
255+
modules: list[str],
256+
_prefix: str = "",
257+
) -> dict[str, dict[str, Any]]:
258+
"""Recursively flatten nested dump into {namepath: recipe_data}.
259+
260+
Walks the module tree in the just JSON dump and returns a flat dict
261+
keyed by each recipe's ``namepath`` (e.g. ``module::submodule::recipe``).
262+
Module paths are appended to ``modules``.
263+
264+
Args:
265+
node: A just JSON dump node (root or module subtree).
266+
modules: Module paths are appended to this list.
267+
_prefix: Internal — current module path prefix for recursion.
268+
269+
Returns:
270+
Flat dict mapping namepath strings to recipe data dicts.
271+
"""
272+
recipes: dict[str, dict[str, Any]] = {}
273+
for name, data in node.get("recipes", {}).items():
274+
if not isinstance(data, dict):
275+
continue
276+
recipes[data.get("namepath", name)] = data
277+
for mod_name, mod_data in node.get("modules", {}).items():
278+
if not isinstance(mod_data, dict):
279+
continue
280+
mod_path = f"{_prefix}::{mod_name}" if _prefix else mod_name
281+
modules.append(mod_path)
282+
recipes.update(JustfileFixture._flatten(mod_data, modules, mod_path))
283+
return recipes
284+
236285
def _run(
237286
self,
238287
*args: str,

tests/test_module_fixture.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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_all_modules(module_tree: JustfileFixture) -> None:
91+
"""all_modules discovers module paths from the dump."""
92+
assert "infra" in module_tree.all_modules
93+
assert "infra::deploy" in module_tree.all_modules
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+
modules: list[str] = []
128+
flat = JustfileFixture._flatten(dump, modules)
129+
assert set(flat.keys()) == {"root", "foo::bar", "foo::baz::qux"}
130+
assert flat["foo::baz::qux"]["private"] is True
131+
assert sorted(modules) == ["foo", "foo::baz"]
132+
133+
134+
def test_flatten_collects_modules() -> None:
135+
"""_flatten collects module paths when given a list."""
136+
dump = {
137+
"recipes": {},
138+
"modules": {
139+
"foo": {
140+
"recipes": {},
141+
"modules": {
142+
"bar": {
143+
"recipes": {},
144+
"modules": {},
145+
}
146+
},
147+
}
148+
},
149+
}
150+
151+
modules: list[str] = []
152+
JustfileFixture._flatten(dump, modules)
153+
assert sorted(modules) == ["foo", "foo::bar"]

0 commit comments

Comments
 (0)