From b3e3d738f362de5f56738f8f47a7d1f9b5a17e06 Mon Sep 17 00:00:00 2001 From: David Linke Date: Sun, 15 Feb 2026 23:32:22 +0100 Subject: [PATCH 1/9] Add pytest test suite for copier template Structural tests validate generated project files, content, and template variable substitution. Integration tests run just commands in generated projects. CI workflow runs both. --- .github/workflows/test-template.yml | 43 +++++++ .gitignore | 3 + pyproject.toml | 18 +++ tests/__init__.py | 0 tests/conftest.py | 129 +++++++++++++++++++ tests/test_generation.py | 188 ++++++++++++++++++++++++++++ tests/test_integration.py | 46 +++++++ tests/test_licenses.py | 35 ++++++ tests/test_options.py | 57 +++++++++ 9 files changed, 519 insertions(+) create mode 100644 .github/workflows/test-template.yml create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_generation.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_licenses.py create mode 100644 tests/test_options.py diff --git a/.github/workflows/test-template.yml b/.github/workflows/test-template.yml new file mode 100644 index 0000000..04f9abe --- /dev/null +++ b/.github/workflows/test-template.yml @@ -0,0 +1,43 @@ +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.13"] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@v6 + 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] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@v6 + with: + python-version: "3.12" + - 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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dbac042 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "linkml-project-copier" +version = "0.0.0" +requires-python = ">=3.10" + +[dependency-groups] +test = [ + "copier>=9.4.0", + "jinja2-time", + "pytest>=8.0", + "pyyaml", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "integration: tests that run just commands in generated projects (slow)", +] 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..dc9d762 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,129 @@ +"""Fixtures and helpers for linkml-project-copier template tests.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest +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.""" + return subprocess.run( + ["just", *args], + cwd=project_dir, + capture_output=True, + text=True, + timeout=timeout, + ) + + +# --------------------------------------------------------------------------- +# 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/test_generation.py b/tests/test_generation.py new file mode 100644 index 0000000..b635522 --- /dev/null +++ b/tests/test_generation.py @@ -0,0 +1,188 @@ +"""Structural tests for default project generation.""" + +from __future__ import annotations + +import yaml +import pytest + + +# 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", + # Project output + "project/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.""" + text_extensions = {".py", ".toml", ".yaml", ".yml", ".md", ".mk", ".cfg", ".txt"} + failures = [] + for path in default_project.rglob("*"): + if not path.is_file(): + continue + if path.suffix not in text_extensions: + continue + try: + content = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + for marker in ("{{", "}}", "{%", "%}"): + if marker in content: + rel = path.relative_to(default_project) + 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): + import tomllib + + 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..726189a --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,46 @@ +"""Integration tests that run just commands in generated projects.""" + +from __future__ import annotations + +import pytest + +from conftest 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 for integration testing (mutable).""" + dest = tmp_path_factory.mktemp("integration") + project = generate_project(dest) + git_init(project) + return project + + +def test_just_install(integration_project): + result = run_just(integration_project, "install") + assert result.returncode == 0, ( + f"just install failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + + +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): + result = run_just(integration_project, "lint") + assert result.returncode == 0, ( + f"just lint failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + + +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..f6e6875 --- /dev/null +++ b/tests/test_licenses.py @@ -0,0 +1,35 @@ +"""Tests for license template options.""" + +from __future__ import annotations + +import 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() From 30da0c98c276c1c45c5ca1604aa24dd953bdf6c7 Mon Sep 17 00:00:00 2001 From: David Linke Date: Sun, 15 Feb 2026 23:33:40 +0100 Subject: [PATCH 2/9] Extract helpers module to fix import in test_integration --- tests/conftest.py | 84 +------------------------------------- tests/helpers.py | 86 +++++++++++++++++++++++++++++++++++++++ tests/test_integration.py | 2 +- 3 files changed, 89 insertions(+), 83 deletions(-) create mode 100644 tests/helpers.py diff --git a/tests/conftest.py b/tests/conftest.py index dc9d762..107b0f4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,90 +1,10 @@ -"""Fixtures and helpers for linkml-project-copier template tests.""" +"""Fixtures for linkml-project-copier template tests.""" from __future__ import annotations -import subprocess -from pathlib import Path - import pytest -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.""" - return subprocess.run( - ["just", *args], - cwd=project_dir, - capture_output=True, - text=True, - timeout=timeout, - ) +from tests.helpers import ALL_LICENSES, generate_project # --------------------------------------------------------------------------- diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..118c9b3 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,86 @@ +"""Shared helpers for template tests.""" + +from __future__ import annotations + +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.""" + return subprocess.run( + ["just", *args], + cwd=project_dir, + capture_output=True, + text=True, + timeout=timeout, + ) diff --git a/tests/test_integration.py b/tests/test_integration.py index 726189a..1a72fec 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -4,7 +4,7 @@ import pytest -from conftest import generate_project, git_init, run_just +from tests.helpers import generate_project, git_init, run_just pytestmark = pytest.mark.integration From 705bc4d1198b71f5b5d6fae4990e74ce80548f6d Mon Sep 17 00:00:00 2001 From: David Linke Date: Sun, 15 Feb 2026 23:36:31 +0100 Subject: [PATCH 3/9] Fix two test failures: remove project/README.md, exclude .github from marker check --- tests/test_generation.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/test_generation.py b/tests/test_generation.py index b635522..3025f7d 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -55,8 +55,6 @@ "tests/data/problem/invalid/.gitkeep", # Examples "examples/README.md", - # Project output - "project/README.md", ] @@ -76,21 +74,29 @@ def test_no_jinja_extension_files(self, default_project): 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.""" + """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: - rel = path.relative_to(default_project) failures.append(f"{rel} contains '{marker}'") break assert failures == [], "Unexpanded Jinja markers found:\n" + "\n".join(failures) From 67ebda4aa6bd4c5c848f049cda0ce4ea3d622cbd Mon Sep 17 00:00:00 2001 From: David Linke Date: Sun, 15 Feb 2026 23:41:30 +0100 Subject: [PATCH 4/9] Fix tomllib compat for Python 3.10, install deps in integration fixture --- pyproject.toml | 1 + tests/test_generation.py | 12 ++++++++++-- tests/test_integration.py | 10 +++++++++- tests/test_licenses.py | 10 +++++++++- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dbac042..08d71a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ test = [ "jinja2-time", "pytest>=8.0", "pyyaml", + "tomli>=2.0; python_version < '3.11'", ] [tool.pytest.ini_options] diff --git a/tests/test_generation.py b/tests/test_generation.py index 3025f7d..3019678 100644 --- a/tests/test_generation.py +++ b/tests/test_generation.py @@ -2,9 +2,19 @@ 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 = [ @@ -107,8 +117,6 @@ class TestPyprojectToml: @pytest.fixture(scope="class") def pyproject(self, default_project): - import tomllib - return tomllib.loads((default_project / "pyproject.toml").read_text(encoding="utf-8")) def test_project_name(self, pyproject): diff --git a/tests/test_integration.py b/tests/test_integration.py index 1a72fec..5de555e 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -11,14 +11,22 @@ @pytest.fixture(scope="module") def integration_project(tmp_path_factory): - """Generate a project with git init for integration testing (mutable).""" + """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}" diff --git a/tests/test_licenses.py b/tests/test_licenses.py index f6e6875..e7c6e5c 100644 --- a/tests/test_licenses.py +++ b/tests/test_licenses.py @@ -2,7 +2,15 @@ from __future__ import annotations -import tomllib +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 From 5b3303de5803711810ad5f429ba0f202ab153aa2 Mon Sep 17 00:00:00 2001 From: David Linke Date: Mon, 16 Feb 2026 00:19:29 +0100 Subject: [PATCH 5/9] Test more Python versions in CI; simplify pyproject.toml --- .github/workflows/test-template.yml | 13 +++++++------ pyproject.toml | 5 ----- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/.github/workflows/test-template.yml b/.github/workflows/test-template.yml index 04f9abe..e54135a 100644 --- a/.github/workflows/test-template.yml +++ b/.github/workflows/test-template.yml @@ -12,12 +12,12 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 with: fetch-depth: 0 - - uses: astral-sh/setup-uv@v6 + - uses: astral-sh/setup-uv@v7.3.0 with: python-version: ${{ matrix.python-version }} - run: uv sync --group test @@ -30,13 +30,14 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] + python-version: ["3.10", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6.0.2 with: fetch-depth: 0 - - uses: astral-sh/setup-uv@v6 + - uses: astral-sh/setup-uv@v7.3.0 with: - python-version: "3.12" + python-version: ${{ matrix.python-version }} - name: Install just run: uv tool install rust-just - run: uv sync --group test diff --git a/pyproject.toml b/pyproject.toml index 08d71a5..2ea8bc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,3 @@ -[project] -name = "linkml-project-copier" -version = "0.0.0" -requires-python = ">=3.10" - [dependency-groups] test = [ "copier>=9.4.0", From cfd671ac6b02749bd578c473cb781d08f9a96192 Mon Sep 17 00:00:00 2001 From: David Linke Date: Mon, 16 Feb 2026 00:20:31 +0100 Subject: [PATCH 6/9] Add CONTRIBUTING.md for template --- CONTRIBUTING.md | 108 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 CONTRIBUTING.md 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. From 9e227864252e21690fba9cddc1bb722f8e4dcd6a Mon Sep 17 00:00:00 2001 From: David Linke Date: Mon, 16 Feb 2026 01:16:03 +0100 Subject: [PATCH 7/9] Fix integration test environment and skip ShEx test on 3.13 Strip VIRTUAL_ENV from subprocess env in run_just to prevent uv venv mismatch warnings. Skip test_just_test on Python >= 3.13 due to upstream pyjsg incompatibility in linkml's ShEx generator. --- tests/helpers.py | 4 ++++ tests/test_integration.py | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/tests/helpers.py b/tests/helpers.py index 118c9b3..da3baa5 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import subprocess from pathlib import Path @@ -77,10 +78,13 @@ def git_init(project_dir: Path) -> None: 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_integration.py b/tests/test_integration.py index 5de555e..b5dd4d9 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -2,6 +2,8 @@ from __future__ import annotations +import sys + import pytest from tests.helpers import generate_project, git_init, run_just @@ -33,6 +35,10 @@ def test_just_install(integration_project): ) +@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, ( From e0aaececd1419f86f2fdf2a8cc15d28f12b90e04 Mon Sep 17 00:00:00 2001 From: David Linke Date: Wed, 18 Feb 2026 19:24:02 +0100 Subject: [PATCH 8/9] Reduce warnings in example to 4 --- ...f add_example %}{{ project_slug }}.yaml{% endif %}.jinja | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 From 5915bc6b72cb41f9e752c93c55214ef07d2cb865 Mon Sep 17 00:00:00 2001 From: David Linke Date: Wed, 25 Feb 2026 21:32:30 +0100 Subject: [PATCH 9/9] Fix lint test to tolerate warnings Allow exit code 1 (warnings-only) from `just lint` and verify 0 errors / 4 warnings via linkml-lint JSON output. --- tests/test_integration.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index b5dd4d9..ab55aab 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -2,6 +2,9 @@ from __future__ import annotations +import json +import os +import subprocess import sys import pytest @@ -47,9 +50,28 @@ def test_just_test(integration_project): 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 == 0, ( - f"just lint failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" + 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}" )