Skip to content
Open
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
53 changes: 46 additions & 7 deletions src/pytest_just/fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,15 @@ def _dump(self) -> dict[str, Any]:
raise JustJsonFormatError("Unexpected just JSON format: top-level value must be an object.")
return loaded

@cached_property
def _flattened(self) -> dict[str, Any]:
"""Cached result of ``_flatten`` — recipes and modules."""
return self._flatten(self._dump)

@property
def _recipes(self) -> dict[str, dict[str, Any]]:
"""Return the validated recipe mapping from the parsed just dump."""
recipes = self._dump.get("recipes", {})
if not isinstance(recipes, dict):
raise JustJsonFormatError("Unexpected just JSON format: `recipes` must be a mapping.")
if not all(isinstance(value, dict) for value in recipes.values()):
raise JustJsonFormatError("Unexpected just JSON format: each recipe payload must be an object.")
return recipes
"""Recipes flattened across all modules, keyed by namepath."""
return self._flattened["recipes"]

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

@property
def module_namepaths(self) -> list[str]:
"""All module paths from the dump, sorted alphabetically."""
return self._flattened["modules"]

@staticmethod
def _flatten(node: dict[str, Any]) -> dict[str, Any]:
"""Flatten the nested module tree from a just JSON dump.

Returns a dict with ``recipes`` (flat {namepath: recipe_data}) and
``modules`` (sorted list of module paths).

Args:
node: A just JSON dump (root level).

Returns:
``{"recipes": {namepath: data, ...}, "modules": ["mod", "mod::sub", ...]}``
"""
recipes: dict[str, dict[str, Any]] = {}
modules: list[str] = []

def walk(n: dict[str, Any], prefix: str = "") -> None:
raw = n.get("recipes", {})
if not isinstance(raw, dict):
raise JustJsonFormatError("Unexpected just JSON format: `recipes` must be a mapping.")
for name, data in raw.items():
if not isinstance(data, dict):
raise JustJsonFormatError("Unexpected just JSON format: each recipe payload must be an object.")
recipes[data.get("namepath", name)] = data
for mod_name, mod_data in n.get("modules", {}).items():
if not isinstance(mod_data, dict):
continue
mod_path = f"{prefix}::{mod_name}" if prefix else mod_name
modules.append(mod_path)
walk(mod_data, mod_path)

walk(node)
return {"recipes": recipes, "modules": sorted(modules)}

def _run(
self,
*args: str,
Expand Down
133 changes: 133 additions & 0 deletions tests/test_module_fixture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Tests for ``JustfileFixture`` module-aware recipe resolution."""

from __future__ import annotations

import shutil
from textwrap import dedent
from pathlib import Path

import pytest

from pytest_just import JustfileFixture
from pytest_just.errors import UnknownRecipeError


pytestmark = pytest.mark.skipif(shutil.which("just") is None, reason="just binary is required")


@pytest.fixture
def module_tree(tmp_path: Path) -> JustfileFixture:
"""Justfile with a module and submodule."""
(tmp_path / "justfile").write_text(dedent("""
mod infra

root:
@echo root
"""), encoding="utf-8")
mod = tmp_path / "infra"
mod.mkdir()
(mod / "mod.just").write_text(dedent("""
mod deploy

_require-infra:
@true

status: _require-infra
@echo ok
"""), encoding="utf-8")
sub = mod / "deploy"
sub.mkdir()
(sub / "mod.just").write_text(dedent("""
[private]
up profile='default': _require-deploy
@echo up {{ profile }}

_require-deploy:
@true
"""), encoding="utf-8")
return JustfileFixture(root=tmp_path)


def test_recipes_flattened_by_namepath(module_tree: JustfileFixture) -> None:
"""Recipes from modules appear keyed by full namepath."""
names = module_tree.recipe_names(include_private=True)
assert "root" in names
assert "infra::status" in names
assert "infra::_require-infra" in names
assert "infra::deploy::up" in names
assert "infra::deploy::_require-deploy" in names


def test_is_private_with_namepath(module_tree: JustfileFixture) -> None:
"""Privacy checks work with full module paths."""
assert not module_tree.is_private("root")
assert not module_tree.is_private("infra::status")
assert module_tree.is_private("infra::_require-infra")
assert module_tree.is_private("infra::deploy::up")


def test_dependencies_with_namepath(module_tree: JustfileFixture) -> None:
"""Dependency inspection works with full module paths.

Note: just stores dependency names as module-local names (not namepaths),
so dependencies() returns local names like ``_require-infra``.
"""
assert "_require-infra" in module_tree.dependencies("infra::status")
assert "_require-deploy" in module_tree.dependencies("infra::deploy::up")


def test_parameters_with_namepath(module_tree: JustfileFixture) -> None:
"""Parameter inspection works with full module paths."""
assert "profile" in module_tree.parameter_names("infra::deploy::up")


def test_unknown_namepath_raises(module_tree: JustfileFixture) -> None:
"""Unknown namepath raises UnknownRecipeError."""
with pytest.raises(UnknownRecipeError):
module_tree.dependencies("infra::nonexistent")


def test_module_namepaths(module_tree: JustfileFixture) -> None:
"""module_namepaths discovers module paths from the dump."""
assert "infra" in module_tree.module_namepaths
assert "infra::deploy" in module_tree.module_namepaths


def test_recipe_names_excludes_private_by_default(module_tree: JustfileFixture) -> None:
"""Public recipe listing excludes private recipes across modules."""
public = module_tree.recipe_names()
assert "infra::status" in public
assert "infra::_require-infra" not in public
assert "infra::deploy::up" not in public # marked [private]


def test_flatten_static_method() -> None:
"""_flatten works as a standalone static method."""
dump = {
"recipes": {
"root": {"namepath": "root", "private": False},
},
"modules": {
"foo": {
"recipes": {
"bar": {"namepath": "foo::bar", "private": False},
},
"modules": {
"baz": {
"recipes": {
"qux": {"namepath": "foo::baz::qux", "private": True},
},
"modules": {},
}
},
}
},
}

result = JustfileFixture._flatten(dump)
recipes = result["recipes"]
assert set(recipes.keys()) == {"root", "foo::bar", "foo::baz::qux"}
assert recipes["root"]["private"] is False
assert recipes["foo::bar"]["private"] is False
assert recipes["foo::baz::qux"]["private"] is True
assert result["modules"] == ["foo", "foo::baz"]