Skip to content
Draft
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
21 changes: 21 additions & 0 deletions .ci/check-tests-structure/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2024 ETH Zurich, Leonardo Schwarz

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
50 changes: 50 additions & 0 deletions .ci/check-tests-structure/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# check-tests-structure

Validates that a project's tests folder structure mirrors its sources folder
structure, so that every module has a test module next to the same relative
path. Configured entirely from the checked project's `pyproject.toml`.

```toml
[tool.check-tests-structure]
sources_path = "src/my_package" # required
tests_path = "tests/unit" # required
allow_missing_tests = true # do not fail on sources without a test
```

| Key | Default | Purpose |
|---|---|---|
| `sources_path` | required | Root of the sources tree, relative to the working directory |
| `tests_path` | required | Root of the tests tree, relative to the working directory |
| `inputs_glob` | `["*.py"]` | Source files to consider |
| `tests_glob` | `["test_*.py"]` | Test files to consider |
| `tests_pattern` | `test_(.*).py` | Maps a test filename back to its source name |
| `allow_missing_sources` | `false` | Tolerate tests without a matching source |
| `allow_missing_tests` | `false` | Tolerate sources without a matching test |
| `excluded_files` | `["__init__.py", "__main__.py"]` | Never reported |

Unmatched files are printed with fuzzy-matched suggestions, and the command
exits non-zero unless the corresponding `allow_missing_*` option is set.

Both paths are resolved against the **working directory**, not against the
directory holding the `pyproject.toml`. In a workspace, run the check from the
member's own directory.

See [the project documentation](../../docs/check-tests-structure.md) for how
other repositories consume this as a pre-commit hook.

## Development

This is a self-contained subproject: it is not part of the reference package,
not a uv workspace member, and it keeps its own `uv.lock`.

```bash
uv run --directory .ci/check-tests-structure --frozen pytest
```

## Provenance

Imported from <https://github.com/leoschwarz/check-tests-structure> at commit
`1228f93`, including the then-unreleased fixes to the `allow_missing_*` options
and the simplified entry point. Consolidated here so that FGCZ projects do not
depend on a personal account. Distributed under the MIT license (see `LICENSE`),
unlike the Apache-2.0 licensed reference project around it.
38 changes: 38 additions & 0 deletions .ci/check-tests-structure/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[project]
name = "fgcz-check-tests-structure"
version = "0.1.0"
description = "Checks if your test folder structure corresponds to the source folder structure"
authors = [{ name = "Leonardo Schwarz", email = "leonardo.schwarz@fgcz.ethz.ch" }]
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.11"

dependencies = [
"cyclopts>=2.9",
"pydantic>=2.9",
"rapidfuzz>=3.10",
]

[project.scripts]
check-tests-structure = "check_tests_structure.__main__:app"

[dependency-groups]
dev = [
"pyfakefs>=5.7",
"pytest>=8",
"pytest-mock>=3.14",
]

[build-system]
requires = ["setuptools >= 61.0"]
build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
where = ["src"]

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.check-tests-structure]
sources_path = "src/check_tests_structure"
tests_path = "tests"
Empty file.
42 changes: 42 additions & 0 deletions .ci/check-tests-structure/src/check_tests_structure/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import sys
from pathlib import Path

import cyclopts

from check_tests_structure.compare import Compare
from check_tests_structure.config import (
Config,
find_pyproject_toml,
parse_pyproject_toml,
)

app = cyclopts.App()


def run_check(config: Config) -> None:
compare = Compare(config=config)
differences = compare.get_differences()
compare.print_differences(differences)
if (differences["source"] and not config.allow_missing_tests) or (
differences["test"] and not config.allow_missing_sources
):
sys.exit(1)


@app.default
def run(path: Path | None = None) -> None:
# find the pyproject.toml
pyproject_toml = find_pyproject_toml(path or Path.cwd())
if not pyproject_toml:
print("No pyproject.toml found.")
sys.exit(1)

# parse the pyproject.toml
config = parse_pyproject_toml(pyproject_toml)

# run the check
run_check(config)


if __name__ == "__main__":
app()
85 changes: 85 additions & 0 deletions .ci/check-tests-structure/src/check_tests_structure/compare.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from __future__ import annotations

from functools import cached_property

from check_tests_structure.config import Config
from check_tests_structure.lookup import Lookup


class Compare:
def __init__(self, config: Config):
self._config = config

def get_differences(self) -> dict[str, list[dict[str, str]]]:
"""Returns the differences between the source and test files.
This will be a dictionary with two keys: 'source' and 'test', each containing a
list of entries of the files that are only present in that particular folder.
"""
differences = {"source": [], "test": []}
for source_file in self.source_files:
if not self.test_files.exists(source_file):
differences["source"].append(source_file)
for test_file in self.test_files:
if not self.source_files.exists(test_file):
differences["test"].append(test_file)
return differences

def print_differences(self, differences: dict[str, list[dict[str, str]]]) -> None:
"""Prints the differences between the source and test files."""
if not differences["source"] and not differences["test"]:
print("No differences found.")
return
if differences["source"]:
print("Source files not in test folder:")
for source in differences["source"]:
print(f" {source['dir']}/{source['original_name']}")
self.test_files.print_fuzzy_matches(
source, " - {dir}/{original_name} ({score:.1f}% match)"
)
if differences["test"]:
print("Test files not in source folder:")
for test in differences["test"]:
print(f" {test['dir']}/{test['original_name']}")
self.source_files.print_fuzzy_matches(
test, " - {dir}/{original_name} ({score:.1f}% match)"
)

@cached_property
def source_files(self) -> Lookup:
"""Lists all source files in the sources folder, relative to the sources folder."""
paths = {
path.relative_to(self._config.sources_path)
for glob_pattern in self._config.inputs_glob
for path in self._config.sources_path.rglob(glob_pattern)
if path.name not in self._config.excluded_files
}
return Lookup(
[
{"dir": str(path.parent), "original_name": path.name, "name": path.stem}
for path in sorted(paths)
]
)

@cached_property
def test_files(self) -> Lookup:
"""Lists all test files in the tests folder, relative to the tests folder."""
paths = {
path.relative_to(self._config.tests_path)
for glob_pattern in self._config.tests_glob
for path in self._config.tests_path.rglob(glob_pattern)
if path.name not in self._config.excluded_files
}
entries = [
{
"dir": str(path.parent),
"original_name": path.name,
"name": self._get_test_name(path.name),
}
for path in sorted(paths)
]
return Lookup([entry for entry in entries if entry["name"] is not None])

def _get_test_name(self, filename: str) -> str | None:
"""Extracts the test name from the test filename."""
matching = self._config.tests_pattern.match(filename)
return matching.group(1) if matching else None
39 changes: 39 additions & 0 deletions .ci/check-tests-structure/src/check_tests_structure/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from __future__ import annotations

import re
import tomllib
from pathlib import Path

from pydantic import BaseModel, ConfigDict


class Config(BaseModel):
model_config = ConfigDict(validate_default=True)

sources_path: Path
tests_path: Path

inputs_glob: list[str] = ["*.py"]
tests_glob: list[str] = ["test_*.py"]
tests_pattern: re.Pattern[str] = re.compile(r"test_(.*).py")

allow_missing_sources: bool = False
allow_missing_tests: bool = False

excluded_files: list[str] = ["__init__.py", "__main__.py"]


def find_pyproject_toml(path: Path) -> Path | None:
"""Searches for the pyproject.toml file in the given path or any of its parent directories."""
while not (path / "pyproject.toml").exists():
if path == path.parent:
break
path = path.parent
if (path / "pyproject.toml").exists():
return path / "pyproject.toml"
return None


def parse_pyproject_toml(path: Path) -> Config:
metadata = tomllib.loads(path.read_text())
return Config.model_validate(metadata.get("tool", {}).get("check-tests-structure", {}))
50 changes: 50 additions & 0 deletions .ci/check-tests-structure/src/check_tests_structure/lookup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from pathlib import Path

import rapidfuzz


class Lookup:
# TODO this is the naive implementation, as a proof of concept, of course we can
# make it faster later

def __init__(self, entries_list: list[dict[str, str]]):
self._entries_list = entries_list

@property
def entries_list(self) -> list[dict[str, str]]:
return self._entries_list

def exists(self, entry: dict[str, str]) -> bool:
for entry_ in self._entries_list:
if entry["dir"] == entry_["dir"] and entry["name"] == entry_["name"]:
return True
return False

def fuzzy_match(self, entry: dict[str, str], n_max: int) -> list[tuple[str, float, int]]:
# prepare the inputs
entry_path = str(Path(entry["dir"]) / entry["name"])
entries_list_paths = [
str(Path(entry_["dir"]) / entry_["name"]) for entry_ in self._entries_list
]
# find the best matches
return rapidfuzz.process.extract(
entry_path, entries_list_paths, scorer=rapidfuzz.fuzz.WRatio, limit=n_max
)

def print_fuzzy_matches(
self,
entry: dict[str, str],
template: str,
threshold: float = 90,
n_max: int = 5,
):
for _file, score, index in self.fuzzy_match(entry=entry, n_max=n_max):
if score >= threshold:
entry = self._entries_list[index]
print(template.format(**entry, score=score))

def __iter__(self):
return iter(self._entries_list)

def __len__(self):
return len(self._entries_list)
22 changes: 22 additions & 0 deletions .ci/check-tests-structure/tests/test_compare.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from pathlib import Path

import pytest
from check_tests_structure.compare import Compare
from check_tests_structure.config import Config


@pytest.fixture
def mock_config():
return Config(
sources_path=Path("/dev/null/sources"),
tests_path=Path("/dev/null/tests"),
)


@pytest.fixture
def mock_compare(mock_config):
return Compare(mock_config)


def test_get_name(mock_compare):
assert mock_compare._get_test_name("test_my_test.py") == "my_test"
43 changes: 43 additions & 0 deletions .ci/check-tests-structure/tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from pathlib import Path

from check_tests_structure.config import find_pyproject_toml, parse_pyproject_toml


def test_find_pyproject_toml_initial_when_project_root(fs):
fs.create_file("/my_project/pyproject.toml")
assert find_pyproject_toml(Path("/my_project")) == Path("/my_project/pyproject.toml")


def test_find_pyproject_toml_initial_when_passed_already(fs):
fs.create_file("/my_project/pyproject.toml")
assert find_pyproject_toml(Path("/my_project/pyproject.toml")) == Path(
"/my_project/pyproject.toml"
)


def test_find_pyproject_toml_when_sub_directory(fs):
fs.create_file("/my_project/pyproject.toml")
assert find_pyproject_toml(Path("/my_project/subdir")) == Path("/my_project/pyproject.toml")


def test_find_pyproject_toml_when_sub_sub_directory(fs):
fs.create_file("/my_project/pyproject.toml")
assert find_pyproject_toml(Path("/my_project/subdir/subsubdir")) == Path(
"/my_project/pyproject.toml"
)


def test_find_pyproject_toml_when_not_found(fs):
assert find_pyproject_toml(Path("/my_project")) is None


def test_parse_pyproject_toml_when_minimal(mocker):
mock_path = mocker.Mock()
mock_path.read_text.return_value = """
[tool.check-tests-structure]
sources_path = "src"
tests_path = "tests"
"""
config = parse_pyproject_toml(path=mock_path)
assert config.sources_path == Path("src")
assert config.tests_path == Path("tests")
Loading