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
4 changes: 4 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ build-readme:
build-contributors:
sh ./tools/get-contributors.sh seedcase-project/seedcase-flower > docs/includes/_contributors.qmd

# Generate updated help-output strings for copy-pasting into test_cli.py
generate-help-strings:
PYTHONPATH=. uv run python tools/generate-help-strings.py

# Preview the documentation website with automatic reload on changes
preview-website: build-quartodoc
uv run quarto preview --execute
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ dev = [
"pre-commit>=4.5.0",
"pytest>=9.0.1",
"pytest-cov>=7.0.0",
"pytest-mock>=3.14.0",
"quartodoc>=0.11.1",
"ruff>=0.14.7",
"types-tabulate>=0.9.0.20241207",
Expand Down
3 changes: 2 additions & 1 deletion src/seedcase_flower/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from seedcase_flower.internals import BuildStyle, _read_properties, _resolve_uri

app = App(
name="seedcase-flower",
help="Flower generates human-readable documentation from Data Packages.",
default_parameter=Parameter(negative=()),
config=[
Expand All @@ -19,7 +20,7 @@
),
config.Toml(
"pyproject.toml",
root_keys="tool.seedcase-flower",
root_keys=["tool", "seedcase-flower"],
search_parents=True,
use_commands_as_keys=False,
),
Expand Down
198 changes: 164 additions & 34 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,50 +1,180 @@
"""These are integration tests for the CLI commands."""
"""Tests for the CLI commands."""

import json
from pathlib import Path
from textwrap import dedent

from pytest import fixture, mark
import pytest

from seedcase_flower.cli import build, view
from seedcase_flower.cli import app, view
from seedcase_flower.internals import BuildStyle

_DATAPACKAGE_DATA = {
"name": "placeholder",
"created": "2026-02-12T11:25:49+01:00",
"description": "Placeholder",
"id": "Placeholder",
"licenses": [{"name": "Placeholder"}],
"title": "Placeholder",
"version": "0.0.0",
}

# Create a file at tmp_path that is automatically cleaned up after tests finish
@fixture
def datapackage_path(tmp_path):
data = {
"name": "placeholder",
"created": "2026-02-12T11:25:49+01:00",
"description": "Placeholder",
"id": "Placeholder",
"licenses": [{"name": "Placeholder"}],
"title": "Placeholder",
"version": "0.0.0",
}

@pytest.fixture
def datapackage_path(tmp_path):
"""Create a temporary datapackage.json and return its path as a string."""
file_path = tmp_path / "datapackage.json"
file_path.write_text(json.dumps(data))

# Since `build` expects a str as the URI
file_path.write_text(json.dumps(_DATAPACKAGE_DATA))
return str(file_path)


@mark.parametrize(
"style, expected",
[
(BuildStyle.quarto_one_page, None),
],
@pytest.fixture
def mock_resolve_uri(mocker):
"""Mock _resolve_uri to isolate CLI tests from filesystem resolution."""
return mocker.patch("seedcase_flower.cli._resolve_uri")


@pytest.fixture
def mock_read_properties(mocker):
"""Mock _read_properties to isolate CLI tests from file I/O."""
return mocker.patch("seedcase_flower.cli._read_properties")


# Testing CLI invocation ====


def test_build_with_mocked_internals(mock_resolve_uri, mock_read_properties):
"""Isolate CLI behaviour by mocking internal helpers."""
fake_path = Path("datapackage.json")
mock_resolve_uri.return_value = fake_path
# Simulate running the app from the command line (but without calling sys.exit())
app(["build", "datapackage.json"], result_action="return_value")

# Checking that the correct values were passed to the internal functions
mock_resolve_uri.assert_called_once_with("datapackage.json")
mock_read_properties.assert_called_once_with(fake_path)


# Checking stdout ====


# TODO: Update this when verbose is added.
def test_build_verbose_prints_output(capsys, datapackage_path):
"""--verbose should print output_dir, properties, template_dir, and style."""
app(
["build", datapackage_path, "--verbose"],
result_action="return_value",
)
expected = f"docs {_DATAPACKAGE_DATA} None BuildStyle.quarto_one_page\n"
assert capsys.readouterr().out == expected


def test_build_no_verbose_produces_no_output(capsys, datapackage_path):
"""Without --verbose, build should produce no stdout."""
app(["build", datapackage_path], result_action="return_value")
assert capsys.readouterr().out == ""


# File-based config ====


def test_build_reads_uri_from_flower_toml(tmp_path, monkeypatch):
"""Build args specified in .flower.toml should overwrite the default values."""
toml_path = tmp_path / ".flower.toml"
toml_path.write_text(
'uri = "custom.json"\n'
'style = "quarto_resource_listing"\n'
'template_dir = "my-templates/"\n'
'output_dir = "my-docs/"\n'
"verbose = true\n"
)

monkeypatch.chdir(tmp_path)

_, bound, _ = app.parse_args(["build"])
assert bound.arguments["uri"] == "custom.json"
assert bound.arguments["style"] == BuildStyle.quarto_resource_listing
assert bound.arguments["template_dir"] == Path("my-templates/")
assert bound.arguments["output_dir"] == Path("my-docs/")
assert bound.arguments["verbose"] is True


# Help output ====

_HELP_PAGE = dedent(
"""\
Usage: seedcase-flower COMMAND

Flower generates human-readable documentation from Data Packages.

╭─ Commands ─────────────────────────────────────────────────────────────────────────────╮
│ build Build human-readable documentation from a datapackage.json file. │
│ --help (-h) Display this message and exit. │
│ --version Display application version. │
╰────────────────────────────────────────────────────────────────────────────────────────╯
""" # noqa
)
def test_build(
datapackage_path: str,
style: BuildStyle,
expected: None,
) -> None:
"""Test the build CLI function."""
result = build(datapackage_path, style=style)
assert result == expected

_BUILD_HELP_PAGE = dedent(
"""\
Usage: seedcase-flower build [ARGS]

Build human-readable documentation from a datapackage.json file.

╭─ Parameters ───────────────────────────────────────────────────────────────────────────╮
│ URI --uri The URI to a datapackage.json file. [default: │
│ datapackage.json] │
│ STYLE --style The style used to structure the output. If a template │
│ directory is given, this parameter will be ignored. │
│ [choices: quarto-one-page, quarto-resource-listing, │
│ quarto-resource-tables] [default: quarto-one-page] │
│ TEMPLATE-DIR --template-dir The directory that contains the Jinja template files and │
│ sections.toml. When set, it will override any built-in │
│ style given by the style parameter. │
│ OUTPUT-DIR --output-dir The directory to save the generated files in. [default: │
│ docs] │
│ VERBOSE --verbose If True, prints additional information to the console. │
│ [default: False] │
╰────────────────────────────────────────────────────────────────────────────────────────╯
""" # noqa
)

_CHANGED_MSG = (
"The `{cmd}` help output changed. Run `just generate-help-strings` "
"and paste the updated string into the relevant test."
)


@pytest.fixture
def console():
from rich.console import Console

return Console(
width=90,
force_terminal=True,
highlight=False,
color_system=None,
legacy_windows=False,
)


def test_help_page(capsys, console):
"""Top-level --help should match expected output."""
with pytest.raises(SystemExit):
app(["--help"], console=console)
assert capsys.readouterr().out == _HELP_PAGE, _CHANGED_MSG.format(cmd="general")


def test_build_help_page(capsys, console):
"""build --help should document all parameters with defaults and choices."""
with pytest.raises(SystemExit):
app(["build", "--help"], console=console)
assert capsys.readouterr().out == _BUILD_HELP_PAGE, _CHANGED_MSG.format(cmd="build")


# view (placeholder) ====


def test_view() -> None:
"""Test the view CLI function."""
result = view()
assert result == ""
"""view returns an empty string."""
assert view() == ""
74 changes: 74 additions & 0 deletions tools/generate-help-strings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Generate the expected help-output strings used in test_cli.py.

Run this script after changing a docstring or CLI parameter. Only snippets
whose output differs from the current constants in test_cli.py are printed.
"""

import sys
from io import StringIO
from operator import itemgetter

from rich.console import Console

from seedcase_flower.cli import app
from tests.test_cli import _BUILD_HELP_PAGE, _HELP_PAGE


def _capture_help(args: list[str]) -> str:
"""Return the help text produced by *args* as a plain string."""
console = Console(
width=90,
force_terminal=True,
highlight=False,
color_system=None,
legacy_windows=False,
)

old_stdout = sys.stdout
sys.stdout = StringIO()
try:
try:
app(args, console=console)
except SystemExit:
pass
return sys.stdout.getvalue()
finally:
sys.stdout = old_stdout


def _is_outdated(check: tuple[str, list[str], str]) -> bool:
"""Return True if the current help output differs from the stored constant."""
_, args, current = check
return _capture_help(args) != current


def _find_outdated_checks(
checks: list[tuple[str, list[str], str]],
) -> list[tuple[str, list[str]]]:
"""Return checks whose current help output differs from the stored constant."""
return list(map(itemgetter(0, 1), filter(_is_outdated, checks)))


def _as_constant_snippet(name: str, text: str) -> str:
"""Return a copy-pasteable constant assignment for *text*."""
lines = text.splitlines()
indented_body = "\n".join(f" {line}" if line else "" for line in lines)
return f'{name} = dedent(\n """\\\n{indented_body}\n """ # noqa\n)'


if __name__ == "__main__":
checks = [
("_HELP_PAGE", ["--help"], _HELP_PAGE),
("_BUILD_HELP_PAGE", ["build", "--help"], _BUILD_HELP_PAGE),
]
changed = _find_outdated_checks(checks)

if not changed:
print("No changes detected. All help-output constants are up to date.")
else:
print("\nReview that the output below looks as expected.")
print("Then, copy and paste it into tests/test_cli.py,")
print("replacing the variable(s) with the same name.")
for name, args in changed:
print("\n\n")
print(_as_constant_snippet(name, _capture_help(args)))
Loading