diff --git a/.github/workflows/test-template.yml b/.github/workflows/test-template.yml new file mode 100644 index 0000000..e54135a --- /dev/null +++ b/.github/workflows/test-template.yml @@ -0,0 +1,44 @@ +name: Test template + +on: + push: + branches: [main] + pull_request: + +jobs: + structural: + name: Structural tests (py${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@v7.3.0 + with: + python-version: ${{ matrix.python-version }} + - run: uv sync --group test + - run: uv run pytest -m "not integration" -v + + integration: + name: Integration tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.10", "3.13"] + steps: + - uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@v7.3.0 + with: + python-version: ${{ matrix.python-version }} + - name: Install just + run: uv tool install rust-just + - run: uv sync --group test + - run: uv run pytest -m integration -v diff --git a/.gitignore b/.gitignore index c7fc6b9..8e005e9 100644 --- a/.gitignore +++ b/.gitignore @@ -128,6 +128,9 @@ dmypy.json # Pyre type checker .pyre/ +# uv lock file (not needed for the template repo itself) +uv.lock + # pycharm .idea # Local vscode editor config diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..497fdc1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,108 @@ +# Contributing to linkml-project-copier + +Thank you for considering a contribution! + +## Prerequisites + +- **Python >= 3.10** +- **uv** -- for dependency management and running tests +- **just** -- only needed if you want to run integration tests locally + (`uv tool install rust-just`) + +## Getting started + +```shell +git clone https://github.com/linkml/linkml-project-copier.git +cd linkml-project-copier +uv sync --group test +``` + +## Running the tests + +The test suite has two tiers: + +```shell +# Structural tests only (fast, ~25 seconds, no just/linkml needed) +uv run pytest -m "not integration" -v + +# Integration tests only (slow, minutes, needs just + network) +uv run pytest -m integration -v + +# Everything +uv run pytest -v +``` + +### Important: commit before testing + +The tests use copier's Python API with `vcs_ref="HEAD"`, which means copier +generates projects from the **last commit** on the current branch. If you +modify template files without committing, the tests will run against the +old commit and your changes won't be covered. + +## Test architecture + +### Two tiers + +**Structural tests** (`test_generation.py`, `test_options.py`, +`test_licenses.py`) generate projects via copier's Python API and inspect +the output -- file existence, content, template variable substitution. They +are fast (seconds) and need nothing beyond the test dependencies. These +tests must never modify the generated project. + +**Integration tests** (`test_integration.py`) generate a project, then run +`just install`, `just test`, `just lint`, and `just gen-doc` via subprocess. +They exercise the full toolchain (uv, linkml, just) and take minutes. + +### Fixture design + +Generating a project with copier takes 1-3 seconds. With 80+ structural +tests, per-test generation would be very slow. To avoid this: + +- **Session-scoped fixtures** (in `conftest.py`) generate each project + variant once and share it across all structural tests. The trade-off: + structural tests must treat the generated project as **read-only**. +- **Module-scoped fixture** for integration tests generates a fresh project + because `just` commands mutate the project directory (installing packages, + generating files). + +Available session fixtures: `default_project`, `no_example_project`, +`no_pypi_project`, `no_docs_preview_project`, and `license_project` +(parametrized across all six license types). + +### Shared helpers (`tests/helpers.py`) + +| Helper | Purpose | +|--------|---------| +| `generate_project(dest, data_overrides)` | Call copier's `run_copy()` with sensible defaults | +| `git_init(project_dir)` | Init git + initial commit (needed for dynamic versioning) | +| `run_just(project_dir, *args)` | Run a just command via subprocess with timeout | +| `DEFAULT_DATA` | Dict of template variable defaults used by all tests | +| `ALL_LICENSES` | List of all six supported license identifiers | + +### Adding a new structural test + +1. Pick the right fixture. If you need the default project, use + `default_project`. If you need a specific option combination that + doesn't exist yet, add a new session-scoped fixture in `conftest.py`. +2. Put the test in the appropriate module (`test_generation.py` for general + structure, `test_options.py` for boolean flags, `test_licenses.py` for + license variants). +3. Never modify files inside the generated project directory -- session + fixtures are shared. + +### Adding a new integration test + +Add the test to `test_integration.py`. It receives the `integration_project` +fixture which already has `just install` run in it, so dependencies are +available. Mark the test (or the whole module) with `@pytest.mark.integration`. + +## CI + +The GitHub Actions workflow `test-template.yml` runs two jobs: + +- **structural** -- fast, Python version matrix, Ubuntu only +- **integration** -- slow, OS matrix (Ubuntu + Windows), Python version matrix + +Structural tests run on every push and PR. Integration tests also run on +every push and PR but take longer, so they use a smaller matrix focused on +OS coverage. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2ea8bc5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[dependency-groups] +test = [ + "copier>=9.4.0", + "jinja2-time", + "pytest>=8.0", + "pyyaml", + "tomli>=2.0; python_version < '3.11'", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "integration: tests that run just commands in generated projects (slow)", +] diff --git a/template/src/{{project_slug}}/schema/{% if add_example %}{{ project_slug }}.yaml{% endif %}.jinja b/template/src/{{project_slug}}/schema/{% if add_example %}{{ project_slug }}.yaml{% endif %}.jinja index b8e185f..d02c179 100644 --- a/template/src/{{project_slug}}/schema/{% if add_example %}{{ project_slug }}.yaml{% endif %}.jinja +++ b/template/src/{{project_slug}}/schema/{% if add_example %}{{ project_slug }}.yaml{% endif %}.jinja @@ -11,10 +11,10 @@ see_also: prefixes: {{project_slug}}: https://w3id.org/{{github_org}}/{{project_name}}/ linkml: https://w3id.org/linkml/ - biolink: https://w3id.org/biolink/ + biolink: https://w3id.org/biolink/vocab/ schema: http://schema.org/ PATO: http://purl.obolibrary.org/obo/PATO_ - example: https://example.org/ + example: http://www.example.org/rdf# default_prefix: {{project_slug}} default_range: string @@ -83,6 +83,8 @@ slots: enums: PersonStatus: + description: + The vital status of a person permissible_values: ALIVE: description: the person is living diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..107b0f4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,49 @@ +"""Fixtures for linkml-project-copier template tests.""" + +from __future__ import annotations + +import pytest + +from tests.helpers import ALL_LICENSES, generate_project + + +# --------------------------------------------------------------------------- +# Session-scoped fixtures for structural tests (read-only, generated once) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def default_project(tmp_path_factory): + """Project generated with all defaults and add_example=True.""" + dest = tmp_path_factory.mktemp("default") + return generate_project(dest) + + +@pytest.fixture(scope="session") +def no_example_project(tmp_path_factory): + """Project generated with add_example=False.""" + dest = tmp_path_factory.mktemp("no_example") + return generate_project(dest, {"add_example": False}) + + +@pytest.fixture(scope="session") +def no_pypi_project(tmp_path_factory): + """Project generated with gh_action_pypi=False.""" + dest = tmp_path_factory.mktemp("no_pypi") + return generate_project(dest, {"gh_action_pypi": False}) + + +@pytest.fixture(scope="session") +def no_docs_preview_project(tmp_path_factory): + """Project generated with gh_action_docs_preview=False.""" + dest = tmp_path_factory.mktemp("no_docs_preview") + return generate_project(dest, {"gh_action_docs_preview": False}) + + +@pytest.fixture(scope="session", params=ALL_LICENSES) +def license_project(request, tmp_path_factory): + """Project generated for each license type. Returns (license_name, project_path).""" + license_name = request.param + dest = tmp_path_factory.mktemp(f"license_{license_name}") + project_path = generate_project(dest, {"license": license_name}) + return license_name, project_path diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..da3baa5 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,90 @@ +"""Shared helpers for template tests.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +from copier import run_copy + +TEMPLATE_ROOT = Path(__file__).resolve().parent.parent + +ALL_LICENSES = ["MIT", "BSD-3-Clause", "Apache-2.0", "MPL-2.0", "LGPL-3.0-only", "GPL-3.0-only"] + +DEFAULT_DATA = { + "project_name": "test-schema", + "project_slug": "test_schema", + "email": "test@example.org", + "full_name": "Test User", + "github_org": "test-org", + "project_description": "A test project.", + "license": "MIT", + "copyright_year": "2025", + "add_example": True, + "gh_action_pypi": True, + "gh_action_docs_preview": True, +} + + +def generate_project( + dest: Path, + data_overrides: dict | None = None, +) -> Path: + """Generate a project from the copier template. + + Args: + dest: Directory where the project will be generated. + data_overrides: Values to override in DEFAULT_DATA. + + Returns: + Path to the generated project directory. + """ + data = {**DEFAULT_DATA, **(data_overrides or {})} + run_copy( + str(TEMPLATE_ROOT), + dest, + data=data, + defaults=True, + unsafe=True, + vcs_ref="HEAD", + ) + return dest + + +def git_init(project_dir: Path) -> None: + """Initialize a git repo with an initial commit (needed for dynamic versioning).""" + subprocess.run(["git", "init"], cwd=project_dir, check=True, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=project_dir, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=project_dir, + check=True, + capture_output=True, + ) + subprocess.run(["git", "add", "."], cwd=project_dir, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "init"], + cwd=project_dir, + check=True, + capture_output=True, + ) + + +def run_just(project_dir: Path, *args: str, timeout: int = 600) -> subprocess.CompletedProcess: + """Run a just command in the given project directory.""" + # Remove VIRTUAL_ENV so the generated project's uv uses its own .venv + env = {k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"} + return subprocess.run( + ["just", *args], + cwd=project_dir, + capture_output=True, + text=True, + timeout=timeout, + env=env, + ) diff --git a/tests/test_generation.py b/tests/test_generation.py new file mode 100644 index 0000000..3019678 --- /dev/null +++ b/tests/test_generation.py @@ -0,0 +1,202 @@ +"""Structural tests for default project generation.""" + +from __future__ import annotations + +import sys + +import yaml +import pytest + +if sys.version_info >= (3, 11): + import tomllib +else: + try: + import tomllib + except ModuleNotFoundError: + import tomli as tomllib + + +# Files expected in a default project (add_example=True, all actions enabled) +EXPECTED_FILES = [ + # Root config files + ".editorconfig", + ".gitignore", + ".pre-commit-config.yaml", + ".yamllint.yaml", + "config.public.mk", + "config.yaml", + "justfile", + "project.justfile", + "pyproject.toml", + "LICENSE", + "README.md", + "CODE_OF_CONDUCT.md", + "CONTRIBUTING.md", + "mkdocs.yml", + ".copier-answers.yml", + # GitHub + ".github/dependabot.yml", + ".github/workflows/deploy-docs.yaml", + ".github/workflows/main.yaml", + ".github/workflows/pypi-publish.yaml", + ".github/workflows/test_pages_build.yaml", + # Docs + "docs/about.md", + "docs/index.md", + "docs/elements/.gitkeep", + "docs/js/extra-loader.js", + "docs/templates-linkml/README.md", + # Source + "src/test_schema/__init__.py", + "src/test_schema/_version.py", + "src/test_schema/datamodel/__init__.py", + "src/test_schema/schema/README.md", + "src/test_schema/schema/test_schema.yaml", + # Tests (in generated project) + "tests/__init__.py", + "tests/test_data.py", + "tests/data/README.md", + "tests/data/valid/.gitkeep", + "tests/data/valid/Person-001.yaml", + "tests/data/valid/PersonCollection-001.yaml", + "tests/data/invalid/.gitkeep", + "tests/data/invalid/Person-002.yaml", + "tests/data/problem/valid/.gitkeep", + "tests/data/problem/invalid/.gitkeep", + # Examples + "examples/README.md", +] + + +class TestDefaultProjectStructure: + """Verify that expected files exist in the default generated project.""" + + @pytest.mark.parametrize("relpath", EXPECTED_FILES) + def test_file_exists(self, default_project, relpath): + assert (default_project / relpath).exists(), f"Missing: {relpath}" + + +class TestNoJinjaArtifacts: + """Verify that no Jinja artifacts remain in the generated project.""" + + def test_no_jinja_extension_files(self, default_project): + jinja_files = list(default_project.rglob("*.jinja")) + assert jinja_files == [], f"Jinja files remain: {jinja_files}" + + def test_no_unexpanded_markers(self, default_project): + """No {{ }} or {% %} markers should remain in generated text files. + + GitHub Actions workflow files are excluded because they use ${{ }} + syntax which is GitHub Actions expression syntax, not Jinja. + """ + text_extensions = {".py", ".toml", ".yaml", ".yml", ".md", ".mk", ".cfg", ".txt"} + # GitHub Actions workflows legitimately use ${{ }} syntax + excluded_dirs = {".github"} + failures = [] + for path in default_project.rglob("*"): + if not path.is_file(): + continue + if path.suffix not in text_extensions: + continue + rel = path.relative_to(default_project) + if rel.parts[0] in excluded_dirs: + continue + try: + content = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + for marker in ("{{", "}}", "{%", "%}"): + if marker in content: + failures.append(f"{rel} contains '{marker}'") + break + assert failures == [], "Unexpanded Jinja markers found:\n" + "\n".join(failures) + + +class TestPyprojectToml: + """Validate generated pyproject.toml content.""" + + @pytest.fixture(scope="class") + def pyproject(self, default_project): + return tomllib.loads((default_project / "pyproject.toml").read_text(encoding="utf-8")) + + def test_project_name(self, pyproject): + assert pyproject["project"]["name"] == "test_schema" + + def test_description(self, pyproject): + assert pyproject["project"]["description"] == "A test project." + + def test_license(self, pyproject): + assert pyproject["project"]["license"] == "MIT" + + def test_authors(self, pyproject): + authors = pyproject["project"]["authors"] + assert len(authors) == 1 + assert authors[0]["name"] == "Test User" + assert authors[0]["email"] == "test@example.org" + + def test_linkml_runtime_dependency(self, pyproject): + deps = pyproject["project"]["dependencies"] + assert any("linkml-runtime" in d for d in deps) + + def test_dynamic_version(self, pyproject): + assert "version" in pyproject["project"]["dynamic"] + + +class TestSchemaYaml: + """Validate the generated example schema.""" + + @pytest.fixture(scope="class") + def schema(self, default_project): + return yaml.safe_load( + (default_project / "src/test_schema/schema/test_schema.yaml").read_text( + encoding="utf-8" + ) + ) + + def test_schema_name(self, schema): + assert schema["name"] == "test-schema" + + def test_schema_license(self, schema): + assert schema["license"] == "MIT" + + def test_classes_present(self, schema): + classes = schema["classes"] + assert "Person" in classes + assert "NamedThing" in classes + assert "PersonCollection" in classes + + +class TestConfigPublicMk: + """Validate config.public.mk values.""" + + @pytest.fixture(scope="class") + def config_lines(self, default_project): + return (default_project / "config.public.mk").read_text(encoding="utf-8") + + def test_schema_name(self, config_lines): + assert 'LINKML_SCHEMA_NAME="test_schema"' in config_lines + + def test_schema_source_dir(self, config_lines): + assert 'LINKML_SCHEMA_SOURCE_DIR="src/test_schema/schema"' in config_lines + + +class TestCopierAnswers: + """Validate .copier-answers.yml content.""" + + @pytest.fixture(scope="class") + def answers(self, default_project): + return yaml.safe_load( + (default_project / ".copier-answers.yml").read_text(encoding="utf-8") + ) + + def test_project_slug(self, answers): + assert answers["project_slug"] == "test_schema" + + def test_project_name(self, answers): + assert answers["project_name"] == "test-schema" + + def test_add_example(self, answers): + assert answers["add_example"] is True + + def test_github_org(self, answers): + assert answers["github_org"] == "test-org" diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..ab55aab --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,82 @@ +"""Integration tests that run just commands in generated projects.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys + +import pytest + +from tests.helpers import generate_project, git_init, run_just + +pytestmark = pytest.mark.integration + + +@pytest.fixture(scope="module") +def integration_project(tmp_path_factory): + """Generate a project with git init and install deps for integration testing.""" + dest = tmp_path_factory.mktemp("integration") + project = generate_project(dest) + git_init(project) + # Install dependencies upfront so individual tests don't depend on order + result = run_just(project, "install") + if result.returncode != 0: + pytest.fail( + f"just install failed during fixture setup:\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + return project + + +def test_just_install(integration_project): + """Verify that just install succeeds (already run in fixture, re-run is idempotent).""" + result = run_just(integration_project, "install") + assert result.returncode == 0, ( + f"just install failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + + +@pytest.mark.skipif( + sys.version_info >= (3, 13), + reason="linkml's ShEx generator crashes on Python 3.13 (pyjsg incompatibility)", +) +def test_just_test(integration_project): + result = run_just(integration_project, "test") + assert result.returncode == 0, ( + f"just test failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + + +def test_just_lint(integration_project): + # just lint exits 1 on warnings, 2 on errors — only errors are failures + result = run_just(integration_project, "lint") + assert result.returncode < 2, ( + f"just lint found errors:\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + + # Verify exact warning/error counts via JSON output + env = {k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"} + lint_json = subprocess.run( + ["uv", "run", "linkml-lint", "--format", "json", "src/test_schema/schema"], + cwd=integration_project, + capture_output=True, + text=True, + timeout=120, + env=env, + ) + problems = json.loads(lint_json.stdout) + errors = [p for p in problems if p["level"] == "error"] + warnings = [p for p in problems if p["level"] == "warning"] + assert len(errors) == 0, f"Expected 0 lint errors, got {len(errors)}: {errors}" + assert len(warnings) == 4, ( + f"Expected 4 lint warnings, got {len(warnings)}: {warnings}" + ) + + +def test_just_gen_doc(integration_project): + result = run_just(integration_project, "gen-doc") + assert result.returncode == 0, ( + f"just gen-doc failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) diff --git a/tests/test_licenses.py b/tests/test_licenses.py new file mode 100644 index 0000000..e7c6e5c --- /dev/null +++ b/tests/test_licenses.py @@ -0,0 +1,43 @@ +"""Tests for license template options.""" + +from __future__ import annotations + +import sys + +if sys.version_info >= (3, 11): + import tomllib +else: + try: + import tomllib + except ModuleNotFoundError: + import tomli as tomllib + + +# Marker text expected in each LICENSE file +LICENSE_MARKERS = { + "MIT": "The MIT License (MIT)", + "BSD-3-Clause": "Redistribution and use in source and binary forms", + "Apache-2.0": "Apache License", + "MPL-2.0": "Mozilla Public License Version 2.0", + "LGPL-3.0-only": "GNU LESSER GENERAL PUBLIC LICENSE", + "GPL-3.0-only": "GNU GENERAL PUBLIC LICENSE", +} + + +class TestLicenseFile: + """Validate LICENSE file content for each license type.""" + + def test_license_marker_present(self, license_project): + license_name, project_path = license_project + content = (project_path / "LICENSE").read_text(encoding="utf-8") + marker = LICENSE_MARKERS[license_name] + assert marker in content, ( + f"LICENSE for {license_name} missing expected marker: {marker!r}" + ) + + def test_pyproject_license_field(self, license_project): + license_name, project_path = license_project + pyproject = tomllib.loads( + (project_path / "pyproject.toml").read_text(encoding="utf-8") + ) + assert pyproject["project"]["license"] == license_name diff --git a/tests/test_options.py b/tests/test_options.py new file mode 100644 index 0000000..65cb77b --- /dev/null +++ b/tests/test_options.py @@ -0,0 +1,57 @@ +"""Tests for boolean template options.""" + +from __future__ import annotations + +import pytest + + +class TestWithoutExample: + """With add_example=False, example-specific files should be absent.""" + + ABSENT_FILES = [ + "src/test_schema/schema/test_schema.yaml", + "tests/test_data.py", + "tests/data/valid/Person-001.yaml", + "tests/data/valid/PersonCollection-001.yaml", + "tests/data/invalid/Person-002.yaml", + ] + + PRESENT_FILES = [ + "pyproject.toml", + "justfile", + "config.public.mk", + "src/test_schema/__init__.py", + "src/test_schema/schema/README.md", + "tests/__init__.py", + "tests/data/README.md", + ] + + @pytest.mark.parametrize("relpath", ABSENT_FILES) + def test_file_absent(self, no_example_project, relpath): + assert not (no_example_project / relpath).exists(), f"Should be absent: {relpath}" + + @pytest.mark.parametrize("relpath", PRESENT_FILES) + def test_file_present(self, no_example_project, relpath): + assert (no_example_project / relpath).exists(), f"Missing: {relpath}" + + +class TestWithoutPypiAction: + """With gh_action_pypi=False, pypi-publish.yaml should be absent.""" + + def test_pypi_publish_absent(self, no_pypi_project): + assert not (no_pypi_project / ".github/workflows/pypi-publish.yaml").exists() + + def test_main_workflow_present(self, no_pypi_project): + assert (no_pypi_project / ".github/workflows/main.yaml").exists() + + +class TestWithoutDocsPreview: + """With gh_action_docs_preview=False, test_pages_build.yaml should be absent.""" + + def test_pages_build_absent(self, no_docs_preview_project): + assert not ( + no_docs_preview_project / ".github/workflows/test_pages_build.yaml" + ).exists() + + def test_deploy_docs_present(self, no_docs_preview_project): + assert (no_docs_preview_project / ".github/workflows/deploy-docs.yaml").exists()