diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..40f5f57 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,78 @@ +name: Check Changelog +on: + pull_request: + branches: + - main + - dev + workflow_dispatch: + +jobs: + check-changelog: + runs-on: ubuntu-latest + if: github.ref_name != 'main' + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 # Fetch all history for accurate comparison + + - name: Check PR labels for changelog skip + id: check-skip + run: | + # Check if PR has the skip-changelog label + labels="${{ join(github.event.pull_request.labels.*.name, ' ') }}" + echo "PR labels: $labels" + + if [[ "$labels" == *"skip-changelog"* ]]; then + echo "Found 'skip-changelog' label - skipping changelog check" + echo "skip=true" >> $GITHUB_OUTPUT + else + echo "No 'skip-changelog' label found - proceeding with changelog check" + echo "skip=false" >> $GITHUB_OUTPUT + fi + + - name: Fetch target branch + if: steps.check-skip.outputs.skip != 'true' + run: | + git fetch origin ${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }} + + - name: Extract content above insertion marker from target branch + if: steps.check-skip.outputs.skip != 'true' + id: extract-target + run: | + marker="" + # Extract content above the marker in the target branch + git show origin/${{ github.event.pull_request.base.ref }}:CHANGELOG.md | awk -v marker="$marker" ' + BEGIN { found=0 } + $0 ~ marker { found=1; exit } + { if (!found) print } + ' | sed '/^\s*$/d' > target_above_marker.txt + + - name: Extract content above insertion marker from PR branch + if: steps.check-skip.outputs.skip != 'true' + id: extract-pr + run: | + marker="" + # Extract content above the marker in the PR branch + cat CHANGELOG.md | awk -v marker="$marker" ' + BEGIN { found=0 } + $0 ~ marker { found=1; exit } + { if (!found) print } + ' | sed '/^\s*$/d' > pr_above_marker.txt + + - name: Compare content above insertion marker + if: steps.check-skip.outputs.skip != 'true' + id: compare + run: | + if ! diff -q target_above_marker.txt pr_above_marker.txt > /dev/null; then + echo "Differences detected above the insertion marker in CHANGELOG.md." + exit 0 + else + echo "No differences detected above the insertion marker." + exit 1 + fi + + - name: Skip changelog check + if: steps.check-skip.outputs.skip == 'true' + run: | + echo "Changelog check skipped due to PR label" \ No newline at end of file diff --git a/.github/workflows/check-pr-for-release-version.yml b/.github/workflows/check-pr-for-release-version.yml new file mode 100644 index 0000000..2030833 --- /dev/null +++ b/.github/workflows/check-pr-for-release-version.yml @@ -0,0 +1,41 @@ +name: Release Pattern Check + +on: + pull_request: + branches: + - main + types: [opened, edited, reopened, synchronize] + +jobs: + check-release-pattern: + runs-on: ubuntu-latest + + steps: + - name: Check PR Title, Description, or Hotfix Branch + run: | + title="${{ github.event.pull_request.title }}" + body="${{ github.event.pull_request.body }}" + head_ref="${{ github.head_ref }}" + + echo "PR title: $title" + echo "PR body: $body" + echo "PR source branch: $head_ref" + + # Allow hotfix branches unconditionally + if [[ "$head_ref" == hotfix/* ]]; then + echo "✅ Hotfix branch detected ($head_ref). Skipping release pattern check." + exit 0 + fi + + # Regex: Release-x.y.z (case-insensitive) + regex='[Rr][Ee][Ll][Ee][Aa][Ss][Ee]-[0-9]+\.[0-9]+\.[0-9]+' + + if [[ "$title" =~ $regex ]] || [[ "$body" =~ $regex ]]; then + echo "✅ Found valid Release pattern in title or description." + exit 0 + else + echo "❌ Invalid PR." + echo " Title or description must contain: Release-x.y.z (e.g., Release-1.2.3)" + echo " OR the branch must start with: hotfix/" + exit 1 + fi diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml new file mode 100644 index 0000000..6b4948a --- /dev/null +++ b/.github/workflows/create-tag.yml @@ -0,0 +1,128 @@ +name: Create Release Tag + +on: + pull_request: + types: [closed] + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + create-tag: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.13 + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + + - name: Extract release version (Release-x.y.z OR hotfix bump) + id: extract_version + shell: bash + run: | + set -euo pipefail + + title="${{ github.event.pull_request.title }}" + body="${{ github.event.pull_request.body }}" + head_ref="${{ github.head_ref }}" # PR source branch name, e.g. hotfix/... + + echo "PR source branch: $head_ref" + echo "PR title: $title" + + # Hotfix: version = latest tag + 1 in patch + if [[ "$head_ref" == hotfix/* ]]; then + echo "✅ Hotfix PR detected. Deducing version from latest tag + patch bump..." + + git fetch --tags --force + + version="$(increase_latest_tag --patch)" + + echo "✅ Hotfix release version: $version" + echo "version=$version" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Non-hotfix: expect Release-x.y.z in title/body + regex='[Rr][Ee][Ll][Ee][Aa][Ss][Ee]-([0-9]+\.[0-9]+\.[0-9]+)' + + if [[ "$title" =~ $regex ]]; then + version="${BASH_REMATCH[1]}" + elif [[ "$body" =~ $regex ]]; then + version="${BASH_REMATCH[1]}" + else + echo "❌ No valid release version found in PR title or description." + echo " Expected: Release-x.y.z (e.g., Release-1.2.3)" + echo " Or use a hotfix/* branch to auto-bump patch." + exit 1 + fi + + echo "✅ Found release version: $version" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Update CHANGELOG.md + run: | + version="${{ steps.extract_version.outputs.version }}" + python3 update_changelog "$version" + + - name: Commit and push changelog update + run: | + version="${{ steps.extract_version.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git add CHANGELOG.md + git commit -m "docs: update changelog for $version" || echo "No changes to commit" + git push origin HEAD:main + + - name: Create Git tag + run: | + version="${{ steps.extract_version.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Safety: don't fail if tag already exists (e.g., re-runs) + if git rev-parse -q --verify "refs/tags/$version" >/dev/null; then + echo "Tag $version already exists. Skipping tag creation." + exit 0 + fi + + git tag "$version" + git push origin "$version" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.extract_version.outputs.version }} + name: Release ${{ steps.extract_version.outputs.version }} + body: | + 🚀 **Automatic release:** `${{ steps.extract_version.outputs.version }}` + - Created from PR: #${{ github.event.pull_request.number }} + - Commit: `${{ github.sha }}` + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Merge main back into dev + run: | + git fetch origin dev + git checkout dev + git merge origin/main --no-edit + git push origin dev diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 0000000..28984d2 --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,45 @@ +name: pytest + +on: + pull_request: + branches: ['*'] + schedule: + - cron: '0 0 * * *' # Daily at midnight + +jobs: + test: + runs-on: ubuntu-latest + permissions: + contents: read + + strategy: + fail-fast: false + matrix: + python-version: [3.12, 3.13] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[test] + + - name: Run pytest + run: | + python -m pytest tests/ -v + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: 97gamjak/devops + fail_ci_if_error: true + flags: unittests + verbose: true diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml new file mode 100644 index 0000000..cc8c15d --- /dev/null +++ b/.github/workflows/ruff.yml @@ -0,0 +1,32 @@ +name: Ruff + +on: + pull_request: + branches: ['*'] + schedule: + - cron: '0 0 * * *' # Daily at midnight + +jobs: + ruff: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ruff + + - name: Run ruff check + run: ruff check . + + - name: Run ruff format check + run: ruff format --check . diff --git a/.gitignore b/.gitignore index 7f92207..86e4df5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ __pycache__ .venv/ dist/ build/ +**.egg-info/ +tests/test.ipynb +**_.*.py +.coverage +**/__version__.py diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..39e80d7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "cSpell.words": [ + "Codecov", + "levelname", + "MSTD", + "unittests" + ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..70eff87 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,49 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## Next Release + +### API + +- Add cli command `get_latest_tag` +- Add cli command `increase_latest_tag` +- Add new license checking rule to `cpp_checks` +- Add cli command `generate_toml_template` to get a template default toml file +- Add cli commands `add_license_header` and `add_license_headers` +- Add cli command `filter_buggy_cpp_files` + +### Features + +#### Git + +- Add function to retrieve latest tag from git + +#### Config + +- Adding possibility to have a `devops.toml` or `.devops.toml` config file +- Adding logging levels to toml file config: `global_level`, `utils_level`, `config_level`, `cpp_level` + ```toml + [logging] + global_level = "INFO" + cpp_level = "DEBUG" + ``` +- Adding `file.encoding` config for toml configuration + +#### CPP Rules + +- Add license check rule for cpp header and source files + +### Deployment + +#### CI/CD + +- Add checking if `CHANGELOG.md` was updated +- Add ruff check and ruff format CI +- Add pytest CI with python versions 3.12 and 3.13 +- Add automatic release CI for PRs to main (either via title or via hotfix/ branch) +- Add overnight CI runs for pytest and ruff CIs +- Add test coverage to pytest CI + + + diff --git a/README.md b/README.md index e69de29..a58f501 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,4 @@ +# DevOps + +[![pytest](https://github.com/97gamjak/devops/actions/workflows/pytest.yml/badge.svg)](https://github.com/97gamjak/devops/actions/workflows/pytest.yml) +[![codecov](https://codecov.io/gh/97gamjak/devops/graph/badge.svg?token=yqdcZCNRzK)](https://codecov.io/gh/97gamjak/devops) \ No newline at end of file diff --git a/devops.toml.template b/devops.toml.template new file mode 100644 index 0000000..32882f5 --- /dev/null +++ b/devops.toml.template @@ -0,0 +1,23 @@ +# DevOps Configuration File + +[exclude] +#buggy_cpp_macros = [] + +[logging] +#global_level = "INFO" +#utils_level = "INFO" +#config_level = "INFO" +#cpp_level = "INFO" + +[git] +#tag_prefix = "" +#empty_tag_list_allowed = true + +[cpp] +#style_checks = true +#license_header_check = true +#check_only_staged_files = false + +[file] +#encoding = "utf-8" + diff --git a/pyproject.toml b/pyproject.toml index 1849a8f..e62e234 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,27 @@ +[build-system] +requires = ["setuptools>=65.5.1", "setuptools_scm>=6.2.0"] + [project] name = "devops" -version = "0.0.1" -description = "This package handles commit and CI checks for mstd" +dynamic = ["version"] +authors = [{ name = "Jakob Gamper", email = "97gamjak@gmail.com" }] +description = "This package is a collection of DevOps related tools and scripts." readme = "README.md" requires-python = ">=3.12" -dependencies = ["pytest>=9.0.1", "ruff>=0.14.6", "typer>=0.20.0"] +dependencies = ["typer>=0.20.0"] + +[project.optional-dependencies] +test = ["pytest>=9.0.1", "pytest-cov", "coverage", "docstr-coverage"] [project.scripts] -cpp_checks = "devops.scripts.cpp_checks:main" +cpp_checks = "devops.scripts.cpp_checks:app" update_changelog = "devops.scripts.update_changelog:app" +get_latest_tag = "devops.scripts.get_latest_git_tag:latest_tag" +increase_latest_tag = "devops.scripts.get_latest_git_tag:increase_tag" +generate_toml_template = "devops.scripts.generate_toml_template:app" +add_license_header = "devops.scripts.add_license_header:add_single_header" +add_license_headers = "devops.scripts.add_license_header:add_multiple_headers" +filter_buggy_cpp_files = "devops.scripts.cpp_files:app" + +[tool.setuptools_scm] +version_file = "src/devops/__version__.py" \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..dbab4ef --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +addopts = + --cov=src/devops + --cov-report=html + --doctest-modules \ No newline at end of file diff --git a/src/devops/ruff.toml b/ruff.toml similarity index 71% rename from src/devops/ruff.toml rename to ruff.toml index e2b672d..1ef702a 100644 --- a/src/devops/ruff.toml +++ b/ruff.toml @@ -8,4 +8,13 @@ ignore = [ "S607", # relative paths are not allowed by this rule in subprocess commands "ANN401", # allow Any type annotations for now ] -pylint.max-args = 6 \ No newline at end of file +pylint.max-args = 6 + +[lint.per-file-ignores] +"tests/*" = [ + "S101", # allow use of assert in tests + "PLR2004", # allow use of magic numbers in tests +] + +[lint.pydocstyle] +convention = "numpy" diff --git a/src/devops.egg-info/PKG-INFO b/src/devops.egg-info/PKG-INFO deleted file mode 100644 index b528ff4..0000000 --- a/src/devops.egg-info/PKG-INFO +++ /dev/null @@ -1,9 +0,0 @@ -Metadata-Version: 2.4 -Name: devops -Version: 0.0.1 -Summary: This package handles commit and CI checks for mstd -Requires-Python: >=3.12 -Description-Content-Type: text/markdown -Requires-Dist: pytest>=9.0.1 -Requires-Dist: ruff>=0.14.6 -Requires-Dist: typer>=0.20.0 diff --git a/src/devops.egg-info/SOURCES.txt b/src/devops.egg-info/SOURCES.txt deleted file mode 100644 index 25c0642..0000000 --- a/src/devops.egg-info/SOURCES.txt +++ /dev/null @@ -1,29 +0,0 @@ -README.md -pyproject.toml -src/devops/__init__.py -src/devops/github.py -src/devops.egg-info/PKG-INFO -src/devops.egg-info/SOURCES.txt -src/devops.egg-info/dependency_links.txt -src/devops.egg-info/entry_points.txt -src/devops.egg-info/requires.txt -src/devops.egg-info/top_level.txt -src/devops/cpp/__init__.py -src/devops/cpp/style_rules.py -src/devops/enums/__init__.py -src/devops/enums/base.py -src/devops/files/__init__.py -src/devops/files/config.py -src/devops/files/files.py -src/devops/files/update_changelog.py -src/devops/logger/__init__.py -src/devops/logger/logger.py -src/devops/rules/__init__.py -src/devops/rules/result_type.py -src/devops/rules/rules.py -src/devops/scripts/__init__.py -src/devops/scripts/cpp_checks.py -src/devops/scripts/update_changelog.py -src/devops/utils/__init__.py -src/devops/utils/rich.py -src/devops/utils/utils.py \ No newline at end of file diff --git a/src/devops.egg-info/dependency_links.txt b/src/devops.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/src/devops.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/devops.egg-info/entry_points.txt b/src/devops.egg-info/entry_points.txt deleted file mode 100644 index e38502a..0000000 --- a/src/devops.egg-info/entry_points.txt +++ /dev/null @@ -1,3 +0,0 @@ -[console_scripts] -cpp_checks = devops.scripts.cpp_checks:main -update_changelog = devops.scripts.update_changelog:app diff --git a/src/devops.egg-info/requires.txt b/src/devops.egg-info/requires.txt deleted file mode 100644 index c59dfdd..0000000 --- a/src/devops.egg-info/requires.txt +++ /dev/null @@ -1,3 +0,0 @@ -pytest>=9.0.1 -ruff>=0.14.6 -typer>=0.20.0 diff --git a/src/devops.egg-info/top_level.txt b/src/devops.egg-info/top_level.txt deleted file mode 100644 index e18b651..0000000 --- a/src/devops.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -devops diff --git a/src/devops/__init__.py b/src/devops/__init__.py index 2f5cd90..1b7e2b5 100644 --- a/src/devops/__init__.py +++ b/src/devops/__init__.py @@ -1,7 +1,14 @@ -"""Top level package for mstd checks.""" +"""Top level package for devops.""" from pathlib import Path -__BASE_DIR__ = Path(__file__).resolve().parent.parent +from devops.config import init_config -__all__ = ["__BASE_DIR__"] +__NOT_DEFINED__ = object() +__GLOBAL_CONFIG__ = __NOT_DEFINED__ + +__BASE_DIR__ = Path(__file__).resolve().parent + + +if __GLOBAL_CONFIG__ is __NOT_DEFINED__: + __GLOBAL_CONFIG__ = init_config() diff --git a/src/devops/config/__init__.py b/src/devops/config/__init__.py new file mode 100644 index 0000000..486c411 --- /dev/null +++ b/src/devops/config/__init__.py @@ -0,0 +1,8 @@ +"""DevOps config package.""" + +from .config import init_config +from .config_cpp import CppConfig +from .config_git import GitConfig +from .constants import Constants + +__all__ = ["Constants", "CppConfig", "GitConfig", "init_config"] diff --git a/src/devops/config/base.py b/src/devops/config/base.py new file mode 100644 index 0000000..e3e3634 --- /dev/null +++ b/src/devops/config/base.py @@ -0,0 +1,217 @@ +"""Base configuration utilities.""" + +from __future__ import annotations + +import typing + +if typing.TYPE_CHECKING: + from typing import Any + + from devops.enums import StrEnum + + +# TODO(97gamjak): centralize exception handling +# https://github.com/97gamjak/devops/issues/24 +class ConfigError(Exception): + """Custom exception for configuration-related errors.""" + + def __init__(self, message: str) -> None: + """Initialize the exception with a message.""" + super().__init__(f"ConfigError: {message}") + self.message = message + + +def get_table(mapping: dict[str, Any], key: str) -> dict[str, Any]: + """Get a sub-table from a mapping. + + Parameters + ---------- + mapping: dict[str, Any] + The mapping to extract the sub-table from. + key: str + The key of the sub-table. + + Returns + ------- + dict[str, Any] + The extracted sub-table or an empty dictionary if the key is not found. + + Raises + ------ + ConfigError + If the value associated with the key is not a dictionary. + + """ + value = mapping.get(key) + + if value is None: + return {} + + if not isinstance(value, dict): + msg = f"Expected dict for key '{key}', got {type(value).__name__}" + raise ConfigError(msg) + + return value + + +def _get_type( + mapping: dict[str, Any], key: str, default: Any, expected_type: type +) -> Any: + """Get a value of expected type from a mapping. + + Parameters + ---------- + mapping: dict[str, Any] + The mapping to extract the value from. + key: str + The key of the value. + default: Any + The default value to return if the key is not found. + expected_type: type + The expected type of the value. + + Returns + ------- + Any + The extracted value or the default value if the key is not found. + + Raises + ------ + ConfigError + If the value associated with the key is not of the expected type. + """ + value = mapping.get(key, default) + + if value is None: + return None + + if not isinstance(value, expected_type): + msg = ( + f"Expected {expected_type.__name__} for " + f"key '{key}', got {type(value).__name__}" + ) + raise ConfigError(msg) + + return value + + +def get_bool( + mapping: dict[str, Any], key: str, *, default: bool | None = None +) -> bool | None: + """Get a boolean value from a mapping. + + Parameters + ---------- + mapping: dict[str, Any] + The mapping to extract the boolean from. + key: str + The key of the boolean. + default: bool | None + The default value to return if the key is not found. + + Returns + ------- + bool | None + The extracted boolean value or None if the key is not found. + """ + return _get_type(mapping, key, default, bool) + + +def get_str( + mapping: dict[str, Any], key: str, default: str | None = None +) -> str | None: + """Get a string value from a mapping. + + Parameters + ---------- + mapping: dict[str, Any] + The mapping to extract the string from. + key: str + The key of the string. + default: str | None + The default value to return if the key is not found. + + Returns + ------- + str | None + The extracted string value or None if the key is not found. + """ + return _get_type(mapping, key, default, str) + + +def get_str_enum( + mapping: dict[str, Any], key: str, enum_type: type, default: str | None = None +) -> StrEnum | None: + """Get a string enum value from a mapping. + + Parameters + ---------- + mapping: dict[str, Any] + The mapping to extract the enum value from. + key: str + The key of the enum value. + enum_type: type + The enum type to validate against. + default: str + The default value to return if the key is not found. + + Returns + ------- + StrEnum | None + The extracted enum value or None if the key is not found. + + Raises + ------ + ConfigError + If the value associated with the key is not a valid enum value. + """ + value = _get_type(mapping, key, default, str) + + if value is None: + return None + + if enum_type.is_valid(value): + return enum_type(value) + + msg = ( + f"Invalid value for key '{key}': {value}," + f" expected one of {enum_type.list_values()}" + ) + raise ConfigError(msg) + + +def get_str_list( + mapping: dict[str, Any], key: str, default: list[str] | None = None +) -> list[str]: + """Get a list of strings from a mapping. + + Parameters + ---------- + mapping: dict[str, Any] + The mapping to extract the list from. + key: str + The key of the list. + default: list[str] | None + The default value to return if the key is not found. Defaults to None. + + Returns + ------- + list[str] + The extracted list of strings or an empty list if the key is not found. + + Raises + ------ + ConfigError + If the value associated with the key is not a list of strings. + + """ + value = mapping.get(key, default) + + if value is None: + return [] + + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + msg = f"Expected list of strings for key '{key}', got {type(value).__name__}" + raise ConfigError(msg) + + return value diff --git a/src/devops/config/config.py b/src/devops/config/config.py new file mode 100644 index 0000000..1eba7dc --- /dev/null +++ b/src/devops/config/config.py @@ -0,0 +1,179 @@ +"""Module for reading and parsing configuration files.""" + +from __future__ import annotations + +import typing +from dataclasses import dataclass, field +from pathlib import Path + +from devops.logger import config_logger + +from .base import get_str_list, get_table +from .config_cpp import CppConfig, parse_cpp_config +from .config_file import FileConfig, parse_file_config +from .config_git import GitConfig, parse_git_config +from .config_logging import LoggingConfig, parse_logging_config +from .constants import Constants +from .toml import load_toml + +if typing.TYPE_CHECKING: + from typing import Any + + +@dataclass +class ExcludeConfig: + """Dataclass to hold default exclusion values.""" + + buggy_cpp_macros: list[str] = field(default_factory=list) + + def to_toml_lines(self) -> list[str]: + """Convert the ExcludeConfig to TOML lines. + + Returns + ------- + list[str] + The list of TOML lines representing the configuration. + + """ + lines = ["[exclude]\n"] + + lines.append( + "#buggy_cpp_macros = [" + + ", ".join(f'"{macro}"' for macro in self.buggy_cpp_macros) + + "]\n" + ) + + return lines + + +@dataclass +class GlobalConfig: + """Dataclass to hold default configuration values.""" + + exclude: ExcludeConfig = field(default_factory=ExcludeConfig) + logging: LoggingConfig = field(default_factory=LoggingConfig) + git: GitConfig = field(default_factory=GitConfig) + cpp: CppConfig = field(default_factory=CppConfig) + file: FileConfig = field(default_factory=FileConfig) + + def write_default(self) -> None: + """Write the current configuration to a TOML file. + + Parameters + ---------- + path: str | Path + The path to the output TOML file. + + """ + lines = ["# DevOps Configuration File\n\n"] + lines += [*self.exclude.to_toml_lines(), "\n"] + lines += [*self.logging.to_toml_lines(), "\n"] + lines += [*self.git.to_toml_lines(), "\n"] + lines += [*self.cpp.to_toml_lines(), "\n"] + lines += [*self.file.to_toml_lines(), "\n"] + file = Path(Constants.files.default_toml_template) + + # NOTE: here we don't want to use any special handling from + # the files module to avoid circular imports + with file.open("w", encoding=self.file.encoding) as f: + f.writelines(lines) + + +def parse_config(raw: dict[str, Any]) -> GlobalConfig: + """Parse a raw configuration dictionary into a GlobalConfig object. + + Parameters + ---------- + raw: dict[str, Any] + The raw configuration dictionary. + + Returns + ------- + GlobalConfig + The parsed GlobalConfig object. + + """ + # start logging configuration + # NOTE: this should be done before anything else + # as logging config already updates loggers + logging_config = parse_logging_config(raw) + + git_config = parse_git_config(raw) + cpp_config = parse_cpp_config(raw) + file_config = parse_file_config(raw) + + # start exclude configuration + exclude_table = get_table(raw, "exclude") + + buggy_cpp_macros = get_str_list(exclude_table, "buggy_cpp_macros") + + exclude_config = ExcludeConfig( + buggy_cpp_macros=buggy_cpp_macros, + ) + # end exclude configuration + + return GlobalConfig( + exclude=exclude_config, + logging=logging_config, + git=git_config, + cpp=cpp_config, + file=file_config, + ) + + +def read_config(path: str | Path | None = None) -> GlobalConfig: + """Read and parse a TOML configuration file into a GlobalConfig object. + + Parameters + ---------- + path: str | Path | None + The path to the TOML configuration file. + If None, defaults to the general default config + + Returns + ------- + GlobalConfig + The parsed GlobalConfig object. + """ + if path is None: + return GlobalConfig() + + raw_config = load_toml(Path(path)) + return parse_config(raw_config) + + +def init_config() -> GlobalConfig: + """Initialize global config paths. + + Returns + ------- + GlobalConfig + The initialized global configuration object. + """ + file_names = Constants.files.toml_filenames + found_configs = [Path(fname) for fname in file_names if Path(fname).is_file()] + + if len(found_configs) == 1: + config = read_config(Path(found_configs[0])) + else: + config = read_config() + + # Note: we log here after setting up the config to ensure logging config is applied + # before any logging is done. + + use_default_config = False + + if len(found_configs) > 1: + config_logger.warning( + "Multiple config files found: %s. Using no config file.", + ", ".join(str(p) for p in found_configs), + ) + use_default_config = True + elif len(found_configs) < 1: + config_logger.debug("No config file found. Using default configuration.") + use_default_config = True + + if use_default_config: + config_logger.debug("The default configuration being used is: %s", config) + + return config diff --git a/src/devops/config/config_cpp.py b/src/devops/config/config_cpp.py new file mode 100644 index 0000000..48a85be --- /dev/null +++ b/src/devops/config/config_cpp.py @@ -0,0 +1,88 @@ +"""Module for parsing C++ configuration.""" + +from dataclasses import dataclass + +from devops.logger import config_logger + +from .base import get_bool, get_str, get_table + + +@dataclass +class CppConfig: + """Dataclass to hold C++ configuration values.""" + + # Enable or disable running C++ style checks (e.g., clang-format, clang-tidy). + style_checks: bool = True + # Enable or disable verification that source files contain + # the expected license header. + license_header_check: bool = True + # Path to the license header file whose contents should be enforced, or None to use + # the tool's default behavior (for example, no custom license header content). + license_header: str | None = None + # If True, limit checks to files that are currently staged + # (e.g., in a pre-commit hook). + check_only_staged_files: bool = False + + def to_toml_lines(self) -> list[str]: + """Convert the CppConfig to TOML lines. + + Returns + ------- + list[str] + The list of TOML lines representing the configuration. + + """ + lines = ["[cpp]\n"] + + lines.append(f"#style_checks = {str(self.style_checks).lower()}\n") + lines.append( + f"#license_header_check = {str(self.license_header_check).lower()}\n" + ) + + if self.license_header is not None: + lines.append(f'#license_header = "{self.license_header}"\n') + + lines.append( + f"#check_only_staged_files = {str(self.check_only_staged_files).lower()}\n" + ) + + return lines + + +def parse_cpp_config(raw_config: dict) -> CppConfig: + """Parse C++ configuration from a raw dictionary. + + Parameters + ---------- + raw_config: dict + The raw C++ configuration dictionary. + + Returns + ------- + CppConfig + The parsed CppConfig dataclass instance. + """ + table = get_table(raw_config, "cpp") + + style_checks = get_bool(table, "style_checks", default=CppConfig.style_checks) + + license_header_check = get_bool( + table, "license_header_check", default=CppConfig.license_header_check + ) + + license_header = get_str(table, "license_header") + + check_only_staged_files = get_bool( + table, "check_only_staged_files", default=CppConfig.check_only_staged_files + ) + + config = CppConfig( + style_checks=style_checks, + license_header_check=license_header_check, + license_header=license_header, + check_only_staged_files=check_only_staged_files, + ) + + config_logger.debug(f"Parsed C++ configuration: {config}") + + return config diff --git a/src/devops/config/config_file.py b/src/devops/config/config_file.py new file mode 100644 index 0000000..0b80bd5 --- /dev/null +++ b/src/devops/config/config_file.py @@ -0,0 +1,57 @@ +"""Module to parse file configuration values.""" + +from dataclasses import dataclass +from pathlib import Path + +from .base import ConfigError, get_str, get_table + + +@dataclass(frozen=True) +class FileConfig: + """Dataclass to hold file configuration values.""" + + encoding: str = "utf-8" + + def to_toml_lines(self) -> list[str]: + """Convert the FileConfig to TOML lines. + + Returns + ------- + list[str] + The list of TOML lines representing the configuration. + + """ + lines = ["[file]\n"] + lines.append(f'#encoding = "{self.encoding}"\n') + return lines + + +def parse_file_config(raw_config: dict) -> FileConfig: + """Parse file configuration from a raw dictionary. + + Parameters + ---------- + raw_config: dict + The raw file configuration dictionary. + + Returns + ------- + FileConfig + The parsed FileConfig dataclass instance. + + Raises + ------ + ConfigError + If the specified encoding is invalid. + """ + table = get_table(raw_config, "file") + + encoding = get_str(table, "encoding", default=FileConfig.encoding) + + try: + Path(__file__).open("r", encoding=encoding).close() + except LookupError as e: + msg = f"Invalid file encoding specified in configuration: {encoding}" + raise ConfigError(msg) from e + + return FileConfig(encoding=encoding) diff --git a/src/devops/config/config_git.py b/src/devops/config/config_git.py new file mode 100644 index 0000000..c86f45a --- /dev/null +++ b/src/devops/config/config_git.py @@ -0,0 +1,58 @@ +"""Git configuration parsing module.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .base import get_bool, get_str, get_table + + +@dataclass +class GitConfig: + """Dataclass to hold default git configuration values.""" + + tag_prefix: str = "" + empty_tag_list_allowed: bool = True + + def to_toml_lines(self) -> list[str]: + """Convert the GitConfig to TOML lines. + + Returns + ------- + list[str] + The list of TOML lines representing the configuration. + + """ + lines = ["[git]\n"] + lines.append(f'#tag_prefix = "{self.tag_prefix}"\n') + lines.append( + f"#empty_tag_list_allowed = {str(self.empty_tag_list_allowed).lower()}\n" + ) + return lines + + +def parse_git_config(raw_config: dict) -> GitConfig: + """Parse git configuration from a raw dictionary. + + Parameters + ---------- + raw_config: dict + The raw git configuration dictionary. + + Returns + ------- + GitConfig + The parsed GitConfig dataclass instance. + """ + table = get_table(raw_config, "git") + + tag_prefix = get_str(table, "tag_prefix", GitConfig.tag_prefix) + + empty_tag_list_allowed = get_bool( + table, "empty_tag_list_allowed", default=GitConfig.empty_tag_list_allowed + ) + + return GitConfig( + tag_prefix=tag_prefix, + empty_tag_list_allowed=empty_tag_list_allowed, + ) diff --git a/src/devops/config/config_logging.py b/src/devops/config/config_logging.py new file mode 100644 index 0000000..0f5a481 --- /dev/null +++ b/src/devops/config/config_logging.py @@ -0,0 +1,97 @@ +"""Module for logging configuration.""" + +import logging +from dataclasses import dataclass + +from devops.enums import LogLevel +from devops.logger import config_logger, cpp_check_logger, utils_logger + +from .base import get_str_enum, get_table + + +@dataclass +class LoggingConfig: + """Dataclass to hold logging configuration values.""" + + global_level: LogLevel = LogLevel.INFO + utils_level: LogLevel = LogLevel.INFO + config_level: LogLevel = LogLevel.INFO + cpp_level: LogLevel = LogLevel.INFO + + def to_toml_lines(self) -> list[str]: + """Convert the LoggingConfig to TOML lines. + + Returns + ------- + list[str] + The list of TOML lines representing the configuration. + + """ + lines = ["[logging]\n"] + lines.append(f'#global_level = "{self.global_level.value}"\n') + lines.append(f'#utils_level = "{self.utils_level.value}"\n') + lines.append(f'#config_level = "{self.config_level.value}"\n') + lines.append(f'#cpp_level = "{self.cpp_level.value}"\n') + return lines + + +def parse_logging_config(raw_config: dict) -> LoggingConfig: + """Parse logging configuration from a raw dictionary. + + As a side effect, this function sets the logging levels + according to the parsed configuration. + + Parameters + ---------- + raw_config: dict + The raw logging configuration dictionary. + + Returns + ------- + LoggingConfig + The parsed LoggingConfig dataclass instance. + + """ + table = get_table(raw_config, "logging") + + global_level = get_str_enum(table, "global_level", LogLevel) + utils_level = get_str_enum(table, "utils_level", LogLevel) + config_level = get_str_enum(table, "config_level", LogLevel) + cpp_level = get_str_enum(table, "cpp_level", LogLevel) + + if global_level is None: + global_level = LogLevel.from_logging_level(logging.root.level) + + if utils_level is None: + utils_level = LogLevel.from_logging_level(utils_logger.level) + + if config_level is None: + config_level = LogLevel.from_logging_level(config_logger.level) + + if cpp_level is None: + cpp_level = LogLevel.from_logging_level(cpp_check_logger.level) + + config = LoggingConfig( + global_level=global_level, + utils_level=utils_level, + config_level=config_level, + cpp_level=cpp_level, + ) + + set_logging_levels(config) + return config + + +def set_logging_levels(config: LoggingConfig) -> None: + """Set logging levels based on the provided LoggingConfig. + + Parameters + ---------- + config: LoggingConfig + The logging configuration. + + """ + logging.getLogger().setLevel(config.global_level.to_logging_level()) + utils_logger.setLevel(config.utils_level.to_logging_level()) + config_logger.setLevel(config.config_level.to_logging_level()) + cpp_check_logger.setLevel(config.cpp_level.to_logging_level()) diff --git a/src/devops/config/constants.py b/src/devops/config/constants.py new file mode 100644 index 0000000..5403d76 --- /dev/null +++ b/src/devops/config/constants.py @@ -0,0 +1,31 @@ +"""Constants for DevOps checks.""" + +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class GitConstants: + """Class holding constant Git-related URLs.""" + + github_url: str = "https://github.com" + github_devops_repo: str = github_url + "/97gamjak/devops" + github_devops_issues_url: str = github_devops_repo + "/issues" + github_default_owner_url: str = github_url + "/repo/owner" + + +@dataclass(frozen=True) +class FileConstants: + """Class holding constant file-related values.""" + + default_toml_template: str = "devops.toml.template" + toml_filenames: list[str] = field( + default_factory=lambda: ["devops.toml", ".devops.toml"] + ) + + +@dataclass(frozen=True) +class Constants: + """Class holding constant values for DevOps checks.""" + + github: GitConstants = GitConstants() + files: FileConstants = FileConstants() diff --git a/src/devops/config/toml.py b/src/devops/config/toml.py new file mode 100644 index 0000000..46f52a9 --- /dev/null +++ b/src/devops/config/toml.py @@ -0,0 +1,50 @@ +"""Module for handling TOML files in a DevOps context.""" + +from __future__ import annotations + +import tomllib +from pathlib import Path +from typing import Any + + +# TODO(97gamjak): centralize exception handling +# https://github.com/97gamjak/devops/issues/24 +class TomlError(Exception): + """Custom exception for TOML-related errors.""" + + def __init__(self, message: str) -> None: + """Initialize the exception with a message.""" + super().__init__(f"TomlError: {message}") + self.message = message + + +def load_toml(file_path: str | Path) -> dict[str, Any]: + """Load a TOML file and return its contents as a dictionary. + + Parameters + ---------- + file_path: str | Path + The path to the TOML file to be loaded. + + Returns + ------- + dict[str, Any] + The contents of the TOML file as a dictionary. + + Raises + ------ + TomlError + If there is an error reading or parsing the TOML file. + + """ + if isinstance(file_path, str): + file_path = Path(file_path) + + try: + with file_path.open("rb") as toml_file: + data = tomllib.load(toml_file) + except (FileNotFoundError, tomllib.TOMLDecodeError) as e: + msg = f"Error loading TOML file '{file_path}': {e}" + raise TomlError(msg) from e + + return data diff --git a/src/devops/cpp/__init__.py b/src/devops/cpp/__init__.py index de38dd3..63bdafc 100644 --- a/src/devops/cpp/__init__.py +++ b/src/devops/cpp/__init__.py @@ -1,6 +1,13 @@ """Package defining C++ check rules.""" -from .style_rules import cpp_style_rules +from .buggy_cpp_files import filter_buggy_cpp +from .build_rules import build_cpp_rules +from .checks import run_cpp_checks +from .license_header import add_license_header -cpp_rules = cpp_style_rules -__all__ = ["cpp_rules"] +__all__ = [ + "add_license_header", + "build_cpp_rules", + "filter_buggy_cpp", + "run_cpp_checks", +] diff --git a/src/devops/cpp/buggy_cpp_files.py b/src/devops/cpp/buggy_cpp_files.py new file mode 100644 index 0000000..581cb3a --- /dev/null +++ b/src/devops/cpp/buggy_cpp_files.py @@ -0,0 +1,37 @@ +"""Module for filtering known buggy C++ header files.""" + +import re + +from devops import __GLOBAL_CONFIG__ +from devops.files import open_file + + +def filter_buggy_cpp(files: list[str]) -> list[str]: + """Filter out known buggy C++ header files from a list of files. + + Parameters + ---------- + files: list[str] + The list of file paths to filter. + + Returns + ------- + list[str] + The filtered list of file paths excluding known buggy C++ headers. + + """ + buggy_macros = __GLOBAL_CONFIG__.exclude.buggy_cpp_macros + + filtered_files = [] + for file in files: + with open_file(file, mode="r") as f: + content = f.read() + for buggy_macro in buggy_macros: + regex = rf"\b{re.escape(buggy_macro)}\(\"[^)]*\"\)" + matches = re.findall(regex, content) + if matches: + break + else: + filtered_files.append(file) + + return filtered_files diff --git a/src/devops/cpp/build_rules.py b/src/devops/cpp/build_rules.py new file mode 100644 index 0000000..7a61187 --- /dev/null +++ b/src/devops/cpp/build_rules.py @@ -0,0 +1,41 @@ +"""Module to build C++ rules based on global configuration.""" + +from devops import __GLOBAL_CONFIG__ +from devops.config import CppConfig +from devops.logger import cpp_check_logger +from devops.rules import Rule + +from .license_header import CheckLicenseHeader +from .style_rules import cpp_style_rules + + +def build_cpp_rules(config: CppConfig = __GLOBAL_CONFIG__.cpp) -> list[Rule]: + """Build and return the list of C++ rules based on the global configuration. + + Parameters + ---------- + config: CppConfig + The global C++ configuration. + + Returns + ------- + list[Rule] + The list of C++ rules. + + """ + rules = [] + + if config.style_checks: + rules += cpp_style_rules + + if config.license_header_check: + if config.license_header is not None: + rules.append(CheckLicenseHeader(config.license_header)) + else: + cpp_check_logger.warning( + "License header check is enabled, " + "but no license header text is provided in the configuration." + "This rule will be skipped." + ) + + return rules diff --git a/src/devops/cpp/checks.py b/src/devops/cpp/checks.py new file mode 100644 index 0000000..871d6cb --- /dev/null +++ b/src/devops/cpp/checks.py @@ -0,0 +1,166 @@ +"""C++ checks module.""" + +from pathlib import Path + +from devops import __GLOBAL_CONFIG__ +from devops.config import CppConfig +from devops.files import ( + FileType, + determine_file_type, + get_dirs_in_dir, + get_files_in_dirs, + get_staged_files, + open_file, +) +from devops.logger import cpp_check_logger +from devops.rules import ( + ResultType, + Rule, + filter_file_rules, + filter_line_rules, + is_file_rule, + is_line_rule, +) +from devops.rules.result_type import ResultTypeEnum + + +class CppCheckError(Exception): + """Custom exception for C++ check errors.""" + + +def run_line_checks(rules: list[Rule], file: Path) -> list[ResultType]: + """Run line-based C++ checks on a given file. + + Parameters + ---------- + rules: list[Rule] + The list of rules to apply. + file: Path + The file to check. + + Returns + ------- + list[ResultType] + The list of results from the checks. + + Raises + ------ + CppCheckError + If a non-line rule is provided. + + """ + results = [] + file_type = determine_file_type(file) + + if any(not is_line_rule(rule) for rule in rules): + msg = "Non-line rule provided to run_line_checks" + raise CppCheckError(msg) + + with open_file(file, mode="r") as f: + for line in f: + for rule in rules: + if file_type not in rule.file_types: + continue + + results.append(rule.apply(line)) + + return results + + +def run_file_rules(rules: list[Rule], file: Path) -> list[ResultType]: + """Run file-based C++ checks on a given file. + + Parameters + ---------- + rules: list[Rule] + The list of rules to apply. + file: Path + The file to check. + + Returns + ------- + list[ResultType] + The list of results from the checks. + + Raises + ------ + CppCheckError + If a non-file rule is provided. + + """ + results = [] + file_type = determine_file_type(file) + + if any(not is_file_rule(rule) for rule in rules): + msg = "Non-file rule provided to run_file_rules" + raise CppCheckError(msg) + + with open_file(file, mode="r") as f: + content = f.read() + for rule in rules: + if file_type not in rule.file_types: + continue + + results.append(rule.apply((content,))) + + return results + + +def run_cpp_checks( + rules: list[Rule], config: CppConfig = __GLOBAL_CONFIG__.cpp +) -> None: + """Run C++ checks based on the provided rules. + + Returns immediately after encountering the first file with errors. + + Parameters + ---------- + rules: list[Rule] + The list of rules to apply. + config: CppConfig + The global C++ configuration. + + Raises + ------ + CppCheckError + If invalid (non-file or non-line) rules are provided. + + """ + if config.check_only_staged_files: + cpp_check_logger.info("Running checks on staged files...") + files = get_staged_files() + else: + cpp_check_logger.info("Running full checks...") + + dirs = get_dirs_in_dir() + files = get_files_in_dirs(dirs) + + cpp_check_logger.debug(f"Checking directories: {[str(d) for d in dirs]}") + + files = [file for file in files if FileType.is_cpp_type(determine_file_type(file))] + + if not files: + cpp_check_logger.warning("No files to check.") + return + + file_rules = filter_file_rules(rules) + line_rules = filter_line_rules(rules) + + for filename in files: + cpp_check_logger.debug(f"Checking file: {filename}") + + # file rules + file_results = run_file_rules(file_rules, filename) + + # line rules + file_results += run_line_checks(line_rules, filename) + + if any(result.value != ResultTypeEnum.Ok for result in file_results): + filtered_results = [ + res for res in file_results if res.value != ResultTypeEnum.Ok + ] + for res in filtered_results: + cpp_check_logger.error( + f"CPP check error: result in {filename}: {res.description}" + ) + return diff --git a/src/devops/cpp/license_header.py b/src/devops/cpp/license_header.py new file mode 100644 index 0000000..d3932f3 --- /dev/null +++ b/src/devops/cpp/license_header.py @@ -0,0 +1,114 @@ +"""Module to check for license headers in C++ files.""" + +from __future__ import annotations + +from pathlib import Path + +from devops.files import file_exist, open_file +from devops.rules import ResultType, ResultTypeEnum, Rule, RuleInputType, RuleType + + +def check_license_header( + file_content: str, required_header_file: str | Path +) -> ResultType: + """Check if the file content starts with the required license header. + + Parameters + ---------- + file_content: str + The content of the file to check. + required_header_file: str | Path + The path to the file containing the required license header. + + Returns + ------- + ResultType + The result of the license header check. + + Raises + ------ + DevOpsFileNotFoundError + If the required header file does not exist. + """ + required_header_file = Path(required_header_file) + + # return value can be ignored as exception will be raised if file does not exist + file_exist( + required_header_file, + throwing=True, + throw_msg="Required license header file not found.", + ) + + with open_file(required_header_file, mode="r") as f: + required_header = f.read() + + if file_content.startswith(required_header): + return ResultType(ResultTypeEnum.Ok) + + return ResultType(ResultTypeEnum.Error, "Missing or incorrect license header.") + + +def add_license_header( + file: str | Path, header_to_add: str | Path, *, dry_run: bool = False +) -> bool: + """Add the license header to the file content if it's missing. + + Parameters + ---------- + file: str | Path + The path to the file to modify. + header_to_add: str | Path + The path to the file containing the license header to add. + dry_run: bool + If True, do not modify the file, only simulate the addition. + + Returns + ------- + bool + True if the license header was added, False if it was already present. + + Raises + ------ + DevOpsFileNotFoundError + If the header file does not exist. + """ + header_to_add = Path(header_to_add) + + # return value can be ignored as exception will be raised if file does not exist + file_exist( + header_to_add, + throwing=True, + throw_msg="License header file to add not found.", + ) + + new_content = "" + + with open_file(file, mode="r") as f: + file_content = f.read() + + with open_file(header_to_add, mode="r") as f: + license_header = f.read() + + if not file_content.startswith(license_header): + new_content = license_header + file_content + + if new_content and not dry_run: + with open_file(file, mode="w") as f: + f.write(new_content) + + return True + + return new_content and dry_run + + +class CheckLicenseHeader(Rule): + """Rule to check for the presence of a license header in C++ files.""" + + def __init__(self, header_to_check: str | Path) -> None: + super().__init__( + name="License Header Check", + func=lambda content: check_license_header(content, header_to_check), + rule_type=RuleType.CPP_STYLE, + rule_input_type=RuleInputType.FILE, + description="Ensure that the file contains the required license header.", + ) diff --git a/src/devops/cpp/style_rules.py b/src/devops/cpp/style_rules.py index 015a3d8..a355247 100644 --- a/src/devops/cpp/style_rules.py +++ b/src/devops/cpp/style_rules.py @@ -18,13 +18,10 @@ def __init__(self, key_sequence: str) -> None: """ super().__init__( name=key_sequence, - func=lambda line: check_key_sequence_ordered( - key_sequence, - line - ), + func=lambda line: check_key_sequence_ordered(key_sequence, line), rule_type=RuleType.CPP_STYLE, rule_input_type=RuleInputType.LINE, - description=f'Use "{key_sequence}" only in this given order.' + description=f'Use "{key_sequence}" only in this given order.', ) diff --git a/src/devops/enums/__init__.py b/src/devops/enums/__init__.py index 5d116e5..b7d6e6b 100644 --- a/src/devops/enums/__init__.py +++ b/src/devops/enums/__init__.py @@ -1,5 +1,6 @@ """Top level package for enums in mstd checks.""" from .base import StrEnum +from .logging import LogLevel -__all__ = ["StrEnum"] +__all__ = ["LogLevel", "StrEnum"] diff --git a/src/devops/enums/base.py b/src/devops/enums/base.py index 62b9daa..a2c8671 100644 --- a/src/devops/enums/base.py +++ b/src/devops/enums/base.py @@ -20,3 +20,38 @@ def _missing_(cls, value: object) -> StrEnum | None: if member.value.upper() == value.upper(): return member return None + + @classmethod + def is_valid(cls, value: str) -> bool: + """Check if a given string is a valid enumeration member. + + Parameters + ---------- + value: str + The string to check. + + Returns + ------- + bool + True if the string corresponds to a valid enumeration member, + False otherwise. + + """ + try: + cls(value) + except ValueError: + return False + + return True + + @classmethod + def list_values(cls) -> list[str]: + """List all enumeration values as strings. + + Returns + ------- + list[str] + A list of all enumeration values. + + """ + return [member.value for member in cls] diff --git a/src/devops/enums/logging.py b/src/devops/enums/logging.py new file mode 100644 index 0000000..2b73b78 --- /dev/null +++ b/src/devops/enums/logging.py @@ -0,0 +1,185 @@ +"""Module defining logging level enumeration.""" + +from __future__ import annotations + +import logging + +from .base import StrEnum + + +class LogLevel(StrEnum): + """Enumeration of logging levels.""" + + NONE = "NONE" + DEBUG = "DEBUG" + INFO = "INFO" + WARNING = "WARNING" + ERROR = "ERROR" + CRITICAL = "CRITICAL" + + @classmethod + def from_int(cls, level: int) -> LogLevel: + """Create LogLevel from an integer logging level. + + Parameters + ---------- + level: int + The integer logging level. + + Returns + ------- + LogLevel + The corresponding LogLevel enumeration member. + + """ + int_to_level = { + logging.NOTSET // 10: cls.NONE, + logging.DEBUG // 10: cls.DEBUG, + logging.INFO // 10: cls.INFO, + logging.WARNING // 10: cls.WARNING, + logging.ERROR // 10: cls.ERROR, + logging.CRITICAL // 10: cls.CRITICAL, + } + if level in int_to_level: + return int_to_level[level] + + if level > logging.CRITICAL // 10: + return cls.CRITICAL + + if level < logging.NOTSET // 10: + return cls.NONE + + return cls.INFO + + def to_logging_level(self) -> int: + """Convert LogLevel to corresponding logging module level. + + Returns + ------- + int + The logging module level. + + """ + level_mapping = { + LogLevel.NONE: logging.NOTSET, + LogLevel.DEBUG: logging.DEBUG, + LogLevel.INFO: logging.INFO, + LogLevel.WARNING: logging.WARNING, + LogLevel.ERROR: logging.ERROR, + LogLevel.CRITICAL: logging.CRITICAL, + } + return level_mapping[self] + + @classmethod + def from_logging_level(cls, level: int) -> LogLevel: + """Create LogLevel from an integer logging level. + + Parameters + ---------- + level: int + The integer logging level. + + Returns + ------- + LogLevel + The corresponding LogLevel enumeration member. + + """ + return cls.from_int(level // 10) + + def __lt__(self, other: LogLevel) -> bool: + """Compare two LogLevel instances. + + Parameters + ---------- + other: LogLevel + The other LogLevel instance to compare with. + + Returns + ------- + bool + True if this LogLevel is less than the other LogLevel, False otherwise. + + """ + return self.to_logging_level() < other.to_logging_level() + + def __le__(self, other: LogLevel) -> bool: + """Check if this LogLevel is less than or equal to another LogLevel. + + Parameters + ---------- + other: LogLevel + The other LogLevel instance to compare with. + + Returns + ------- + bool + True if this LogLevel is less than or equal to the other LogLevel, + False otherwise. + + """ + return self.to_logging_level() <= other.to_logging_level() + + def __gt__(self, other: LogLevel) -> bool: + """Check if this LogLevel is greater than another LogLevel. + + Parameters + ---------- + other: LogLevel + The other LogLevel instance to compare with. + + Returns + ------- + bool + True if this LogLevel is greater than the other LogLevel, + False otherwise. + + """ + return self.to_logging_level() > other.to_logging_level() + + def __ge__(self, other: LogLevel) -> bool: + """Check if this LogLevel is greater than or equal to another LogLevel. + + Parameters + ---------- + other: LogLevel + The other LogLevel instance to compare with. + + Returns + ------- + bool + True if this LogLevel is greater than or equal to the other LogLevel, + False otherwise. + + """ + return self.to_logging_level() >= other.to_logging_level() + + def __eq__(self, other: object) -> bool: + """Check if this LogLevel is equal to another LogLevel. + + Parameters + ---------- + other: object + The other object to compare with. + + Returns + ------- + bool + True if this LogLevel is equal to the other LogLevel, + False otherwise. + + """ + if not isinstance(other, LogLevel): + return NotImplemented + return self.to_logging_level() == other.to_logging_level() + + def __hash__(self) -> int: + """Return the hash of the LogLevel instance. + + Returns + ------- + int + The hash value of the LogLevel instance. + + """ + return hash(self.value) diff --git a/src/devops/files/__init__.py b/src/devops/files/__init__.py index da756dc..a4e580b 100644 --- a/src/devops/files/__init__.py +++ b/src/devops/files/__init__.py @@ -2,7 +2,17 @@ from pathlib import Path -from .files import FileType, determine_file_type, get_files_in_dirs, get_staged_files +from .files import ( + FileType, + determine_file_type, + file_exist, + filter_cpp_files, + get_dirs_in_dir, + get_files_in_dirs, + get_staged_files, + open_file, + write_text, +) __EXECUTION_DIR__ = Path.cwd() @@ -10,6 +20,11 @@ "__EXECUTION_DIR__", "FileType", "determine_file_type", + "file_exist", + "filter_cpp_files", + "get_dirs_in_dir", "get_files_in_dirs", - "get_staged_files" + "get_staged_files", + "open_file", + "write_text", ] diff --git a/src/devops/files/config.py b/src/devops/files/config.py deleted file mode 100644 index 30323a0..0000000 --- a/src/devops/files/config.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Module for configurations settings for mstd file operations.""" - -__DEFAULT_ENCODING__ = "utf-8" diff --git a/src/devops/files/files.py b/src/devops/files/files.py index d00c985..cb53400 100644 --- a/src/devops/files/files.py +++ b/src/devops/files/files.py @@ -4,21 +4,39 @@ import subprocess import typing +from contextlib import contextmanager from enum import Enum from pathlib import Path +from devops import __GLOBAL_CONFIG__ + if typing.TYPE_CHECKING: from collections.abc import Iterable -class MSTDFileNotFoundError(Exception): +class DevOpsFileNotFoundError(Exception): """Exception raised when a specified file is not found.""" - def __init__(self, filepath: Path) -> None: - """Initialize the exception with the missing file path.""" - super().__init__(f"File not found: {filepath}") + def __init__(self, filepath: Path, message: str | None = None) -> None: + """Initialize the exception with the missing file path. + + Parameters + ---------- + filepath: Path + The path to the file that was not found. + message: str | None + Optional custom message for the exception. + """ self.filepath = filepath + default_message = f"File not found: {filepath}" + if message is not None: + final_message = f"{default_message} - {message}" + else: + final_message = default_message + + super().__init__(final_message) + class FileType(Enum): """Enumeration of file types for mstd checks.""" @@ -45,6 +63,11 @@ def cpp_types(cls) -> set[FileType]: """Get a set of all CPP related file types.""" return {FileType.CPPHeader, FileType.CPPSource} + @classmethod + def is_cpp_type(cls, file_type: FileType) -> bool: + """Check if the given file type is a C++ related type.""" + return file_type in cls.cpp_types() + def determine_file_type(filename: str | Path) -> FileType: """Determine the file type based on the filename extension. @@ -76,7 +99,7 @@ def get_files_in_dirs( paths: Iterable[Path], exclude_dirs: list[str] | None = None, exclude_files: list[str] | None = None, - max_recursion: int = 20 + max_recursion: int = 20, ) -> list[Path]: """Get all files in the specified directories. @@ -113,10 +136,7 @@ def get_files_in_dirs( if path.is_dir() and path.name not in exclude_dirs: all_files.extend( get_files_in_dirs( - path.iterdir(), - exclude_dirs, - exclude_files, - max_recursion - 1 + path.iterdir(), exclude_dirs, exclude_files, max_recursion - 1 ) ) elif path.is_file() and path.name not in exclude_files: @@ -125,6 +145,24 @@ def get_files_in_dirs( return all_files +def get_dirs_in_dir(directory: str | Path = ".") -> list[Path]: + """Get all directories in the specified directory. + + Parameters + ---------- + directory: str | Path + The path to the directory to search. Defaults to the current directory. + + Returns + ------- + list[Path]: + List of directory paths found in the specified directory. + + """ + dir_path = Path(directory) + return [path for path in dir_path.iterdir() if path.is_dir()] + + def get_staged_files() -> list[Path]: """Get the list of staged files in the git repository. @@ -143,8 +181,124 @@ def get_staged_files() -> list[Path]: ["git", "diff", "--name-only", "--cached"], capture_output=True, text=True, - check=True + check=True, ) files = result.stdout.strip().split("\n") return [Path(file) for file in files if file] + + +def file_exist( + file: str | Path, *, throwing: bool = False, throw_msg: str | None = None +) -> bool: + """Check if a file exists at the given path. + + Parameters + ---------- + file: str | Path + The path to the file to check. + throwing: bool + Whether to raise an exception if the file does not exist. + throw_msg: str | None + Custom message to use when raising an exception if the file does not exist. + + Returns + ------- + bool + True if the file exists, False otherwise. + + Raises + ------ + DevOpsFileNotFoundError + If the file does not exist and throwing is True. + + """ + filepath = Path(file) + if filepath.is_file(): + return True + + if throwing: + raise DevOpsFileNotFoundError(filepath, message=throw_msg) + + return False + + +@contextmanager +def open_file( + file: str | Path, mode: str = "r" +) -> typing.Generator[typing.IO[str], None, None]: + """Read the content of a file. + + Parameters + ---------- + file: str | Path + The path to the file to read. + + Yields + ------ + typing.IO[str] + The opened file object. + + Raises + ------ + DevOpsFileNotFoundError + If the file does not exist. + + """ + file = Path(file) + + if "r" in mode: + file_exist( + file, + throwing=True, + throw_msg="Cannot read file as it does not exist.", + ) + + encoding = __GLOBAL_CONFIG__.file.encoding + file = file.open(mode, encoding=encoding) + + try: + yield file + finally: + file.close() + + +def write_text(file: str | Path, content: str) -> None: + """Write text content to a file. + + Parameters + ---------- + file: str | Path + The path to the file to write. + content: str + The text content to write to the file. + + """ + file = Path(file) + + encoding = __GLOBAL_CONFIG__.file.encoding + + file.write_text(content, encoding=encoding) + + +def filter_cpp_files(files: list[Path]) -> list[Path]: + """Filter and return only C++ related files from the given list. + + Parameters + ---------- + files: list[Path] + The list of file paths to filter. + + Returns + ------- + list[Path] + The filtered list containing only C++ related files. + + """ + cpp_files = [] + for file in files: + file_type = determine_file_type(file) + if FileType.is_cpp_type(file_type): + cpp_files.append(file) + + return cpp_files diff --git a/src/devops/files/update_changelog.py b/src/devops/files/update_changelog.py index 39547d4..3938943 100644 --- a/src/devops/files/update_changelog.py +++ b/src/devops/files/update_changelog.py @@ -4,21 +4,19 @@ from datetime import UTC, datetime from pathlib import Path -from devops.files.files import MSTDFileNotFoundError -from devops.github import get_github_repo - -from .config import __DEFAULT_ENCODING__ +from devops.config import Constants +from devops.files import open_file, write_text __CHANGELOG_PATH__ = Path("CHANGELOG.md") __CHANGELOG_INSERTION_MARKER__ = "" -class MSTDChangelogError(Exception): +class DevOpsChangelogError(Exception): """Base class for changelog related errors.""" def __init__(self, message: str) -> None: """Initialize the exception with a message.""" - super().__init__(f"MSTDChangelogError: {message}") + super().__init__(f"DevOpsChangelogError: {message}") self.message = message @@ -34,19 +32,16 @@ def update_changelog(version: str, changelog_path: Path = __CHANGELOG_PATH__) -> Raises ------ - MSTDFileNotFoundError - If the changelog file does not exist. - MSTDChangelogError + DevOpsFileNotFoundError + If the changelog file does not exist. Happens inside open_file. + DevOpsChangelogError If the "## Next Release" marker is not found in the changelog. """ - if not changelog_path.is_file(): - raise MSTDFileNotFoundError(changelog_path) - - with changelog_path.open("r", encoding=__DEFAULT_ENCODING__) as f: + with open_file(changelog_path, mode="r") as f: content = f.readlines() - repo = get_github_repo() + repo = Constants.github.github_default_owner_url today = datetime.now(tz=UTC).date().isoformat() new_entry = f"## [{version}]({repo}/releases/tag/{version}) - {today}" @@ -72,7 +67,6 @@ def update_changelog(version: str, changelog_path: Path = __CHANGELOG_PATH__) -> if not marker_moved: msg = "Could not find '## Next Release' in CHANGELOG.md" - raise MSTDChangelogError(msg) + raise DevOpsChangelogError(msg) - changelog_path.write_text("".join(updated) + "\n", - encoding=__DEFAULT_ENCODING__) + write_text(changelog_path, "".join(updated) + "\n") diff --git a/src/devops/git/__init__.py b/src/devops/git/__init__.py new file mode 100644 index 0000000..c8ae280 --- /dev/null +++ b/src/devops/git/__init__.py @@ -0,0 +1,5 @@ +"""Module for Git-related constants and functions.""" + +from .tag import GitTagError, get_latest_tag + +__all__ = ["GitTagError", "get_latest_tag"] diff --git a/src/devops/git/tag.py b/src/devops/git/tag.py new file mode 100644 index 0000000..85f880b --- /dev/null +++ b/src/devops/git/tag.py @@ -0,0 +1,201 @@ +"""Module for Git tag-related constants and functions.""" + +from __future__ import annotations + +import subprocess +import typing +from dataclasses import dataclass + +from devops import __GLOBAL_CONFIG__ + +if typing.TYPE_CHECKING: + from devops.config import GitConfig + + +# TODO(97gamjak): centralize exception handling +# https://github.com/97gamjak/devops/issues/24 +class GitTagError(Exception): + """Exception raised for Git tag-related errors in devops checks.""" + + def __init__(self, message: str) -> None: + """Initialize the exception with a message.""" + super().__init__(f"GitTagError: {message}") + self.message = message + + +@dataclass(frozen=True, order=True) +class GitTag: + """Class representing a Git tag.""" + + major: int + minor: int + patch: int + prefix: str + + def __str__(self) -> str: + """Return the string representation of the Git tag. + + Returns + ------- + str + The string representation of the Git tag + in the format 'v..'. + + """ + return f"{self.prefix}{self.major}.{self.minor}.{self.patch}" + + def increase_major(self) -> GitTag: + """Increase the major version by 1 and reset minor and patch to 0. + + Returns + ------- + GitTag + A new GitTag instance with the increased major version. + + """ + return GitTag(self.major + 1, 0, 0, self.prefix) + + def increase_minor(self) -> GitTag: + """Increase the minor version by 1 and reset patch to 0. + + Returns + ------- + GitTag + A new GitTag instance with the increased minor version. + + """ + return GitTag(self.major, self.minor + 1, 0, self.prefix) + + def increase_patch(self) -> GitTag: + """Increase the patch version by 1. + + Returns + ------- + GitTag + A new GitTag instance with the increased patch version. + + """ + return GitTag(self.major, self.minor, self.patch + 1, self.prefix) + + @staticmethod + def from_string(tag: str, config: GitConfig = __GLOBAL_CONFIG__.git) -> GitTag: + """Create a GitTag instance from a string. + + Parameters + ---------- + tag: str + The Git tag string in the format '..'. + config: GitConfig + The Git configuration containing the expected prefix. + + Returns + ------- + GitTag + The GitTag instance created from the string. + + Raises + ------ + GitTagError + If the tag string does not start with the expected prefix. + If the tag string is not in the correct format. + + """ + original_tag = tag + + prefix = config.tag_prefix + + if not tag.startswith(prefix): + msg = ( + f"Tag '{original_tag}' does not start " + f"with the expected prefix '{prefix}'" + ) + raise GitTagError(msg) + + tag = tag.removeprefix(prefix) + parts = tag.split(".") + + # TODO(97gamjak): implement support for different version schemes + # https://97gamjak.atlassian.net/browse/DEV-49 + if len(parts) != 3: # noqa: PLR2004 this will be removed and cleaned up with further naming schemes + msg = f"Invalid tag format: {original_tag}" + raise GitTagError(msg) + + try: + major, minor, patch = map(int, parts) + except ValueError as exc: + msg = f"Invalid numeric components in tag: {original_tag}" + raise GitTagError(msg) from exc + return GitTag(major, minor, patch, prefix) + + +def get_all_tags(config: GitConfig = __GLOBAL_CONFIG__.git) -> list[GitTag]: + """Get all Git tags in the repository. + + Parameters + ---------- + config: GitConfig + The Git configuration containing the expected prefix + and empty tag list allowance. + + Returns + ------- + list[GitTag] + A list of all Git tags. + + Raises + ------ + GitTagError + If there is an error retrieving the Git tags and + empty_tag_list_allowed is False. + + """ + empty_tag_list_allowed = config.empty_tag_list_allowed + + try: + tags_output = subprocess.check_output( + ["git", "tag", "--list"], + stderr=subprocess.DEVNULL, + text=True, + shell=False, + ).strip() + except subprocess.CalledProcessError as e: + msg = ( + "Error retrieving Git tags. " + "Failed to execute git command. Command: 'git tag --list'" + ) + raise GitTagError(msg) from e + + if not empty_tag_list_allowed and not tags_output: + msg = "Failed to retrieve Git tags." + raise GitTagError(msg) + + tags = [] + for tag_str in tags_output.splitlines(): + tag = GitTag.from_string(tag_str, config=config) + tags.append(tag) + + return tags + + +def get_latest_tag( + config: GitConfig = __GLOBAL_CONFIG__.git, +) -> GitTag: + """Get the latest Git tag in the repository. + + Parameters + ---------- + config: GitConfig + The Git configuration containing the expected prefix + and empty tag list allowance. + + Returns + ------- + GitTag + The latest Git tag. If no tags exist, returns GitTag(0, 0, 0, prefix). + + """ + tags = get_all_tags(config=config) + if not tags: + return GitTag(0, 0, 0, config.tag_prefix) + + return max(tags) diff --git a/src/devops/github.py b/src/devops/github.py deleted file mode 100644 index 2bcf588..0000000 --- a/src/devops/github.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Module for GitHub-related constants and functions.""" - -import os - -__GITHUB_REPO__ = "https://github.com" -__MSTD_GITHUB_REPO__ = "https://github.com/97gamjak/mstd" -__MSTD_ISSUES_PAGE__ = f"{__MSTD_GITHUB_REPO__}/issues" - - -class MSTDGithubError(Exception): - """Exception raised for GitHub-related errors in mstd checks.""" - - def __init__(self, message: str) -> None: - """Initialize the exception with a message.""" - super().__init__(f"MSTDGithubError: {message}") - self.message = message - - -def get_github_repo() -> str: - """Get the current GitHub repository URL. - - Returns - ------- - str - The GitHub repository URL. - - """ - repo = os.getenv("GITHUB_REPOSITORY", "repo/owner") - # TODO(97gamjak): centralize env logic if needed elsewhere - # https://github.com/97gamjak/mstd/issues/26 - - return f"{__GITHUB_REPO__}/{repo}" diff --git a/src/devops/logger/__init__.py b/src/devops/logger/__init__.py index f3b21b2..90a3710 100644 --- a/src/devops/logger/__init__.py +++ b/src/devops/logger/__init__.py @@ -1,5 +1,5 @@ -"""Top level package for logger in mstd checks.""" +"""Top level package for logger in devops.""" -from .logger import cpp_check_logger, utils_logger +from .logger import config_logger, cpp_check_logger, utils_logger -__all__ = ["cpp_check_logger", "utils_logger"] +__all__ = ["config_logger", "cpp_check_logger", "utils_logger"] diff --git a/src/devops/logger/logger.py b/src/devops/logger/logger.py index e4d985b..0320e86 100644 --- a/src/devops/logger/logger.py +++ b/src/devops/logger/logger.py @@ -1,21 +1,23 @@ """Module initializing logger for mstd checks.""" + import logging import os -__DEBUG_MSTD_CHECKS__ = os.getenv("DEBUG_MSTD_CHECKS", "0") -__DEBUG_MSTD_UTILS__ = os.getenv("DEBUG_MSTD_UTILS", "0") +__DEBUG_DEVOPS_CHECKS__ = os.getenv("DEBUG_DEVOPS_CHECKS", "0") +__DEBUG_DEVOPS_UTILS__ = os.getenv("DEBUG_DEVOPS_UTILS", "0") # TODO(97gamjak): centralize env logic if needed elsewhere -# https://github.com/97gamjak/mstd/issues/26 -if int(__DEBUG_MSTD_CHECKS__) > 0: +# https://97gamjak.atlassian.net/browse/DEV-26 +if int(__DEBUG_DEVOPS_CHECKS__) > 0: logging.basicConfig(level=logging.DEBUG) else: logging.basicConfig(level=logging.INFO) -cpp_check_logger = logging.getLogger("mstd_cpp_checks") -utils_logger = logging.getLogger("mstd_utils") +cpp_check_logger = logging.getLogger("devops_cpp_checks") +utils_logger = logging.getLogger("devops_utils") +config_logger = logging.getLogger("devops_config") -if int(__DEBUG_MSTD_UTILS__) > 0: +if int(__DEBUG_DEVOPS_UTILS__) > 0: utils_logger.setLevel(logging.DEBUG) else: utils_logger.setLevel(logging.INFO) diff --git a/src/devops/rules/__init__.py b/src/devops/rules/__init__.py index 046e47e..b9f3702 100644 --- a/src/devops/rules/__init__.py +++ b/src/devops/rules/__init__.py @@ -1,11 +1,15 @@ """Top level package for rules in mstd checks.""" + from .result_type import ResultType, ResultTypeEnum from .rules import ( Rule, RuleInputType, RuleType, filter_cpp_rules, + filter_file_rules, filter_line_rules, + is_file_rule, + is_line_rule, ) __all__ = ["ResultType", "ResultTypeEnum"] @@ -14,5 +18,8 @@ "RuleInputType", "RuleType", "filter_cpp_rules", + "filter_file_rules", "filter_line_rules", + "is_file_rule", + "is_line_rule", ] diff --git a/src/devops/rules/result_type.py b/src/devops/rules/result_type.py index c448ba3..88fc486 100644 --- a/src/devops/rules/result_type.py +++ b/src/devops/rules/result_type.py @@ -16,11 +16,7 @@ class ResultTypeEnum(Enum): class ResultType: """Class representing the result of a mstd type check.""" - def __init__( - self, - value: ResultTypeEnum, - description: str | None = None - ) -> None: + def __init__(self, value: ResultTypeEnum, description: str | None = None) -> None: """Initialize ResultType with a value and optional description.""" self.value = value self.description = description diff --git a/src/devops/rules/rules.py b/src/devops/rules/rules.py index 654655e..b3b8276 100644 --- a/src/devops/rules/rules.py +++ b/src/devops/rules/rules.py @@ -1,4 +1,5 @@ """Module defining rules for mstd checks.""" + from __future__ import annotations import typing @@ -107,7 +108,7 @@ def __init__( rule_type: RuleType = RuleType.GENERAL, rule_input_type: RuleInputType = RuleInputType.NONE, file_types: set[FileType] | None = None, - description: str | None = None + description: str | None = None, ) -> None: """Initialize Rule with a name and optional description.""" self.name = name @@ -178,3 +179,54 @@ def filter_line_rules(rules: list[Rule]) -> list[Rule]: """ return [rule for rule in rules if rule.rule_input_type == RuleInputType.LINE] + + +def filter_file_rules(rules: list[Rule]) -> list[Rule]: + """Filter and return only file related rules. + + Parameters + ---------- + rules: list[Rule] + The list of rules to filter. + + Returns + ------- + list[Rule] + A list of file related rules. + + """ + return [rule for rule in rules if rule.rule_input_type == RuleInputType.FILE] + + +def is_line_rule(rule: Rule) -> bool: + """Check if a rule is a line-based rule. + + Parameters + ---------- + rule: Rule + The rule to check. + + Returns + ------- + bool + True if the rule is line-based, False otherwise. + + """ + return rule.rule_input_type == RuleInputType.LINE + + +def is_file_rule(rule: Rule) -> bool: + """Check if a rule is a file-based rule. + + Parameters + ---------- + rule: Rule + The rule to check. + + Returns + ------- + bool + True if the rule is file-based, False otherwise. + + """ + return rule.rule_input_type == RuleInputType.FILE diff --git a/src/devops/scripts/add_license_header.py b/src/devops/scripts/add_license_header.py new file mode 100644 index 0000000..e77f141 --- /dev/null +++ b/src/devops/scripts/add_license_header.py @@ -0,0 +1,58 @@ +"""Script to add license headers to C++ files.""" + +from pathlib import Path + +import typer + +from devops.cpp import add_license_header as add_license_header_func +from devops.files import filter_cpp_files, get_dirs_in_dir, get_files_in_dirs +from devops.utils import mstd_print + +add_single_header = typer.Typer() +add_multiple_headers = typer.Typer() + + +@add_single_header.command() +def add_license_header( + file_path: str, license_header_path: str, *, dry_run: bool = False +) -> None: + """Add a license header to a specified file. + + Parameters + ---------- + file_path: str + The path to the file where the license header should be added. + license_header_path: str + The path to the license header file. + dry_run: bool + If True, only print the file that would be modified without making changes. + + """ + added = add_license_header_func(file_path, license_header_path, dry_run=dry_run) + if added: + mstd_print(f"✅ License header added to {file_path}") + + +@add_multiple_headers.command() +def add_license_header_to_files( + license_header_path: str, dirs: list[str] | None = None, *, dry_run: bool = False +) -> None: + """Add a license header to all valid files in a specified directory. + + Parameters + ---------- + license_header_path: str + The path to the license header file. + dirs: list[str] | None + List of directory paths to search for files. + If None, uses the current directory. + dry_run: bool + If True, only print the files that would be modified without making changes. + """ + dirs = get_dirs_in_dir() if dirs is None else [Path(d) for d in dirs] + + files = get_files_in_dirs(dirs) + files = filter_cpp_files(files) + + for file in files: + add_license_header(file, license_header_path, dry_run=dry_run) diff --git a/src/devops/scripts/cpp_checks.py b/src/devops/scripts/cpp_checks.py index 220e6bd..69ab3d6 100644 --- a/src/devops/scripts/cpp_checks.py +++ b/src/devops/scripts/cpp_checks.py @@ -1,97 +1,32 @@ """Module defining C++ check rules.""" -import sys -from pathlib import Path +from dataclasses import replace -from devops.cpp import cpp_rules -from devops.files import ( - __EXECUTION_DIR__, - determine_file_type, - get_files_in_dirs, - get_staged_files, -) -from devops.logger import cpp_check_logger -from devops.rules import ResultType, ResultTypeEnum, Rule, filter_line_rules +import typer -__CPP_DIRS__ = ["include", "test"] -__OTHER_DIRS__ = ["scripts"] -__EXCLUDE_DIRS__ = ["__pycache__", ".ruff_cache"] -__EXCLUDE_FILES__ = [".gitignore"] +from devops import __GLOBAL_CONFIG__ +from devops.cpp import build_cpp_rules, run_cpp_checks -__DIRS__ = __CPP_DIRS__ + __OTHER_DIRS__ +app = typer.Typer(help="C++ code quality checks.") -def run_line_checks(rules: list[Rule], file: Path) -> list[ResultType]: - """Run line-based C++ checks on a given file. +@app.command() +def cpp_checks(license_header: str | None = None) -> None: + """Run C++ code quality checks. Parameters ---------- - rules: list[Rule] - The list of rules to apply. - file: Path - The file to check. - - Returns - ------- - list[ResultType] - The list of results from the checks. - - """ - results = [] - file_type = determine_file_type(file) - - with Path(file).open("r", encoding="utf-8") as f: - line_rules = filter_line_rules(rules) - for line in f: - for rule in line_rules: - if file_type not in rule.file_types: - continue - - results.append(rule.apply(line)) - - return results - - -def run_checks(rules: list[Rule]) -> None: - """Run C++ checks based on the provided rules. - - Returns immediately after encountering the first file with errors. - - Parameters - ---------- - rules: list[Rule] - The list of rules to apply. + license_header: str | None + The path to the license header file. If None, uses the global configuration. """ - if "full" in sys.argv: - cpp_check_logger.info("Running full checks...") - cpp_check_logger.debug(f"Checking directories: {__DIRS__}") - dirs = [__EXECUTION_DIR__ / dir_name for dir_name in __DIRS__] - files = get_files_in_dirs(dirs, __EXCLUDE_DIRS__, __EXCLUDE_FILES__) - else: - cpp_check_logger.info("Running checks on staged files...") - files = get_staged_files() - - if not files: - cpp_check_logger.warning("No files to check.") - return - - for filename in files: - cpp_check_logger.debug(f"Checking file: {filename}") - file_results = run_line_checks(rules, filename) - if any(result.value != ResultTypeEnum.Ok for result in file_results): - filtered_results = [ - res - for res in file_results - if res.value != ResultTypeEnum.Ok - ] - for res in filtered_results: - cpp_check_logger.error( - f"Line check result in {filename}: {res.description}" - ) - return + if license_header is None: + license_header = __GLOBAL_CONFIG__.cpp.license_header + config = replace( + __GLOBAL_CONFIG__.cpp, + license_header=license_header, + ) -def main() -> None: - """Run C++ checks.""" - run_checks(cpp_rules) + rules = build_cpp_rules(config) + run_cpp_checks(rules, config) diff --git a/src/devops/scripts/cpp_files.py b/src/devops/scripts/cpp_files.py new file mode 100644 index 0000000..e0b8276 --- /dev/null +++ b/src/devops/scripts/cpp_files.py @@ -0,0 +1,32 @@ +"""Script to filter and print buggy C++ files in specified directories.""" + +from pathlib import Path + +import typer + +from devops.cpp import filter_buggy_cpp +from devops.files import filter_cpp_files, get_dirs_in_dir, get_files_in_dirs +from devops.utils import mstd_print + +app = typer.Typer() + + +@app.command() +def filter_buggy_cpp_files(dirs: list[str] | None = None) -> None: + """Filter and print buggy C++ files in specified directories. + + Parameters + ---------- + dirs: list[str] | None + List of directory paths to search for files. + If None, uses the current directory. + """ + dirs = get_dirs_in_dir() if dirs is None else [Path(d) for d in dirs] + + files = get_files_in_dirs(dirs) + files = filter_cpp_files(files) + + non_buggy_files = filter_buggy_cpp(files) + + for file in non_buggy_files: + mstd_print(file) diff --git a/src/devops/scripts/generate_toml_template.py b/src/devops/scripts/generate_toml_template.py new file mode 100644 index 0000000..dc64357 --- /dev/null +++ b/src/devops/scripts/generate_toml_template.py @@ -0,0 +1,13 @@ +"""Script to generate a default TOML configuration template file.""" + +import typer + +from devops import __GLOBAL_CONFIG__ + +app = typer.Typer(help="TOML template generator.") + + +@app.command() +def generate_toml_template() -> None: + """Generate a default TOML configuration template file.""" + __GLOBAL_CONFIG__.write_default() diff --git a/src/devops/scripts/get_latest_git_tag.py b/src/devops/scripts/get_latest_git_tag.py new file mode 100644 index 0000000..7c0dd85 --- /dev/null +++ b/src/devops/scripts/get_latest_git_tag.py @@ -0,0 +1,129 @@ +"""CLI utilities for retrieving and incrementing Git tags.""" + +from __future__ import annotations + +import sys +import typing +from dataclasses import replace + +import typer + +from devops import __GLOBAL_CONFIG__ +from devops.git import GitTagError, get_latest_tag +from devops.utils import mstd_print + +if typing.TYPE_CHECKING: + from devops.git import GitTag + +latest_tag = typer.Typer() +increase_tag = typer.Typer() + + +def _get_latest_tag( + prefix: str | None = None, *, empty_tag_list_allowed: bool | None = None +) -> GitTag: + """Retrieve the latest git tag. + + Parameters + ---------- + prefix: str | None + The expected prefix of the Git tags. If None, uses the default prefix. + empty_tag_list_allowed: bool | None + Whether to allow an empty tag list without raising an error. If None, + uses the default setting. + + Returns + ------- + GitTag + The latest Git tag. + + """ + config = __GLOBAL_CONFIG__.git + + if prefix is None: + prefix = config.tag_prefix + if empty_tag_list_allowed is None: + empty_tag_list_allowed = config.empty_tag_list_allowed + + config = replace( + config, + tag_prefix=prefix, + empty_tag_list_allowed=empty_tag_list_allowed, + ) + + return get_latest_tag(config=config) + + +@latest_tag.command() +def get_latest_tag_script( + prefix: str | None = None, *, empty_tag_list_allowed: bool | None = None +) -> None: + """Retrieve and print the latest git tag. + + Parameters + ---------- + prefix: str | None + The expected prefix of the Git tags. If None, uses the default prefix. + empty_tag_list_allowed: bool | None + Whether to allow an empty tag list without raising an error. If None, + uses the default setting. + + """ + try: + tag = _get_latest_tag( + prefix=prefix, + empty_tag_list_allowed=empty_tag_list_allowed, + ) + mstd_print(str(tag)) + except GitTagError as e: + mstd_print(f"❌ Error retrieving latest git tag: {e}") + sys.exit(1) + + +@increase_tag.command() +def increase_latest_tag( + prefix: str | None = None, + *, + empty_tag_list_allowed: bool | None = None, + major: bool = False, + minor: bool = False, + patch: bool = False, +) -> None: + """Increase the latest git tag by major, minor, or patch. + + Parameters + ---------- + prefix: str | None + The expected prefix of the Git tags. If None, uses the default prefix. + empty_tag_list_allowed: bool | None + Whether to allow an empty tag list without raising an error. If None, + uses the default setting. + major: bool + Whether to increase the major version. + minor: bool + Whether to increase the minor version. + patch: bool + Whether to increase the patch version. + + """ + if sum([major, minor, patch]) != 1: + mstd_print("❌ Please specify exactly one of --major, --minor, or --patch.") + sys.exit(1) + + try: + tag = _get_latest_tag( + prefix=prefix, + empty_tag_list_allowed=empty_tag_list_allowed, + ) + except GitTagError as e: + mstd_print(f"❌ Error retrieving latest git tag: {e}") + sys.exit(1) + + if major: + new_tag = tag.increase_major() + elif minor: + new_tag = tag.increase_minor() + else: # patch + new_tag = tag.increase_patch() + + mstd_print(str(new_tag)) diff --git a/src/devops/scripts/update_changelog.py b/src/devops/scripts/update_changelog.py index 8e257b7..93fe779 100644 --- a/src/devops/scripts/update_changelog.py +++ b/src/devops/scripts/update_changelog.py @@ -5,7 +5,7 @@ import typer from devops.files import update_changelog -from devops.files.update_changelog import MSTDChangelogError +from devops.files.update_changelog import DevOpsChangelogError from devops.utils import mstd_print app = typer.Typer() @@ -24,7 +24,7 @@ def main(version: str) -> None: try: update_changelog.update_changelog(version) mstd_print(f"✅ CHANGELOG.md updated for version {version}") - except MSTDChangelogError as e: + except DevOpsChangelogError as e: mstd_print(f"❌ Error updating changelog: {e}") sys.exit(1) diff --git a/src/devops/utils/utils.py b/src/devops/utils/utils.py index 8493d05..5e94372 100644 --- a/src/devops/utils/utils.py +++ b/src/devops/utils/utils.py @@ -4,7 +4,7 @@ import typing -from devops.github import __MSTD_ISSUES_PAGE__ +from devops.config import Constants from devops.logger import utils_logger from devops.rules import ResultType, ResultTypeEnum @@ -32,9 +32,7 @@ def find_indices(list_to_search: list[Any], element: Any) -> list[int]: def check_key_sequence_ordered( - key_sequence: str, - line: str, - key_delimiter: str = " " + key_sequence: str, line: str, key_delimiter: str = " " ) -> ResultType: """Check if keys in key_sequence appear in order on the given line. @@ -60,15 +58,14 @@ def check_key_sequence_ordered( if set(key_sequence).intersection(set(line_elements)) != set(key_sequence): return ResultType(ResultTypeEnum.Ok) - indices = [ - find_indices(line_elements, key) - for key in key_sequence - ] + indices = [find_indices(line_elements, key) for key in key_sequence] if len(indices) != len(key_sequence): msg = f"Expected {len(key_sequence)} indices, but got {len(indices)}. " msg += "This indicates an internal error. " - msg += f"Please report this issue at {__MSTD_ISSUES_PAGE__}." + msg += ( + f"Please report this issue at {Constants.github.github_devops_issues_url}." + ) raise ValueError(msg) found_indices = 0 @@ -85,12 +82,11 @@ def check_key_sequence_ordered( "All keys from key_sequence %s are present " "in line %s and ordered correctly.", key_sequence, - line + line, ) return ResultType(ResultTypeEnum.Ok) return ResultType( ResultTypeEnum.Error, - f"key_sequence {key_sequence} not ordered correctly " - f"in line {line}." + f"key_sequence {key_sequence} not ordered correctly in line {line}.", ) diff --git a/tests/config/__init__.py b/tests/config/__init__.py new file mode 100644 index 0000000..78399e3 --- /dev/null +++ b/tests/config/__init__.py @@ -0,0 +1 @@ +"""Tests for devops.config module.""" diff --git a/tests/config/test_config.py b/tests/config/test_config.py new file mode 100644 index 0000000..5e41bac --- /dev/null +++ b/tests/config/test_config.py @@ -0,0 +1,284 @@ +"""Tests for devops.config.config module.""" + +from pathlib import Path + +import pytest + +from devops.config.base import ConfigError, get_str_enum +from devops.config.config import ( + ExcludeConfig, + GlobalConfig, + parse_config, + read_config, +) +from devops.enums import LogLevel + + +def test_parse_config_with_exclude_configuration() -> None: + """Test parsing exclude configurations from raw config.""" + raw_config = { + "exclude": { + "buggy_cpp_macros": ["MACRO1", "MACRO2", "MACRO3"], + } + } + result = parse_config(raw_config) + + assert isinstance(result, GlobalConfig) + assert isinstance(result.exclude, ExcludeConfig) + assert result.exclude.buggy_cpp_macros == ["MACRO1", "MACRO2", "MACRO3"] + + +def test_parse_config_with_empty_exclude_list() -> None: + """Test parsing exclude configurations with empty list.""" + raw_config = { + "exclude": { + "buggy_cpp_macros": [], + } + } + result = parse_config(raw_config) + + assert isinstance(result, GlobalConfig) + assert result.exclude.buggy_cpp_macros == [] + + +def test_parse_config_missing_exclude_section() -> None: + """Test handling missing 'exclude' key - should return defaults.""" + raw_config: dict[str, dict[str, list[str]]] = {} + result = parse_config(raw_config) + + assert isinstance(result, GlobalConfig) + assert result.exclude.buggy_cpp_macros == [] + + +def test_parse_config_missing_buggy_cpp_macros_key() -> None: + """Test missing 'buggy_cpp_macros' key returns empty list.""" + raw_config = {"exclude": {}} + result = parse_config(raw_config) + + assert isinstance(result, GlobalConfig) + assert result.exclude.buggy_cpp_macros == [] + + +def test_parse_config_exclude_not_dict() -> None: + """Test handling invalid data type for 'exclude' - should raise ConfigError.""" + raw_config = {"exclude": "not_a_dict"} + + with pytest.raises(ConfigError) as exc_info: + parse_config(raw_config) + + assert "Expected dict for key 'exclude'" in str(exc_info.value) + assert "got str" in str(exc_info.value) + + +def test_parse_config_exclude_is_list() -> None: + """Test invalid data type when exclude is a list raises error.""" + raw_config = {"exclude": ["item1", "item2"]} + + with pytest.raises(ConfigError) as exc_info: + parse_config(raw_config) + + assert "Expected dict for key 'exclude'" in str(exc_info.value) + assert "got list" in str(exc_info.value) + + +def test_parse_config_buggy_cpp_macros_not_list() -> None: + """Test invalid type for buggy_cpp_macros raises error.""" + raw_config = { + "exclude": { + "buggy_cpp_macros": "not_a_list", + } + } + + with pytest.raises(ConfigError) as exc_info: + parse_config(raw_config) + + assert "Expected list of strings for key" in str(exc_info.value) + assert "buggy_cpp_macros" in str(exc_info.value) + + +def test_parse_config_buggy_cpp_macros_list_with_non_strings() -> None: + """Test handling list with non-string elements - should raise ConfigError.""" + raw_config = { + "exclude": { + "buggy_cpp_macros": ["MACRO1", 42, "MACRO3"], + } + } + + with pytest.raises(ConfigError) as exc_info: + parse_config(raw_config) + + assert "Expected list of strings for key" in str(exc_info.value) + assert "buggy_cpp_macros" in str(exc_info.value) + + +def test_parse_config_buggy_cpp_macros_is_dict() -> None: + """Test handling invalid data type when buggy_cpp_macros is a dict.""" + raw_config = { + "exclude": { + "buggy_cpp_macros": {"key": "value"}, + } + } + + with pytest.raises(ConfigError) as exc_info: + parse_config(raw_config) + + assert "Expected list of strings for key" in str(exc_info.value) + assert "buggy_cpp_macros" in str(exc_info.value) + + +def test_read_config_with_none_path() -> None: + """Test default configuration behavior when path is None.""" + result = read_config(None) + + assert isinstance(result, GlobalConfig) + assert isinstance(result.exclude, ExcludeConfig) + assert result.exclude.buggy_cpp_macros == [] + + +def test_read_config_with_valid_toml_file(tmp_path: Path) -> None: + """Test reading a valid TOML configuration file.""" + toml_content = """ +[exclude] +buggy_cpp_macros = ["MACRO_A", "MACRO_B"] +""" + + toml_file = tmp_path / "config.toml" + toml_file.write_text(toml_content) + + result = read_config(toml_file) + + assert isinstance(result, GlobalConfig) + assert result.exclude.buggy_cpp_macros == ["MACRO_A", "MACRO_B"] + + +def test_read_config_with_path_object(tmp_path: Path) -> None: + """Test reading configuration file using Path object.""" + toml_content = """ +[exclude] +buggy_cpp_macros = ["TEST_MACRO"] +""" + + toml_file = tmp_path / "config.toml" + toml_file.write_text(toml_content) + + result = read_config(toml_file) + + assert isinstance(result, GlobalConfig) + assert result.exclude.buggy_cpp_macros == ["TEST_MACRO"] + + +def test_read_config_with_empty_toml_file(tmp_path: Path) -> None: + """Test reading an empty TOML file - should return defaults.""" + toml_content = "" + + toml_file = tmp_path / "config.toml" + toml_file.write_text(toml_content) + + result = read_config(toml_file) + + assert isinstance(result, GlobalConfig) + assert result.exclude.buggy_cpp_macros == [] + + +def test_read_config_with_partial_toml_file(tmp_path: Path) -> None: + """Test reading TOML file with exclude section but no macros.""" + toml_content = """ +[exclude] +""" + + toml_file = tmp_path / "config.toml" + toml_file.write_text(toml_content) + + result = read_config(toml_file) + + assert isinstance(result, GlobalConfig) + assert result.exclude.buggy_cpp_macros == [] + + +def test_get_str_enum_with_valid_value() -> None: + """Test get_str_enum with valid enum value.""" + mapping = {"level": "INFO"} + + result = get_str_enum(mapping, "level", LogLevel) + + assert result == LogLevel.INFO + + +def test_get_str_enum_with_valid_case_insensitive_value() -> None: + """Test get_str_enum with case-insensitive enum value.""" + mapping = {"level": "info"} + + result = get_str_enum(mapping, "level", LogLevel) + + assert result == LogLevel.INFO + + +def test_get_str_enum_with_missing_key() -> None: + """Test get_str_enum with missing key returns None.""" + mapping = {"other_key": "value"} + + result = get_str_enum(mapping, "level", LogLevel) + + assert result is None + + +def test_get_str_enum_with_default_value() -> None: + """Test get_str_enum with default value when key is missing.""" + mapping = {"other_key": "value"} + + result = get_str_enum(mapping, "level", LogLevel, default="DEBUG") + + assert result == LogLevel.DEBUG + + +def test_get_str_enum_with_invalid_enum_value() -> None: + """Test get_str_enum with invalid enum value raises ConfigError.""" + mapping = {"level": "INVALID"} + + with pytest.raises(ConfigError) as exc_info: + get_str_enum(mapping, "level", LogLevel) + + assert "Invalid value for key 'level': INVALID" in str(exc_info.value) + assert "expected one of" in str(exc_info.value) + + +def test_get_str_enum_with_non_string_value() -> None: + """Test get_str_enum with non-string value raises ConfigError.""" + mapping = {"level": 123} + + with pytest.raises(ConfigError) as exc_info: + get_str_enum(mapping, "level", LogLevel) + + assert "Expected str for key 'level'" in str(exc_info.value) + assert "got int" in str(exc_info.value) + + +def test_get_str_enum_with_none_value() -> None: + """Test get_str_enum with None value returns None.""" + mapping = {"level": None} + + result = get_str_enum(mapping, "level", LogLevel) + + assert result is None + + +def test_get_str_enum_with_list_value() -> None: + """Test get_str_enum with list value raises ConfigError.""" + mapping = {"level": ["INFO", "DEBUG"]} + + with pytest.raises(ConfigError) as exc_info: + get_str_enum(mapping, "level", LogLevel) + + assert "Expected str for key 'level'" in str(exc_info.value) + assert "got list" in str(exc_info.value) + + +def test_get_str_enum_with_dict_value() -> None: + """Test get_str_enum with dict value raises ConfigError.""" + mapping = {"level": {"nested": "INFO"}} + + with pytest.raises(ConfigError) as exc_info: + get_str_enum(mapping, "level", LogLevel) + + assert "Expected str for key 'level'" in str(exc_info.value) + assert "got dict" in str(exc_info.value) diff --git a/tests/config/test_config_cpp.py b/tests/config/test_config_cpp.py new file mode 100644 index 0000000..a068f44 --- /dev/null +++ b/tests/config/test_config_cpp.py @@ -0,0 +1,146 @@ +"""Tests for C++ configuration parsing.""" + +from devops.config.config_cpp import CppConfig, parse_cpp_config + + +class TestCppConfigDefaults: + """Tests for CppConfig default values.""" + + def test_cpp_config_default_values(self) -> None: + """Test CppConfig has correct default values.""" + config = CppConfig() + assert config.style_checks is True + assert config.license_header_check is True + assert config.license_header is None + assert config.check_only_staged_files is False + + +class TestParseCppConfig: + """Tests for parse_cpp_config function.""" + + def test_parse_cpp_config_with_all_values(self) -> None: + """Test parsing C++ config with all values specified.""" + raw_config = { + "cpp": { + "style_checks": False, + "license_header_check": True, + "license_header": "/path/to/header.txt", + "check_only_staged_files": True, + } + } + config = parse_cpp_config(raw_config) + + assert isinstance(config, CppConfig) + assert config.style_checks is False + assert config.license_header_check is True + assert config.license_header == "/path/to/header.txt" + assert config.check_only_staged_files is True + + def test_parse_cpp_config_with_defaults(self) -> None: + """Test parsing C++ config with missing keys uses defaults.""" + raw_config = {"cpp": {}} + config = parse_cpp_config(raw_config) + + assert isinstance(config, CppConfig) + assert config.style_checks is True + assert config.license_header_check is True + assert config.license_header is None + assert config.check_only_staged_files is False + + def test_parse_cpp_config_missing_cpp_section(self) -> None: + """Test parsing config without 'cpp' section returns defaults.""" + raw_config: dict = {} + config = parse_cpp_config(raw_config) + + assert isinstance(config, CppConfig) + assert config.style_checks is True + assert config.license_header_check is True + assert config.license_header is None + assert config.check_only_staged_files is False + + def test_parse_cpp_config_partial_values(self) -> None: + """Test parsing C++ config with some values specified.""" + raw_config = { + "cpp": { + "style_checks": False, + "license_header": "/path/to/license.txt", + } + } + config = parse_cpp_config(raw_config) + + assert config.style_checks is False + assert config.license_header_check is True # default + assert config.license_header == "/path/to/license.txt" + assert config.check_only_staged_files is False # default + + def test_parse_cpp_config_style_checks_false(self) -> None: + """Test parsing C++ config with style_checks disabled.""" + raw_config = {"cpp": {"style_checks": False}} + config = parse_cpp_config(raw_config) + + assert config.style_checks is False + + def test_parse_cpp_config_license_header_check_false(self) -> None: + """Test parsing C++ config with license_header_check disabled.""" + raw_config = {"cpp": {"license_header_check": False}} + config = parse_cpp_config(raw_config) + + assert config.license_header_check is False + + def test_parse_cpp_config_check_only_staged_files_true(self) -> None: + """Test parsing C++ config with check_only_staged_files enabled.""" + raw_config = {"cpp": {"check_only_staged_files": True}} + config = parse_cpp_config(raw_config) + + assert config.check_only_staged_files is True + + def test_parse_cpp_config_none_license_header(self) -> None: + """Test parsing C++ config with explicit None for license_header.""" + raw_config = {"cpp": {"license_header": None}} + config = parse_cpp_config(raw_config) + + assert config.license_header is None + + def test_parse_cpp_config_all_booleans_true(self) -> None: + """Test parsing C++ config with all boolean values as True.""" + raw_config = { + "cpp": { + "style_checks": True, + "license_header_check": True, + "check_only_staged_files": True, + } + } + config = parse_cpp_config(raw_config) + + assert config.style_checks is True + assert config.license_header_check is True + assert config.check_only_staged_files is True + + def test_parse_cpp_config_all_booleans_false(self) -> None: + """Test parsing C++ config with all boolean values as False.""" + raw_config = { + "cpp": { + "style_checks": False, + "license_header_check": False, + "check_only_staged_files": False, + } + } + config = parse_cpp_config(raw_config) + + assert config.style_checks is False + assert config.license_header_check is False + assert config.check_only_staged_files is False + + def test_parse_cpp_config_license_header_path(self) -> None: + """Test parsing C++ config with various license header paths.""" + test_paths = [ + "/absolute/path/to/header.txt", + "relative/path/to/header.txt", + "../parent/header.txt", + "./current/header.txt", + ] + + for path in test_paths: + raw_config = {"cpp": {"license_header": path}} + config = parse_cpp_config(raw_config) + assert config.license_header == path diff --git a/tests/config/test_logging_config.py b/tests/config/test_logging_config.py new file mode 100644 index 0000000..01b339b --- /dev/null +++ b/tests/config/test_logging_config.py @@ -0,0 +1,272 @@ +"""Tests for devops.config.logging_config module.""" + +import logging +from unittest.mock import MagicMock, patch + +import pytest + +from devops.config.base import ConfigError +from devops.config.config_logging import ( + LoggingConfig, + parse_logging_config, + set_logging_levels, +) +from devops.enums import LogLevel + + +def test_logging_config_dataclass_defaults() -> None: + """Test LoggingConfig dataclass default values.""" + config = LoggingConfig() + + assert config.global_level == LogLevel.INFO + assert config.utils_level == LogLevel.INFO + assert config.config_level == LogLevel.INFO + assert config.cpp_level == LogLevel.INFO + + +def test_logging_config_dataclass_with_custom_values() -> None: + """Test LoggingConfig dataclass with custom values.""" + config = LoggingConfig( + global_level=LogLevel.DEBUG, + utils_level=LogLevel.WARNING, + config_level=LogLevel.ERROR, + cpp_level=LogLevel.CRITICAL, + ) + + assert config.global_level == LogLevel.DEBUG + assert config.utils_level == LogLevel.WARNING + assert config.config_level == LogLevel.ERROR + assert config.cpp_level == LogLevel.CRITICAL + + +def test_parse_logging_config_with_all_levels() -> None: + """Test parse_logging_config with all logging levels specified.""" + raw_config = { + "logging": { + "global_level": "DEBUG", + "utils_level": "INFO", + "config_level": "WARNING", + "cpp_level": "ERROR", + } + } + + with patch("devops.config.config_logging.set_logging_levels"): + config = parse_logging_config(raw_config) + + assert config.global_level == LogLevel.DEBUG + assert config.utils_level == LogLevel.INFO + assert config.config_level == LogLevel.WARNING + assert config.cpp_level == LogLevel.ERROR + + +def test_parse_logging_config_with_case_insensitive_levels() -> None: + """Test parse_logging_config with case-insensitive level values.""" + raw_config = { + "logging": { + "global_level": "debug", + "utils_level": "Info", + "config_level": "WARNING", + "cpp_level": "error", + } + } + + with patch("devops.config.config_logging.set_logging_levels"): + config = parse_logging_config(raw_config) + + assert config.global_level == LogLevel.DEBUG + assert config.utils_level == LogLevel.INFO + assert config.config_level == LogLevel.WARNING + assert config.cpp_level == LogLevel.ERROR + + +def test_parse_logging_config_with_missing_logging_section() -> None: + """Test parse_logging_config with missing logging section uses defaults.""" + raw_config: dict = {} + + with ( + patch("devops.config.config_logging.set_logging_levels"), + patch("logging.root.level", logging.INFO), + patch("devops.config.config_logging.utils_logger.level", logging.INFO), + patch("devops.config.config_logging.config_logger.level", logging.INFO), + patch( + "devops.config.config_logging.cpp_check_logger.level", + logging.INFO, + ), + ): + config = parse_logging_config(raw_config) + + assert config.global_level == LogLevel.INFO + assert config.utils_level == LogLevel.INFO + assert config.config_level == LogLevel.INFO + assert config.cpp_level == LogLevel.INFO + + +def test_parse_logging_config_with_partial_levels() -> None: + """Test parse_logging_config with only some levels specified.""" + raw_config = { + "logging": { + "global_level": "DEBUG", + "cpp_level": "ERROR", + } + } + + with ( + patch("devops.config.config_logging.set_logging_levels"), + patch("devops.config.config_logging.utils_logger.level", logging.WARNING), + patch("devops.config.config_logging.config_logger.level", logging.ERROR), + ): + config = parse_logging_config(raw_config) + + assert config.global_level == LogLevel.DEBUG + assert config.utils_level == LogLevel.WARNING + assert config.config_level == LogLevel.ERROR + assert config.cpp_level == LogLevel.ERROR + + +def test_parse_logging_config_with_invalid_level() -> None: + """Test parse_logging_config with invalid logging level raises ConfigError.""" + raw_config = { + "logging": { + "global_level": "INVALID_LEVEL", + } + } + + with pytest.raises(ConfigError) as exc_info: + parse_logging_config(raw_config) + + assert "Invalid value for key 'global_level'" in str(exc_info.value) + assert "INVALID_LEVEL" in str(exc_info.value) + + +def test_parse_logging_config_with_non_string_level() -> None: + """Test parse_logging_config with non-string level value raises ConfigError.""" + raw_config = { + "logging": { + "global_level": 10, + } + } + + with pytest.raises(ConfigError) as exc_info: + parse_logging_config(raw_config) + + assert "Expected str for key 'global_level'" in str(exc_info.value) + assert "got int" in str(exc_info.value) + + +def test_parse_logging_config_with_logging_section_not_dict() -> None: + """Test parse_logging_config with logging section not a dict raises ConfigError.""" + raw_config = {"logging": "not_a_dict"} + + with pytest.raises(ConfigError) as exc_info: + parse_logging_config(raw_config) + + assert "Expected dict for key 'logging'" in str(exc_info.value) + assert "got str" in str(exc_info.value) + + +def test_parse_logging_config_calls_set_logging_levels() -> None: + """Test parse_logging_config calls set_logging_levels as side effect.""" + raw_config = { + "logging": { + "global_level": "DEBUG", + } + } + + with ( + patch("devops.config.config_logging.set_logging_levels") as mock_set_levels, + patch("devops.config.config_logging.utils_logger.level", logging.INFO), + patch("devops.config.config_logging.config_logger.level", logging.INFO), + patch( + "devops.config.config_logging.cpp_check_logger.level", + logging.INFO, + ), + ): + parse_logging_config(raw_config) + + mock_set_levels.assert_called_once() + call_args = mock_set_levels.call_args.args[0] + assert call_args.global_level == LogLevel.DEBUG + + +def test_set_logging_levels_sets_global_level() -> None: + """Test set_logging_levels sets the root logger level.""" + config = LoggingConfig(global_level=LogLevel.DEBUG) + + with ( + patch("logging.getLogger") as mock_get_logger, + patch("devops.config.config_logging.utils_logger"), + patch("devops.config.config_logging.config_logger"), + patch("devops.config.config_logging.cpp_check_logger"), + ): + mock_logger = MagicMock() + mock_get_logger.return_value = mock_logger + + set_logging_levels(config) + + mock_logger.setLevel.assert_called_once_with(logging.DEBUG) + + +def test_set_logging_levels_sets_all_logger_levels() -> None: + """Test set_logging_levels sets all logger levels correctly.""" + config = LoggingConfig( + global_level=LogLevel.DEBUG, + utils_level=LogLevel.INFO, + config_level=LogLevel.WARNING, + cpp_level=LogLevel.ERROR, + ) + + with ( + patch("logging.getLogger") as mock_get_logger, + patch("devops.config.config_logging.utils_logger") as mock_utils, + patch("devops.config.config_logging.config_logger") as mock_config, + patch("devops.config.config_logging.cpp_check_logger") as mock_cpp, + ): + mock_root = MagicMock() + mock_get_logger.return_value = mock_root + + set_logging_levels(config) + + mock_root.setLevel.assert_called_once_with(logging.DEBUG) + mock_utils.setLevel.assert_called_once_with(logging.INFO) + mock_config.setLevel.assert_called_once_with(logging.WARNING) + mock_cpp.setLevel.assert_called_once_with(logging.ERROR) + + +def test_parse_logging_config_with_none_level() -> None: + """Test parse_logging_config with NONE level.""" + raw_config = { + "logging": { + "global_level": "NONE", + } + } + + with ( + patch("devops.config.config_logging.set_logging_levels"), + patch("devops.config.config_logging.utils_logger.level", logging.INFO), + patch("devops.config.config_logging.config_logger.level", logging.INFO), + patch( + "devops.config.config_logging.cpp_check_logger.level", + logging.INFO, + ), + ): + config = parse_logging_config(raw_config) + + assert config.global_level == LogLevel.NONE + + +def test_set_logging_levels_with_none_level() -> None: + """Test set_logging_levels correctly converts NONE to NOTSET.""" + config = LoggingConfig(global_level=LogLevel.NONE) + + with ( + patch("logging.getLogger") as mock_get_logger, + patch("devops.config.config_logging.utils_logger"), + patch("devops.config.config_logging.config_logger"), + patch("devops.config.config_logging.cpp_check_logger"), + ): + mock_logger = MagicMock() + mock_get_logger.return_value = mock_logger + + set_logging_levels(config) + + mock_logger.setLevel.assert_called_once_with(logging.NOTSET) diff --git a/tests/config/test_toml.py b/tests/config/test_toml.py new file mode 100644 index 0000000..cac973d --- /dev/null +++ b/tests/config/test_toml.py @@ -0,0 +1,170 @@ +"""Tests for devops.config.toml module.""" + +from __future__ import annotations + +import typing + +import pytest + +from devops.config.toml import TomlError, load_toml + +if typing.TYPE_CHECKING: + from pathlib import Path + + +def test_load_toml_success(tmp_path: Path) -> None: + """Test successful TOML loading with valid file.""" + # Create a valid TOML file + toml_file = tmp_path / "test_config.toml" + toml_content = """ +[database] +server = "192.168.1.1" +ports = [8000, 8001, 8002] +connection_max = 5000 +enabled = true + +[servers] +alpha = "10.0.0.1" +beta = "10.0.0.2" +""" + toml_file.write_text(toml_content) + + # Load the TOML file + result = load_toml(toml_file) + + # Assert the structure is correct + assert isinstance(result, dict) + assert "database" in result + assert "servers" in result + assert result["database"]["server"] == "192.168.1.1" + assert result["database"]["ports"] == [8000, 8001, 8002] + assert result["database"]["connection_max"] == 5000 + assert result["database"]["enabled"] is True + assert result["servers"]["alpha"] == "10.0.0.1" + assert result["servers"]["beta"] == "10.0.0.2" + + +def test_load_toml_string_path(tmp_path: Path) -> None: + """Test that load_toml converts string path to Path object.""" + # Create a valid TOML file + toml_file = tmp_path / "test_config.toml" + toml_content = """ +[app] +name = "TestApp" +version = "1.0.0" +""" + toml_file.write_text(toml_content) + + # Load using string path instead of Path object + result = load_toml(str(toml_file)) + + # Assert the structure is correct + assert isinstance(result, dict) + assert "app" in result + assert result["app"]["name"] == "TestApp" + assert result["app"]["version"] == "1.0.0" + + +def test_load_toml_file_not_found(tmp_path: Path) -> None: + """Test handling of FileNotFoundError for missing files.""" + non_existent_file = tmp_path / "non_existent_file.toml" + + # Assert that TomlError is raised + with pytest.raises(TomlError) as exc_info: + load_toml(non_existent_file) + + # Verify the error message contains the file path + assert "non_existent_file.toml" in str(exc_info.value) + assert "Error loading TOML file" in str(exc_info.value) + + +def test_load_toml_invalid_syntax(tmp_path: Path) -> None: + """Test handling of TOMLDecodeError for invalid TOML syntax.""" + # Create a file with invalid TOML syntax + toml_file = tmp_path / "invalid.toml" + invalid_content = """ +[database +server = "192.168.1.1" +""" + toml_file.write_text(invalid_content) + + # Assert that TomlError is raised + with pytest.raises(TomlError) as exc_info: + load_toml(toml_file) + + # Verify the error message contains information about the parsing error + assert "invalid.toml" in str(exc_info.value) + assert "Error loading TOML file" in str(exc_info.value) + + +def test_load_toml_empty_file(tmp_path: Path) -> None: + """Test loading an empty TOML file returns empty dict.""" + # Create an empty TOML file + toml_file = tmp_path / "empty.toml" + toml_file.write_text("") + + # Load the empty file + result = load_toml(toml_file) + + # Assert an empty dict is returned + assert isinstance(result, dict) + assert len(result) == 0 + + +def test_load_toml_nested_structure(tmp_path: Path) -> None: + """Test loading TOML with nested structure.""" + # Create a TOML file with nested tables + toml_file = tmp_path / "nested.toml" + toml_content = """ +[section1] +key1 = "value1" + +[section1.subsection] +key2 = "value2" + +[[section2.array]] +name = "item1" + +[[section2.array]] +name = "item2" +""" + toml_file.write_text(toml_content) + + # Load the TOML file + result = load_toml(toml_file) + + # Assert the nested structure is correct + assert isinstance(result, dict) + assert result["section1"]["key1"] == "value1" + assert result["section1"]["subsection"]["key2"] == "value2" + assert len(result["section2"]["array"]) == 2 + assert result["section2"]["array"][0]["name"] == "item1" + assert result["section2"]["array"][1]["name"] == "item2" + + +def test_toml_error_exception() -> None: + """Test that TomlError exception works correctly.""" + error_message = "Test error message" + error = TomlError(error_message) + + # Check the message is formatted correctly + assert str(error) == f"TomlError: {error_message}" + assert error.message == error_message + + +def test_load_toml_path_object(tmp_path: Path) -> None: + """Test that load_toml works with Path object directly.""" + # Create a valid TOML file + toml_file = tmp_path / "path_test.toml" + toml_content = """ +[config] +enabled = true +""" + toml_file.write_text(toml_content) + + # Load using Path object + result = load_toml(toml_file) + + # Assert it loaded correctly + assert isinstance(result, dict) + assert result["config"]["enabled"] is True diff --git a/tests/cpp/test_build_rules.py b/tests/cpp/test_build_rules.py new file mode 100644 index 0000000..abec44b --- /dev/null +++ b/tests/cpp/test_build_rules.py @@ -0,0 +1,212 @@ +"""Tests for C++ rule building functionality.""" + +from __future__ import annotations + +import typing + +from devops.config.config_cpp import CppConfig +from devops.cpp.build_rules import build_cpp_rules +from devops.rules import Rule, RuleType + +if typing.TYPE_CHECKING: + from pathlib import Path + + from _pytest.logging import LogCaptureFixture + + +class TestBuildCppRules: + """Tests for build_cpp_rules function.""" + + def setup_method(self) -> None: + """Reset rule counters before each test.""" + Rule.cpp_style_rule_counter = 0 + Rule.general_rule_counter = 0 + + def test_build_cpp_rules_with_defaults(self) -> None: + """Test building rules with default configuration.""" + config = CppConfig() + rules = build_cpp_rules(config) + + # Default config has style_checks=True, license_header_check=True + # but license_header=None, so only style rules should be included + assert isinstance(rules, list) + # Should contain style rules but no license header rule + assert len(rules) > 0 + assert all(isinstance(rule, Rule) for rule in rules) + # All should be style rules + assert all(rule.rule_type == RuleType.CPP_STYLE for rule in rules) + + def test_build_cpp_rules_with_style_checks_disabled(self) -> None: + """Test building rules with style checks disabled.""" + config = CppConfig(style_checks=False, license_header_check=False) + rules = build_cpp_rules(config) + + # With both checks disabled, should return empty list + assert rules == [] + + def test_build_cpp_rules_with_only_style_checks(self) -> None: + """Test building rules with only style checks enabled.""" + config = CppConfig(style_checks=True, license_header_check=False) + rules = build_cpp_rules(config) + + # Should contain only style rules + assert len(rules) > 0 + assert all(rule.rule_type == RuleType.CPP_STYLE for rule in rules) + # None should be license header checks + assert all(rule.name != "License Header Check" for rule in rules) + + def test_build_cpp_rules_with_license_header_check_enabled( + self, tmp_path: Path + ) -> None: + """Test building rules with license header check enabled. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + # Create a license header file + header_file = tmp_path / "header.txt" + header_file.write_text("// Copyright 2024\n") + + config = CppConfig( + style_checks=True, + license_header_check=True, + license_header=str(header_file), + ) + rules = build_cpp_rules(config) + + # Should contain style rules + license header rule + assert len(rules) > 0 + # Check that license header rule is present + license_rules = [rule for rule in rules if rule.name == "License Header Check"] + assert len(license_rules) == 1 + + def test_build_cpp_rules_license_header_without_path( + self, caplog: LogCaptureFixture + ) -> None: + """Test building rules with license header check but no path. + + Parameters + ---------- + caplog: LogCaptureFixture + Pytest fixture for capturing log messages. + + """ + config = CppConfig( + style_checks=False, + license_header_check=True, + license_header=None, # No header file provided + ) + rules = build_cpp_rules(config) + + # Should not contain license header rule + assert rules == [] + # Should log a warning + assert any( + "License header check is enabled" in record.message + for record in caplog.records + ) + assert any( + "no license header text is provided" in record.message + for record in caplog.records + ) + + def test_build_cpp_rules_with_only_license_header(self, tmp_path: Path) -> None: + """Test building rules with only license header check. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + # Create a license header file + header_file = tmp_path / "header.txt" + header_file.write_text("// Header\n") + + config = CppConfig( + style_checks=False, + license_header_check=True, + license_header=str(header_file), + ) + rules = build_cpp_rules(config) + + # Should contain only the license header rule + assert len(rules) == 1 + assert rules[0].name == "License Header Check" + + def test_build_cpp_rules_all_checks_enabled(self, tmp_path: Path) -> None: + """Test building rules with all checks enabled. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + # Create a license header file + header_file = tmp_path / "header.txt" + header_file.write_text("// Header\n") + + config = CppConfig( + style_checks=True, + license_header_check=True, + license_header=str(header_file), + ) + rules = build_cpp_rules(config) + + # Should contain both style rules and license header rule + assert len(rules) > 1 + license_rules = [rule for rule in rules if rule.name == "License Header Check"] + assert len(license_rules) == 1 + + def test_build_cpp_rules_check_only_staged_files_has_no_effect( + self, tmp_path: Path + ) -> None: + """Test that check_only_staged_files doesn't affect rule building. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + header_file = tmp_path / "header.txt" + header_file.write_text("// Header\n") + + config1 = CppConfig( + style_checks=True, + license_header_check=True, + license_header=str(header_file), + check_only_staged_files=False, + ) + config2 = CppConfig( + style_checks=True, + license_header_check=True, + license_header=str(header_file), + check_only_staged_files=True, + ) + + rules1 = build_cpp_rules(config1) + rules2 = build_cpp_rules(config2) + + # Should produce the same rules regardless of check_only_staged_files + assert len(rules1) == len(rules2) + + def test_build_cpp_rules_returns_list_of_rules(self) -> None: + """Test that build_cpp_rules returns a list of Rule objects.""" + config = CppConfig(style_checks=True, license_header_check=False) + rules = build_cpp_rules(config) + + assert isinstance(rules, list) + assert all(isinstance(rule, Rule) for rule in rules) + + def test_build_cpp_rules_style_rules_are_cpp_type(self) -> None: + """Test that all style rules have CPP_STYLE type.""" + config = CppConfig(style_checks=True, license_header_check=False) + rules = build_cpp_rules(config) + + # All returned rules should be CPP_STYLE type + assert all(rule.rule_type == RuleType.CPP_STYLE for rule in rules) diff --git a/tests/cpp/test_checks.py b/tests/cpp/test_checks.py new file mode 100644 index 0000000..54035b9 --- /dev/null +++ b/tests/cpp/test_checks.py @@ -0,0 +1,341 @@ +"""Tests for run_cpp_checks function.""" + +from __future__ import annotations + +import logging +import typing +from unittest.mock import patch + +import pytest + +from devops.config.config_cpp import CppConfig +from devops.cpp.checks import run_cpp_checks +from devops.rules import ResultType, ResultTypeEnum, Rule, RuleInputType, RuleType + +if typing.TYPE_CHECKING: + from pathlib import Path + + from _pytest.logging import LogCaptureFixture + + +class TestRunCppChecks: + """Tests for run_cpp_checks function.""" + + def setup_method(self) -> None: + """Reset rule counters before each test.""" + Rule.cpp_style_rule_counter = 0 + Rule.general_rule_counter = 0 + + @pytest.mark.usefixtures("tmp_path") + def test_run_cpp_checks_with_no_files(self, caplog: LogCaptureFixture) -> None: + """Test run_cpp_checks when no files are found. + + Parameters + ---------- + caplog: LogCaptureFixture + Pytest fixture for capturing log messages. + + """ + # Create a rule + rule = Rule( + name="test_rule", + func=lambda _line: ResultType(ResultTypeEnum.Ok), + rule_type=RuleType.CPP_STYLE, + rule_input_type=RuleInputType.LINE, + ) + + # Mock get_staged_files to return empty list + with patch("devops.cpp.checks.get_staged_files", return_value=[]): + config = CppConfig(check_only_staged_files=True) + run_cpp_checks([rule], config) + + # Should log warning about no files + assert any("No files to check" in record.message for record in caplog.records) + + def test_run_cpp_checks_with_staged_files(self, tmp_path: Path) -> None: + """Test run_cpp_checks with staged files configuration. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + # Create test files + test_file = tmp_path / "test.cpp" + test_file.write_text("int main() {}\n") + + # Create a passing rule + rule = Rule( + name="test_rule", + func=lambda _line: ResultType(ResultTypeEnum.Ok), + rule_type=RuleType.CPP_STYLE, + rule_input_type=RuleInputType.LINE, + ) + + # Mock get_staged_files + with patch("devops.cpp.checks.get_staged_files", return_value=[test_file]): + config = CppConfig(check_only_staged_files=True) + run_cpp_checks([rule], config) + # Should complete without error + + def test_run_cpp_checks_with_full_check( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test run_cpp_checks with full file check configuration. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + monkeypatch + Pytest fixture for monkey patching. + + """ + # Change to tmp directory + monkeypatch.chdir(tmp_path) + + # Create test files in subdirectory + src_dir = tmp_path / "src" + src_dir.mkdir() + test_file = src_dir / "test.cpp" + test_file.write_text("int main() {}\n") + + # Create a passing rule + rule = Rule( + name="test_rule", + func=lambda _line: ResultType(ResultTypeEnum.Ok), + rule_type=RuleType.CPP_STYLE, + rule_input_type=RuleInputType.LINE, + ) + + config = CppConfig(check_only_staged_files=False) + run_cpp_checks([rule], config) + # Should complete without error + + def test_run_cpp_checks_stops_on_error( + self, tmp_path: Path, caplog: LogCaptureFixture + ) -> None: + """Test run_cpp_checks stops after first file with errors. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + caplog: LogCaptureFixture + Pytest fixture for capturing log messages. + + """ + # Create test files + file1 = tmp_path / "file1.cpp" + file1.write_text("bad code\n") + file2 = tmp_path / "file2.cpp" + file2.write_text("more bad code\n") + + # Create a failing rule + rule = Rule( + name="failing_rule", + func=lambda _line: ResultType(ResultTypeEnum.Error, "Code error"), + rule_type=RuleType.CPP_STYLE, + rule_input_type=RuleInputType.LINE, + ) + + # Mock get_staged_files to return both files + with patch("devops.cpp.checks.get_staged_files", return_value=[file1, file2]): + config = CppConfig(check_only_staged_files=True) + run_cpp_checks([rule], config) + + # Should log error for first file and stop + error_logs = [ + record for record in caplog.records if record.levelname == "ERROR" + ] + assert len(error_logs) > 0 + assert any("Code error" in record.message for record in error_logs) + + def test_run_cpp_checks_skips_non_cpp_files(self, tmp_path: Path) -> None: + """Test run_cpp_checks skips non-C++ files. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + # Create mixed files + cpp_file = tmp_path / "test.cpp" + cpp_file.write_text("int main() {}\n") + txt_file = tmp_path / "readme.txt" + txt_file.write_text("documentation\n") + + call_count = [0] + + def counting_func(_line: str) -> ResultType: + call_count[0] += 1 + return ResultType(ResultTypeEnum.Ok) + + rule = Rule( + name="counting_rule", + func=counting_func, + rule_type=RuleType.CPP_STYLE, + rule_input_type=RuleInputType.LINE, + ) + + # Mock get_staged_files to return both files + with patch( + "devops.cpp.checks.get_staged_files", return_value=[cpp_file, txt_file] + ): + config = CppConfig(check_only_staged_files=True) + run_cpp_checks([rule], config) + + # Should only process cpp file + assert call_count[0] == 1 # Only one line in cpp_file + + def test_run_cpp_checks_with_file_rules(self, tmp_path: Path) -> None: + """Test run_cpp_checks with file-based rules. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + test_file = tmp_path / "test.cpp" + test_file.write_text("int main() {}\n") + + # Create a file rule + rule = Rule( + name="file_rule", + func=lambda _content: ResultType(ResultTypeEnum.Ok), + rule_type=RuleType.CPP_STYLE, + rule_input_type=RuleInputType.FILE, + ) + + with patch("devops.cpp.checks.get_staged_files", return_value=[test_file]): + config = CppConfig(check_only_staged_files=True) + run_cpp_checks([rule], config) + # Should complete without error + + def test_run_cpp_checks_with_mixed_rules(self, tmp_path: Path) -> None: + """Test run_cpp_checks with both line and file rules. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + test_file = tmp_path / "test.cpp" + test_file.write_text("int main() {}\n") + + # Create line and file rules + line_rule = Rule( + name="line_rule", + func=lambda _line: ResultType(ResultTypeEnum.Ok), + rule_type=RuleType.CPP_STYLE, + rule_input_type=RuleInputType.LINE, + ) + file_rule = Rule( + name="file_rule", + func=lambda _content: ResultType(ResultTypeEnum.Ok), + rule_type=RuleType.CPP_STYLE, + rule_input_type=RuleInputType.FILE, + ) + + with patch("devops.cpp.checks.get_staged_files", return_value=[test_file]): + config = CppConfig(check_only_staged_files=True) + run_cpp_checks([line_rule, file_rule], config) + # Should complete without error + + def test_run_cpp_checks_logs_checked_file( + self, tmp_path: Path, caplog: LogCaptureFixture + ) -> None: + """Test run_cpp_checks logs the files being checked. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + caplog: LogCaptureFixture + Pytest fixture for capturing log messages. + + """ + test_file = tmp_path / "test.cpp" + test_file.write_text("int main() {}\n") + + rule = Rule( + name="test_rule", + func=lambda _line: ResultType(ResultTypeEnum.Ok), + rule_type=RuleType.CPP_STYLE, + rule_input_type=RuleInputType.LINE, + ) + + with ( + caplog.at_level(logging.DEBUG), + patch("devops.cpp.checks.get_staged_files", return_value=[test_file]), + ): + config = CppConfig(check_only_staged_files=True) + run_cpp_checks([rule], config) + + # Should log the file being checked (at debug level) + assert any( + "Checking file" in record.message and str(test_file) in record.message + for record in caplog.records + ) + + def test_run_cpp_checks_with_empty_rules_list(self, tmp_path: Path) -> None: + """Test run_cpp_checks with empty rules list. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + test_file = tmp_path / "test.cpp" + test_file.write_text("int main() {}\n") + + with patch("devops.cpp.checks.get_staged_files", return_value=[test_file]): + config = CppConfig(check_only_staged_files=True) + run_cpp_checks([], config) + # Should complete without error + + def test_run_cpp_checks_only_logs_errors( + self, tmp_path: Path, caplog: LogCaptureFixture + ) -> None: + """Test run_cpp_checks only logs non-Ok results. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + caplog: LogCaptureFixture + Pytest fixture for capturing log messages. + + """ + test_file = tmp_path / "test.cpp" + test_file.write_text("line1\nline2\n") + + # Create a rule that fails on specific line + def selective_rule(line: str) -> ResultType: + if "line1" in line: + return ResultType(ResultTypeEnum.Error, "Error on line1") + return ResultType(ResultTypeEnum.Ok) + + rule = Rule( + name="selective_rule", + func=selective_rule, + rule_type=RuleType.CPP_STYLE, + rule_input_type=RuleInputType.LINE, + ) + + with patch("devops.cpp.checks.get_staged_files", return_value=[test_file]): + config = CppConfig(check_only_staged_files=True) + run_cpp_checks([rule], config) + + # Should only log the error, not the Ok result + error_logs = [ + record + for record in caplog.records + if record.levelname == "ERROR" and "Error on line1" in record.message + ] + assert len(error_logs) == 1 diff --git a/tests/cpp/test_license_header.py b/tests/cpp/test_license_header.py new file mode 100644 index 0000000..1876445 --- /dev/null +++ b/tests/cpp/test_license_header.py @@ -0,0 +1,281 @@ +"""Tests for license header checking functionality.""" + +from __future__ import annotations + +import typing + +from devops.cpp.license_header import CheckLicenseHeader, check_license_header +from devops.rules import ResultTypeEnum, RuleInputType, RuleType + +if typing.TYPE_CHECKING: + from pathlib import Path + + +class TestCheckLicenseHeader: + """Tests for check_license_header function.""" + + def test_check_license_header_present(self, tmp_path: Path) -> None: + """Test check when license header is present. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + # Create a license header file + header_file = tmp_path / "header.txt" + header_file.write_text("// Copyright 2024\n// All rights reserved\n") + + # Create file content that starts with the header + file_content = "// Copyright 2024\n// All rights reserved\n\nint main() {}\n" + + result = check_license_header(file_content, header_file) + assert result.value == ResultTypeEnum.Ok + + def test_check_license_header_missing(self, tmp_path: Path) -> None: + """Test check when license header is missing. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + # Create a license header file + header_file = tmp_path / "header.txt" + header_file.write_text("// Copyright 2024\n// All rights reserved\n") + + # Create file content without the header + file_content = "int main() {}\n" + + result = check_license_header(file_content, header_file) + assert result.value == ResultTypeEnum.Error + assert result.description == "Missing or incorrect license header." + + def test_check_license_header_incorrect(self, tmp_path: Path) -> None: + """Test check when license header is incorrect. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + # Create a license header file + header_file = tmp_path / "header.txt" + header_file.write_text("// Copyright 2024\n// All rights reserved\n") + + # Create file content with different header + file_content = "// Copyright 2023\n// Some rights reserved\n\nint main() {}\n" + + result = check_license_header(file_content, header_file) + assert result.value == ResultTypeEnum.Error + assert result.description == "Missing or incorrect license header." + + def test_check_license_header_partial_match(self, tmp_path: Path) -> None: + """Test check when only part of the header matches. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + # Create a license header file + header_file = tmp_path / "header.txt" + header_file.write_text("// Copyright 2024\n// All rights reserved\n") + + # Create file content with only part of the header + file_content = "// Copyright 2024\n\nint main() {}\n" + + result = check_license_header(file_content, header_file) + assert result.value == ResultTypeEnum.Error + + def test_check_license_header_empty_file(self, tmp_path: Path) -> None: + """Test check on empty file content. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + # Create a license header file + header_file = tmp_path / "header.txt" + header_file.write_text("// Copyright 2024\n") + + # Empty file content + file_content = "" + + result = check_license_header(file_content, header_file) + assert result.value == ResultTypeEnum.Error + + def test_check_license_header_empty_header(self, tmp_path: Path) -> None: + """Test check with empty license header. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + # Create an empty license header file + header_file = tmp_path / "header.txt" + header_file.write_text("") + + # Any file content should pass with empty header + file_content = "int main() {}\n" + + result = check_license_header(file_content, header_file) + assert result.value == ResultTypeEnum.Ok + + def test_check_license_header_with_str_path(self, tmp_path: Path) -> None: + """Test check using string path instead of Path object. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + # Create a license header file + header_file = tmp_path / "header.txt" + header_file.write_text("// Header\n") + + # Create file content with the header + file_content = "// Header\nint main() {}\n" + + result = check_license_header(file_content, str(header_file)) + assert result.value == ResultTypeEnum.Ok + + def test_check_license_header_multiline(self, tmp_path: Path) -> None: + """Test check with multiline license header. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + # Create a multiline license header + header_text = """/* + * Copyright (c) 2024 Example Corp + * All rights reserved. + * This source code is licensed under the MIT license. + */ +""" + header_file = tmp_path / "header.txt" + header_file.write_text(header_text) + + # Create file content with the header + file_content = header_text + "\n#include \n\nint main() {}\n" + + result = check_license_header(file_content, header_file) + assert result.value == ResultTypeEnum.Ok + + def test_check_license_header_with_leading_whitespace(self, tmp_path: Path) -> None: + """Test check with license header containing leading whitespace. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + # Create a license header with specific whitespace + header_text = " // Copyright 2024\n // All rights reserved\n" + header_file = tmp_path / "header.txt" + header_file.write_text(header_text) + + # File content must match exactly including whitespace + file_content = ( + " // Copyright 2024\n // All rights reserved\n\nint main() {}\n" + ) + + result = check_license_header(file_content, header_file) + assert result.value == ResultTypeEnum.Ok + + +class TestCheckLicenseHeaderClass: + """Tests for CheckLicenseHeader rule class.""" + + def test_check_license_header_class_creation(self, tmp_path: Path) -> None: + """Test CheckLicenseHeader class instantiation. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + header_file = tmp_path / "header.txt" + header_file.write_text("// Header\n") + + rule = CheckLicenseHeader(str(header_file)) + + assert rule.name == "License Header Check" + assert rule.rule_type == RuleType.CPP_STYLE + assert rule.rule_input_type == RuleInputType.FILE + + desc = "Ensure that the file contains the required license header." + assert rule.description == desc + + def test_check_license_header_class_apply_with_valid_content( + self, tmp_path: Path + ) -> None: + """Test applying CheckLicenseHeader rule with valid content. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + header_file = tmp_path / "header.txt" + header_file.write_text("// Header\n") + + rule = CheckLicenseHeader(str(header_file)) + file_content = "// Header\nint main() {}\n" + + result = rule.apply((file_content,)) + assert result.value == ResultTypeEnum.Ok + + def test_check_license_header_class_apply_with_invalid_content( + self, tmp_path: Path + ) -> None: + """Test applying CheckLicenseHeader rule with invalid content. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + header_file = tmp_path / "header.txt" + header_file.write_text("// Header\n") + + rule = CheckLicenseHeader(str(header_file)) + file_content = "int main() {}\n" + + result = rule.apply((file_content,)) + assert result.value == ResultTypeEnum.Error + assert result.description == "Missing or incorrect license header." + + def test_check_license_header_class_with_path_object(self, tmp_path: Path) -> None: + """Test CheckLicenseHeader class with Path object. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + header_file = tmp_path / "header.txt" + header_file.write_text("// Header\n") + + rule = CheckLicenseHeader(header_file) + file_content = "// Header\nint main() {}\n" + + result = rule.apply((file_content,)) + assert result.value == ResultTypeEnum.Ok diff --git a/tests/cpp/test_style_rules.py b/tests/cpp/test_style_rules.py index 58ed8be..effac7e 100644 --- a/tests/cpp/test_style_rules.py +++ b/tests/cpp/test_style_rules.py @@ -8,49 +8,51 @@ class TestCheckKeySeqOrder: """Tests for CheckKeySeqOrder rule.""" - def setup_method(self): + def setup_method(self) -> None: """Reset rule counters before each test.""" Rule.cpp_style_rule_counter = 0 Rule.general_rule_counter = 0 - def test_check_key_seq_order_creation(self): + def test_check_key_seq_order_creation(self) -> None: """Test CheckKeySeqOrder rule creation.""" rule = CheckKeySeqOrder("static inline constexpr") assert rule.name == "static inline constexpr" assert rule.rule_type == RuleType.CPP_STYLE assert rule.rule_input_type == RuleInputType.LINE - assert rule.description == 'Use "static inline constexpr" only in this given order.' + assert rule.description == ( + 'Use "static inline constexpr" only in this given order.' + ) assert rule.file_types == FileType.cpp_types() - def test_check_key_seq_order_correct_order(self): + def test_check_key_seq_order_correct_order(self) -> None: """Test CheckKeySeqOrder returns Ok for correct order.""" rule = CheckKeySeqOrder("static inline constexpr") line = "static inline constexpr int x = 42;" result = rule.apply(line) assert result.value == ResultTypeEnum.Ok - def test_check_key_seq_order_incorrect_order(self): + def test_check_key_seq_order_incorrect_order(self) -> None: """Test CheckKeySeqOrder returns Error for incorrect order.""" rule = CheckKeySeqOrder("static inline constexpr") line = "inline static constexpr int x = 42;" result = rule.apply(line) assert result.value == ResultTypeEnum.Error - def test_check_key_seq_order_missing_keys(self): + def test_check_key_seq_order_missing_keys(self) -> None: """Test CheckKeySeqOrder returns Ok when keys are missing.""" rule = CheckKeySeqOrder("static inline constexpr") line = "int x = 42;" result = rule.apply(line) assert result.value == ResultTypeEnum.Ok - def test_check_key_seq_order_partial_keys(self): + def test_check_key_seq_order_partial_keys(self) -> None: """Test CheckKeySeqOrder returns Ok when only some keys are present.""" rule = CheckKeySeqOrder("static inline constexpr") line = "static int x = 42;" result = rule.apply(line) assert result.value == ResultTypeEnum.Ok - def test_check_key_seq_order_different_sequence(self): + def test_check_key_seq_order_different_sequence(self) -> None: """Test CheckKeySeqOrder with different key sequence.""" rule = CheckKeySeqOrder("const int") line = "const int x = 42;" @@ -61,16 +63,18 @@ def test_check_key_seq_order_different_sequence(self): result = rule.apply(line) assert result.value == ResultTypeEnum.Error - def test_rule_initialization(self): + def test_rule_initialization(self) -> None: """Test that CheckKeySeqOrder is initialized correctly.""" rule = CheckKeySeqOrder("static inline constexpr") assert rule.name == "static inline constexpr" assert rule.rule_type == RuleType.CPP_STYLE assert rule.rule_input_type == RuleInputType.LINE - assert rule.description == 'Use "static inline constexpr" only in this given order.' + assert rule.description == ( + 'Use "static inline constexpr" only in this given order.' + ) - def test_rule_initialization_custom_key_sequence(self): + def test_rule_initialization_custom_key_sequence(self) -> None: """Test CheckKeySeqOrder with a different key sequence.""" rule = CheckKeySeqOrder("const static") @@ -79,21 +83,21 @@ def test_rule_initialization_custom_key_sequence(self): assert rule.rule_input_type == RuleInputType.LINE assert rule.description == 'Use "const static" only in this given order.' - def test_apply_correct_order(self): + def test_apply_correct_order(self) -> None: """Test that rule passes when keys are in correct order.""" rule = CheckKeySeqOrder("static inline constexpr") result = rule.apply("static inline constexpr int x = 42;") assert result.value == ResultTypeEnum.Ok - def test_apply_incorrect_order(self): + def test_apply_incorrect_order(self) -> None: """Test that rule fails when keys are out of order.""" rule = CheckKeySeqOrder("static inline constexpr") result = rule.apply("inline static constexpr int x = 42;") assert result.value == ResultTypeEnum.Error - def test_apply_partial_keys_present(self): + def test_apply_partial_keys_present(self) -> None: """Test that rule passes when only some keys are present.""" rule = CheckKeySeqOrder("static inline constexpr") # Only "static" and "constexpr" are present, missing "inline" @@ -101,14 +105,14 @@ def test_apply_partial_keys_present(self): assert result.value == ResultTypeEnum.Ok - def test_apply_no_keys_present(self): + def test_apply_no_keys_present(self) -> None: """Test that rule passes when no keys are present.""" rule = CheckKeySeqOrder("static inline constexpr") result = rule.apply("int x = 42;") assert result.value == ResultTypeEnum.Ok - def test_apply_single_key_present(self): + def test_apply_single_key_present(self) -> None: """Test that rule passes when only a single key is present.""" rule = CheckKeySeqOrder("static inline constexpr") result = rule.apply("static int x = 42;") @@ -119,23 +123,23 @@ def test_apply_single_key_present(self): class TestCppStyleRulesModule: """Tests for cpp_style_rules module.""" - def test_rule01_is_check_key_seq_order(self): + def test_rule01_is_check_key_seq_order(self) -> None: """Test that rule01 is a CheckKeySeqOrder instance.""" assert isinstance(rule01, CheckKeySeqOrder) - def test_rule01_checks_static_inline_constexpr(self): + def test_rule01_checks_static_inline_constexpr(self) -> None: """Test that rule01 checks for static inline constexpr ordering.""" assert rule01.name == "static inline constexpr" - def test_cpp_style_rules_list_not_empty(self): + def test_cpp_style_rules_list_not_empty(self) -> None: """Test that cpp_style_rules list is not empty.""" assert len(cpp_style_rules) > 0 - def test_cpp_style_rules_contains_rule01(self): + def test_cpp_style_rules_contains_rule01(self) -> None: """Test that cpp_style_rules contains rule01.""" assert rule01 in cpp_style_rules - def test_all_cpp_style_rules_are_cpp_style_type(self): + def test_all_cpp_style_rules_are_cpp_style_type(self) -> None: """Test that all rules in cpp_style_rules have CPP_STYLE type.""" for rule in cpp_style_rules: assert rule.rule_type == RuleType.CPP_STYLE @@ -144,12 +148,12 @@ def test_all_cpp_style_rules_are_cpp_style_type(self): class TestStaticInlineConstexprRule: """Tests for static inline constexpr ordering rule.""" - def setup_method(self): + def setup_method(self) -> None: """Reset rule counters before each test.""" Rule.cpp_style_rule_counter = 0 Rule.general_rule_counter = 0 - def test_static_inline_constexpr_correct(self): + def test_static_inline_constexpr_correct(self) -> None: """Test correct static inline constexpr order.""" rule = CheckKeySeqOrder("static inline constexpr") test_cases = [ @@ -161,7 +165,7 @@ def test_static_inline_constexpr_correct(self): result = rule.apply(line) assert result.value == ResultTypeEnum.Ok, f"Failed for: {line}" - def test_static_inline_constexpr_wrong_order(self): + def test_static_inline_constexpr_wrong_order(self) -> None: """Test wrong static inline constexpr order returns Error.""" rule = CheckKeySeqOrder("static inline constexpr") test_cases = [ @@ -172,10 +176,9 @@ def test_static_inline_constexpr_wrong_order(self): ] for line in test_cases: result = rule.apply(line) - assert result.value == ResultTypeEnum.Error, f"Expected Error for: { - line}" + assert result.value == ResultTypeEnum.Error, f"Expected Error for: {line}" - def test_static_inline_constexpr_partial_present(self): + def test_static_inline_constexpr_partial_present(self) -> None: """Test lines with only some keywords present.""" rule = CheckKeySeqOrder("static inline constexpr") test_cases = [ @@ -187,70 +190,72 @@ def test_static_inline_constexpr_partial_present(self): ] for line in test_cases: result = rule.apply(line) - assert result.value == ResultTypeEnum.Ok, f"Expected Ok for: { - line}" + assert result.value == ResultTypeEnum.Ok, f"Expected Ok for: {line}" - def test_rule01_exists_and_configured(self): + def test_rule01_exists_and_configured(self) -> None: """Test that rule01 is properly configured.""" assert rule01 is not None assert rule01.name == "static inline constexpr" assert rule01.rule_type == RuleType.CPP_STYLE assert rule01.rule_input_type == RuleInputType.LINE - def test_rule01_in_cpp_style_rules(self): + def test_rule01_in_cpp_style_rules(self) -> None: """Test that rule01 is in the cpp_style_rules list.""" assert rule01 in cpp_style_rules assert len(cpp_style_rules) >= 1 - def test_static_inline_constexpr_correct_order(self): + def test_static_inline_constexpr_correct_order(self) -> None: """Test static inline constexpr in correct order.""" result = rule01.apply("static inline constexpr int value = 10;") assert result.value == ResultTypeEnum.Ok - def test_static_inline_constexpr_wrong_order_inline_first(self): + def test_static_inline_constexpr_wrong_order_inline_first(self) -> None: """Test inline static constexpr (wrong order).""" result = rule01.apply("inline static constexpr int value = 10;") assert result.value == ResultTypeEnum.Error - def test_static_inline_constexpr_wrong_order_constexpr_first(self): + def test_static_inline_constexpr_wrong_order_constexpr_first(self) -> None: """Test constexpr static inline (wrong order).""" result = rule01.apply("constexpr static inline int value = 10;") assert result.value == ResultTypeEnum.Error - def test_static_inline_constexpr_wrong_order_constexpr_inline_static(self): + def test_static_inline_constexpr_wrong_order_constexpr_inline_static(self) -> None: """Test constexpr inline static (wrong order).""" result = rule01.apply("constexpr inline static int value = 10;") assert result.value == ResultTypeEnum.Error - def test_static_constexpr_only(self): + def test_static_constexpr_only(self) -> None: """Test static constexpr without inline (should pass - not all keys present).""" result = rule01.apply("static constexpr int value = 10;") assert result.value == ResultTypeEnum.Ok - def test_inline_constexpr_only(self): + def test_inline_constexpr_only(self) -> None: """Test inline constexpr without static (should pass - not all keys present).""" result = rule01.apply("inline constexpr int value = 10;") assert result.value == ResultTypeEnum.Ok - def test_no_keywords(self): + def test_no_keywords(self) -> None: """Test line with no relevant keywords.""" result = rule01.apply("int value = 10;") assert result.value == ResultTypeEnum.Ok - def test_with_template(self): + def test_with_template(self) -> None: """Test static inline constexpr in a template context.""" result = rule01.apply( - "template static inline constexpr T default_value{};") + "template static inline constexpr T default_value{};" + ) assert result.value == ResultTypeEnum.Ok - def test_in_function_declaration(self): + def test_in_function_declaration(self) -> None: """Test static inline constexpr in a function declaration.""" result = rule01.apply( - "static inline constexpr auto compute() -> int { return 42; }") + "static inline constexpr auto compute() -> int { return 42; }" + ) assert result.value == ResultTypeEnum.Ok - def test_wrong_order_in_function(self): + def test_wrong_order_in_function(self) -> None: """Test wrong order in function declaration.""" result = rule01.apply( - "inline static constexpr auto compute() -> int { return 42; }") + "inline static constexpr auto compute() -> int { return 42; }" + ) assert result.value == ResultTypeEnum.Error diff --git a/tests/enums/__init__.py b/tests/enums/__init__.py new file mode 100644 index 0000000..36427bb --- /dev/null +++ b/tests/enums/__init__.py @@ -0,0 +1 @@ +"""Tests for devops.enums package.""" diff --git a/tests/enums/test_base.py b/tests/enums/test_base.py new file mode 100644 index 0000000..da55ec7 --- /dev/null +++ b/tests/enums/test_base.py @@ -0,0 +1,86 @@ +"""Tests for devops.enums.base module.""" + +import pytest + +from devops.enums.base import StrEnum + + +class SampleEnum(StrEnum): + """Test enumeration for testing StrEnum functionality.""" + + OPTION_A = "OPTION_A" + OPTION_B = "OPTION_B" + OPTION_C = "OPTION_C" + + +def test_str_enum_str_representation() -> None: + """Test string representation of StrEnum members.""" + assert str(SampleEnum.OPTION_A) == "OPTION_A" + assert str(SampleEnum.OPTION_B) == "OPTION_B" + assert str(SampleEnum.OPTION_C) == "OPTION_C" + + +def test_str_enum_case_insensitive_access() -> None: + """Test case-insensitive access to StrEnum members.""" + assert SampleEnum("option_a") == SampleEnum.OPTION_A + assert SampleEnum("OPTION_A") == SampleEnum.OPTION_A + assert SampleEnum("Option_A") == SampleEnum.OPTION_A + assert SampleEnum("option_b") == SampleEnum.OPTION_B + assert SampleEnum("OPTION_B") == SampleEnum.OPTION_B + + +def test_str_enum_invalid_value() -> None: + """Test that invalid values raise ValueError.""" + with pytest.raises(ValueError, match="is not a valid"): + SampleEnum("invalid_option") + + with pytest.raises(ValueError, match="is not a valid"): + SampleEnum("OPTION_D") + + +def test_str_enum_is_valid_with_valid_values() -> None: + """Test is_valid method with valid enumeration values.""" + assert SampleEnum.is_valid("OPTION_A") is True + assert SampleEnum.is_valid("option_a") is True + assert SampleEnum.is_valid("Option_A") is True + assert SampleEnum.is_valid("OPTION_B") is True + assert SampleEnum.is_valid("option_b") is True + assert SampleEnum.is_valid("OPTION_C") is True + assert SampleEnum.is_valid("option_c") is True + + +def test_str_enum_is_valid_with_invalid_values() -> None: + """Test is_valid method with invalid values.""" + assert SampleEnum.is_valid("invalid") is False + assert SampleEnum.is_valid("OPTION_D") is False + assert SampleEnum.is_valid("") is False + assert SampleEnum.is_valid("option") is False + + +def test_str_enum_list_values() -> None: + """Test list_values method returns all enumeration values.""" + values = SampleEnum.list_values() + + assert isinstance(values, list) + assert len(values) == 3 + assert "OPTION_A" in values + assert "OPTION_B" in values + assert "OPTION_C" in values + + +def test_str_enum_list_values_order() -> None: + """Test that list_values preserves enum definition order.""" + values = SampleEnum.list_values() + + assert values[0] == "OPTION_A" + assert values[1] == "OPTION_B" + assert values[2] == "OPTION_C" + + +def test_str_enum_list_values_returns_new_list() -> None: + """Test that list_values returns a new list each time.""" + values1 = SampleEnum.list_values() + values2 = SampleEnum.list_values() + + assert values1 == values2 + assert values1 is not values2 diff --git a/tests/enums/test_logging.py b/tests/enums/test_logging.py new file mode 100644 index 0000000..37ab904 --- /dev/null +++ b/tests/enums/test_logging.py @@ -0,0 +1,212 @@ +"""Tests for devops.enums.logging module.""" + +import logging + +from devops.enums.logging import LogLevel + + +def test_log_level_enum_values() -> None: + """Test that all expected LogLevel enum values exist.""" + assert LogLevel.NONE.value == "NONE" + assert LogLevel.DEBUG.value == "DEBUG" + assert LogLevel.INFO.value == "INFO" + assert LogLevel.WARNING.value == "WARNING" + assert LogLevel.ERROR.value == "ERROR" + assert LogLevel.CRITICAL.value == "CRITICAL" + + +def test_log_level_str_representation() -> None: + """Test string representation of LogLevel members.""" + assert str(LogLevel.DEBUG) == "DEBUG" + assert str(LogLevel.INFO) == "INFO" + assert str(LogLevel.WARNING) == "WARNING" + assert str(LogLevel.ERROR) == "ERROR" + assert str(LogLevel.CRITICAL) == "CRITICAL" + assert str(LogLevel.NONE) == "NONE" + + +def test_log_level_case_insensitive_access() -> None: + """Test case-insensitive access to LogLevel members.""" + assert LogLevel("debug") == LogLevel.DEBUG + assert LogLevel("DEBUG") == LogLevel.DEBUG + assert LogLevel("Debug") == LogLevel.DEBUG + assert LogLevel("info") == LogLevel.INFO + assert LogLevel("INFO") == LogLevel.INFO + + +def test_log_level_from_int_with_valid_values() -> None: + """Test from_int method with valid integer logging levels divided by 10.""" + assert LogLevel.from_int(0) == LogLevel.NONE + assert LogLevel.from_int(1) == LogLevel.DEBUG + assert LogLevel.from_int(2) == LogLevel.INFO + assert LogLevel.from_int(3) == LogLevel.WARNING + assert LogLevel.from_int(4) == LogLevel.ERROR + assert LogLevel.from_int(5) == LogLevel.CRITICAL + + +def test_log_level_from_int_with_values_above_critical() -> None: + """Test from_int method with values above CRITICAL returns CRITICAL.""" + assert LogLevel.from_int(6) == LogLevel.CRITICAL + assert LogLevel.from_int(10) == LogLevel.CRITICAL + assert LogLevel.from_int(100) == LogLevel.CRITICAL + + +def test_log_level_from_int_with_values_below_none() -> None: + """Test from_int method with values below NONE returns NONE.""" + assert LogLevel.from_int(-1) == LogLevel.NONE + assert LogLevel.from_int(-10) == LogLevel.NONE + + +def test_log_level_from_logging_level_with_standard_levels() -> None: + """Test from_logging_level method with standard logging module levels.""" + assert LogLevel.from_logging_level(logging.NOTSET) == LogLevel.NONE + assert LogLevel.from_logging_level(logging.DEBUG) == LogLevel.DEBUG + assert LogLevel.from_logging_level(logging.INFO) == LogLevel.INFO + assert LogLevel.from_logging_level(logging.WARNING) == LogLevel.WARNING + assert LogLevel.from_logging_level(logging.ERROR) == LogLevel.ERROR + assert LogLevel.from_logging_level(logging.CRITICAL) == LogLevel.CRITICAL + + +def test_log_level_from_logging_level_with_custom_values() -> None: + """Test from_logging_level with custom integer values.""" + # Test with values that would be divided by 10 + assert LogLevel.from_logging_level(15) == LogLevel.DEBUG # 15 // 10 = 1 + assert LogLevel.from_logging_level(25) == LogLevel.INFO # 25 // 10 = 2 + assert LogLevel.from_logging_level(35) == LogLevel.WARNING # 35 // 10 = 3 + assert LogLevel.from_logging_level(45) == LogLevel.ERROR # 45 // 10 = 4 + assert LogLevel.from_logging_level(55) == LogLevel.CRITICAL # 55 // 10 = 5 + + +def test_log_level_to_logging_level() -> None: + """Test to_logging_level method converts to correct logging module level.""" + assert LogLevel.NONE.to_logging_level() == logging.NOTSET + assert LogLevel.DEBUG.to_logging_level() == logging.DEBUG + assert LogLevel.INFO.to_logging_level() == logging.INFO + assert LogLevel.WARNING.to_logging_level() == logging.WARNING + assert LogLevel.ERROR.to_logging_level() == logging.ERROR + assert LogLevel.CRITICAL.to_logging_level() == logging.CRITICAL + + +def test_log_level_comparison_less_than() -> None: + """Test less than comparison operator.""" + assert LogLevel.NONE < LogLevel.DEBUG + assert LogLevel.DEBUG < LogLevel.INFO + assert LogLevel.INFO < LogLevel.WARNING + assert LogLevel.WARNING < LogLevel.ERROR + assert LogLevel.ERROR < LogLevel.CRITICAL + + assert not (LogLevel.DEBUG < LogLevel.NONE) + assert not (LogLevel.INFO < LogLevel.DEBUG) + assert not (LogLevel.DEBUG < LogLevel.DEBUG) + + +def test_log_level_comparison_less_than_or_equal() -> None: + """Test less than or equal comparison operator.""" + assert LogLevel.NONE <= LogLevel.DEBUG + assert LogLevel.DEBUG <= LogLevel.INFO + assert LogLevel.INFO <= LogLevel.WARNING + assert LogLevel.WARNING <= LogLevel.ERROR + assert LogLevel.ERROR <= LogLevel.CRITICAL + + assert LogLevel.DEBUG <= LogLevel.DEBUG + assert LogLevel.INFO <= LogLevel.INFO + + assert not (LogLevel.DEBUG <= LogLevel.NONE) + assert not (LogLevel.INFO <= LogLevel.DEBUG) + + +def test_log_level_comparison_greater_than() -> None: + """Test greater than comparison operator.""" + assert LogLevel.DEBUG > LogLevel.NONE + assert LogLevel.INFO > LogLevel.DEBUG + assert LogLevel.WARNING > LogLevel.INFO + assert LogLevel.ERROR > LogLevel.WARNING + assert LogLevel.CRITICAL > LogLevel.ERROR + + assert not (LogLevel.NONE > LogLevel.DEBUG) + assert not (LogLevel.DEBUG > LogLevel.INFO) + assert not (LogLevel.DEBUG > LogLevel.DEBUG) + + +def test_log_level_comparison_greater_than_or_equal() -> None: + """Test greater than or equal comparison operator.""" + assert LogLevel.DEBUG >= LogLevel.NONE + assert LogLevel.INFO >= LogLevel.DEBUG + assert LogLevel.WARNING >= LogLevel.INFO + assert LogLevel.ERROR >= LogLevel.WARNING + assert LogLevel.CRITICAL >= LogLevel.ERROR + + assert LogLevel.DEBUG >= LogLevel.DEBUG + assert LogLevel.INFO >= LogLevel.INFO + + assert not (LogLevel.NONE >= LogLevel.DEBUG) + assert not (LogLevel.DEBUG >= LogLevel.INFO) + + +def test_log_level_comparison_equality() -> None: + """Test equality comparison operator.""" + assert LogLevel.NONE == LogLevel.NONE + assert LogLevel.DEBUG == LogLevel.DEBUG + assert LogLevel.INFO == LogLevel.INFO + assert LogLevel.WARNING == LogLevel.WARNING + assert LogLevel.ERROR == LogLevel.ERROR + assert LogLevel.CRITICAL == LogLevel.CRITICAL + + assert LogLevel.DEBUG != LogLevel.INFO + assert LogLevel.INFO != LogLevel.WARNING + assert LogLevel.NONE != LogLevel.DEBUG + + +def test_log_level_comparison_equality_with_non_log_level() -> None: + """Test equality comparison with non-LogLevel objects.""" + assert LogLevel.INFO != "INFO" + assert LogLevel.DEBUG != 10 + assert LogLevel.INFO != None # noqa: E711 + assert LogLevel.INFO != 20 + + +def test_log_level_hash() -> None: + """Test that LogLevel instances are hashable.""" + level_set = {LogLevel.DEBUG, LogLevel.INFO, LogLevel.DEBUG} + + assert len(level_set) == 2 + assert LogLevel.DEBUG in level_set + assert LogLevel.INFO in level_set + + +def test_log_level_can_be_used_as_dict_key() -> None: + """Test that LogLevel can be used as dictionary keys.""" + level_dict = { + LogLevel.DEBUG: "debug", + LogLevel.INFO: "info", + LogLevel.WARNING: "warning", + } + + assert level_dict[LogLevel.DEBUG] == "debug" + assert level_dict[LogLevel.INFO] == "info" + assert level_dict[LogLevel.WARNING] == "warning" + + +def test_log_level_comparison_with_none_level() -> None: + """Test that NONE level works correctly in comparisons.""" + assert LogLevel.NONE < LogLevel.DEBUG + assert LogLevel.NONE < LogLevel.INFO + assert LogLevel.NONE <= LogLevel.NONE + assert LogLevel.NONE == LogLevel.NONE + assert not (LogLevel.NONE > LogLevel.DEBUG) + assert not (LogLevel.NONE >= LogLevel.DEBUG) + + +def test_log_level_roundtrip_conversion() -> None: + """Test that conversion from LogLevel to int and back preserves value.""" + for level in [ + LogLevel.NONE, + LogLevel.DEBUG, + LogLevel.INFO, + LogLevel.WARNING, + LogLevel.ERROR, + LogLevel.CRITICAL, + ]: + logging_level = level.to_logging_level() + converted_back = LogLevel.from_logging_level(logging_level) + assert converted_back == level diff --git a/tests/files/test_files.py b/tests/files/test_files.py index 9a1e64c..34b10ad 100644 --- a/tests/files/test_files.py +++ b/tests/files/test_files.py @@ -3,15 +3,15 @@ from devops.files import FileType -def test_all_types(): - +def test_all_types() -> None: + """Test that FileType.all_types() returns the expected set of file types.""" expected_types = { FileType.CPPHeader, FileType.CPPSource, FileType.UNKNOWN, - FileType.CMakeLists + FileType.CMakeLists, } actual_types = FileType.all_types() - assert actual_types == expected_types, "FileType enum does not match expected types." + assert actual_types == expected_types diff --git a/tests/files/test_update_changelog.py b/tests/files/test_update_changelog.py index 26e2b6e..5beb639 100644 --- a/tests/files/test_update_changelog.py +++ b/tests/files/test_update_changelog.py @@ -1,41 +1,47 @@ """Unit tests for changelog update functionality in mstd checks.""" from datetime import UTC, datetime -from unittest.mock import patch import pytest -from devops.files.files import MSTDFileNotFoundError +from devops.config import Constants +from devops.files.files import DevOpsFileNotFoundError from devops.files.update_changelog import ( - MSTDChangelogError, __CHANGELOG_INSERTION_MARKER__, + DevOpsChangelogError, update_changelog, ) +owner_url = Constants.github.github_default_owner_url -class TestMSTDChangelogError: - """Tests for MSTDChangelogError exception class.""" - def test_changelog_error_message(self): - """Test that MSTDChangelogError formats message correctly.""" - error = MSTDChangelogError("test error message") - assert str(error) == "MSTDChangelogError: test error message" +class TestDevOpsChangelogError: + """Tests for DevOpsChangelogError exception class.""" + + def test_changelog_error_message(self) -> None: + """Test that DevOpsChangelogError formats message correctly.""" + error = DevOpsChangelogError("test error message") + assert str(error) == "DevOpsChangelogError: test error message" assert error.message == "test error message" - def test_changelog_error_is_exception(self): - """Test that MSTDChangelogError is a proper exception.""" - error = MSTDChangelogError("test") + def test_changelog_error_is_exception(self) -> None: + """Test that DevOpsChangelogError is a proper exception.""" + error = DevOpsChangelogError("test") assert isinstance(error, Exception) class TestUpdateChangelog: """Tests for update_changelog function.""" - @patch("devops.files.update_changelog.get_github_repo") - def test_update_changelog_success(self, mock_get_repo, tmp_path): - """Test successful changelog update with new version.""" - mock_get_repo.return_value = "https://github.com/test/repo" + def test_update_changelog_success(self, tmp_path: pytest.TempdirFactory) -> None: + """Test successful changelog update with new version. + + Parameters + ---------- + tmp_path : pytest.TempdirFactory + Temporary directory for test files. + """ changelog = tmp_path / "CHANGELOG.md" changelog.write_text( "# Changelog\n" @@ -46,7 +52,7 @@ def test_update_changelog_success(self, mock_get_repo, tmp_path): "\n" "\n" "\n" - "## [1.0.0](https://github.com/test/repo/releases/tag/1.0.0) - 2024-01-01\n" + f"## [1.0.0]({owner_url}/releases/tag/1.0.0) - 2024-01-01\n" "\n" "- Initial release\n" ) @@ -54,7 +60,7 @@ def test_update_changelog_success(self, mock_get_repo, tmp_path): update_changelog("1.1.0", changelog) content = changelog.read_text() - assert "## [1.1.0](https://github.com/test/repo/releases/tag/1.1.0)" in content + assert f"## [1.1.0]({owner_url}/releases/tag/1.1.0)" in content assert "## Next Release" in content # Marker should be after Next Release now next_release_pos = content.find("## Next Release") @@ -62,11 +68,15 @@ def test_update_changelog_success(self, mock_get_repo, tmp_path): new_version_pos = content.find("## [1.1.0]") assert next_release_pos < marker_pos < new_version_pos - @patch("devops.files.update_changelog.get_github_repo") - def test_update_changelog_with_date(self, mock_get_repo, tmp_path): - """Test that changelog entry includes today's date.""" - mock_get_repo.return_value = "https://github.com/test/repo" + def test_update_changelog_with_date(self, tmp_path: pytest.TempdirFactory) -> None: + """Test that changelog entry includes today's date. + + Parameters + ---------- + tmp_path : pytest.TempdirFactory + Temporary directory for test files. + """ changelog = tmp_path / "CHANGELOG.md" changelog.write_text( "# Changelog\n" @@ -82,42 +92,62 @@ def test_update_changelog_with_date(self, mock_get_repo, tmp_path): content = changelog.read_text() today = datetime.now(tz=UTC).date().isoformat() - assert f"## [2.0.0](https://github.com/test/repo/releases/tag/2.0.0) - { - today}" in content + assert f"## [2.0.0]({owner_url}/releases/tag/2.0.0) - {today}" in content - def test_update_changelog_file_not_found(self, tmp_path): - """Test that MSTDFileNotFoundError is raised when file doesn't exist.""" + def test_update_changelog_file_not_found( + self, tmp_path: pytest.TempdirFactory + ) -> None: + """Test that DevOpsFileNotFoundError is raised when file doesn't exist. + + Parameters + ---------- + tmp_path : pytest.TempdirFactory + Temporary directory for test files. + + """ non_existent = tmp_path / "does_not_exist.md" - with pytest.raises(MSTDFileNotFoundError) as exc_info: + with pytest.raises(DevOpsFileNotFoundError) as exc_info: update_changelog("1.0.0", non_existent) assert exc_info.value.filepath == non_existent - @patch("devops.files.update_changelog.get_github_repo") - def test_update_changelog_missing_next_release(self, mock_get_repo, tmp_path): - """Test that MSTDChangelogError is raised when Next Release marker missing.""" - mock_get_repo.return_value = "https://github.com/test/repo" + def test_update_changelog_missing_next_release( + self, tmp_path: pytest.TempdirFactory + ) -> None: + """Test that DevOpsChangelogError is raised when Next Release marker missing. + + Parameters + ---------- + tmp_path : pytest.TempdirFactory + Temporary directory for test files. + """ changelog = tmp_path / "CHANGELOG.md" changelog.write_text( "# Changelog\n" "\n" - "## [1.0.0](https://github.com/test/repo/releases/tag/1.0.0) - 2024-01-01\n" + f"## [1.0.0]({owner_url}/releases/tag/1.0.0) - 2024-01-01\n" "\n" "- Initial release\n" ) - with pytest.raises(MSTDChangelogError) as exc_info: + with pytest.raises(DevOpsChangelogError) as exc_info: update_changelog("1.1.0", changelog) assert "Next Release" in exc_info.value.message - @patch("devops.files.update_changelog.get_github_repo") - def test_update_changelog_removes_old_marker(self, mock_get_repo, tmp_path): - """Test that old insertion marker is removed and new one is placed.""" - mock_get_repo.return_value = "https://github.com/test/repo" + def test_update_changelog_removes_old_marker( + self, tmp_path: pytest.TempdirFactory + ) -> None: + """Test that old insertion marker is removed and new one is placed. + Parameters + ---------- + tmp_path : pytest.TempdirFactory + Temporary directory for test files. + + """ changelog = tmp_path / "CHANGELOG.md" changelog.write_text( "# Changelog\n" @@ -126,11 +156,11 @@ def test_update_changelog_removes_old_marker(self, mock_get_repo, tmp_path): "\n" "- New change\n" "\n" - "## [1.0.0](https://github.com/test/repo/releases/tag/1.0.0) - 2024-01-01\n" + f"## [1.0.0]({owner_url}/releases/tag/1.0.0) - 2024-01-01\n" "\n" "\n" "\n" - "## [0.9.0](https://github.com/test/repo/releases/tag/0.9.0) - 2023-12-01\n" + f"## [0.9.0]({owner_url}/releases/tag/0.9.0) - 2023-12-01\n" ) update_changelog("1.1.0", changelog) @@ -143,11 +173,17 @@ def test_update_changelog_removes_old_marker(self, mock_get_repo, tmp_path): marker_pos = content.find(__CHANGELOG_INSERTION_MARKER__) assert next_release_pos < marker_pos - @patch("devops.files.update_changelog.get_github_repo") - def test_update_changelog_no_existing_marker(self, mock_get_repo, tmp_path): - """Test changelog update when no insertion marker exists.""" - mock_get_repo.return_value = "https://github.com/test/repo" + def test_update_changelog_no_existing_marker( + self, tmp_path: pytest.TempdirFactory + ) -> None: + """Test changelog update when no insertion marker exists. + + Parameters + ---------- + tmp_path : pytest.TempdirFactory + Temporary directory for test files. + """ changelog = tmp_path / "CHANGELOG.md" changelog.write_text( "# Changelog\n" @@ -156,7 +192,7 @@ def test_update_changelog_no_existing_marker(self, mock_get_repo, tmp_path): "\n" "- Feature A\n" "\n" - "## [1.0.0](https://github.com/test/repo/releases/tag/1.0.0) - 2024-01-01\n" + f"## [1.0.0]({owner_url}/releases/tag/1.0.0) - 2024-01-01\n" ) update_changelog("1.1.0", changelog) @@ -165,11 +201,17 @@ def test_update_changelog_no_existing_marker(self, mock_get_repo, tmp_path): assert __CHANGELOG_INSERTION_MARKER__ in content assert "## [1.1.0]" in content - @patch("devops.files.update_changelog.get_github_repo") - def test_update_changelog_preserves_content(self, mock_get_repo, tmp_path): - """Test that changelog update preserves existing content.""" - mock_get_repo.return_value = "https://github.com/test/repo" + def test_update_changelog_preserves_content( + self, tmp_path: pytest.TempdirFactory + ) -> None: + """Test that changelog update preserves existing content. + + Parameters + ---------- + tmp_path : pytest.TempdirFactory + Temporary directory for test files. + """ changelog = tmp_path / "CHANGELOG.md" original_content = ( "# Changelog\n" @@ -186,7 +228,7 @@ def test_update_changelog_preserves_content(self, mock_get_repo, tmp_path): "\n" "\n" "\n" - "## [1.0.0](https://github.com/test/repo/releases/tag/1.0.0) - 2024-01-01\n" + f"## [1.0.0]({owner_url}/releases/tag/1.0.0) - 2024-01-01\n" "\n" "### Added\n" "- Initial release\n" @@ -206,13 +248,17 @@ def test_update_changelog_preserves_content(self, mock_get_repo, tmp_path): assert "- Initial release" in content assert "## [1.0.0]" in content - @patch("devops.files.update_changelog.get_github_repo") def test_update_changelog_next_release_regex_variations( - self, mock_get_repo, tmp_path - ): - """Test that regex matches various Next Release formats.""" - mock_get_repo.return_value = "https://github.com/test/repo" + self, tmp_path: pytest.TempdirFactory + ) -> None: + """Test that regex matches various Next Release formats. + Parameters + ---------- + tmp_path : pytest.TempdirFactory + Temporary directory for test files. + + """ # Test with extra spaces changelog = tmp_path / "CHANGELOG.md" changelog.write_text( @@ -230,11 +276,17 @@ def test_update_changelog_next_release_regex_variations( content = changelog.read_text() assert "## [1.0.0]" in content - @patch("devops.files.update_changelog.get_github_repo") - def test_update_changelog_empty_next_release(self, mock_get_repo, tmp_path): - """Test changelog update when Next Release section is empty.""" - mock_get_repo.return_value = "https://github.com/test/repo" + def test_update_changelog_empty_next_release( + self, tmp_path: pytest.TempdirFactory + ) -> None: + """Test changelog update when Next Release section is empty. + + Parameters + ---------- + tmp_path : pytest.TempdirFactory + Temporary directory for test files. + """ changelog = tmp_path / "CHANGELOG.md" changelog.write_text( "# Changelog\n" @@ -243,7 +295,7 @@ def test_update_changelog_empty_next_release(self, mock_get_repo, tmp_path): "\n" "\n" "\n" - "## [1.0.0](https://github.com/test/repo/releases/tag/1.0.0) - 2024-01-01\n" + f"## [1.0.0]({owner_url}/releases/tag/1.0.0) - 2024-01-01\n" ) update_changelog("1.1.0", changelog) diff --git a/tests/git/__init__.py b/tests/git/__init__.py new file mode 100644 index 0000000..4d5e53d --- /dev/null +++ b/tests/git/__init__.py @@ -0,0 +1 @@ +"""Module for Git-related tests.""" diff --git a/tests/git/test_tag.py b/tests/git/test_tag.py new file mode 100644 index 0000000..b650ff7 --- /dev/null +++ b/tests/git/test_tag.py @@ -0,0 +1,485 @@ +"""Tests for devops.git.tag module.""" + +import subprocess +from dataclasses import FrozenInstanceError +from unittest.mock import MagicMock, patch + +import pytest + +from devops.config import GitConfig +from devops.git.tag import GitTag, GitTagError, get_all_tags, get_latest_tag + + +class TestGitTag: + """Test cases for the GitTag class.""" + + def test_str_representation(self) -> None: + """Test string representation of GitTag.""" + tag = GitTag(1, 2, 3, prefix="v") + assert str(tag) == "v1.2.3" + + def test_str_representation_with_zeros(self) -> None: + """Test string representation with zero values.""" + tag = GitTag(0, 0, 0, prefix="v") + assert str(tag) == "v0.0.0" + + def test_str_representation_with_large_numbers(self) -> None: + """Test string representation with large version numbers.""" + config = GitConfig(tag_prefix="v") + tag = GitTag(10, 20, 30, prefix=config.tag_prefix) + assert str(tag) == "v10.20.30" + + def test_from_string_with_v_prefix(self) -> None: + """Test creating GitTag from string with 'v' prefix.""" + config = GitConfig(tag_prefix="v") + tag = GitTag.from_string("v1.2.3", config=config) + assert tag.major == 1 + assert tag.minor == 2 + assert tag.patch == 3 + + def test_from_string_without_v_prefix(self) -> None: + """Test creating GitTag from string without 'v' prefix.""" + tag = GitTag.from_string("1.2.3") + assert tag.major == 1 + assert tag.minor == 2 + assert tag.patch == 3 + + def test_from_string_with_zeros(self) -> None: + """Test creating GitTag from string with zero values.""" + config = GitConfig(tag_prefix="v") + tag = GitTag.from_string("v0.0.0", config=config) + assert tag.major == 0 + assert tag.minor == 0 + assert tag.patch == 0 + + def test_from_string_with_large_numbers(self) -> None: + """Test creating GitTag from string with large version numbers.""" + config = GitConfig(tag_prefix="v") + tag = GitTag.from_string("v10.20.30", config=config) + assert tag.major == 10 + assert tag.minor == 20 + assert tag.patch == 30 + + def test_from_string_with_too_few_parts(self) -> None: + """Test from_string raises error with too few version parts.""" + with pytest.raises(GitTagError) as exc_info: + GitTag.from_string("v1.2") + + assert "Invalid tag format: v1.2" in str(exc_info.value) + + def test_from_string_with_too_many_parts(self) -> None: + """Test from_string raises error with too many version parts.""" + with pytest.raises(GitTagError) as exc_info: + GitTag.from_string("v1.2.3.4") + + assert "Invalid tag format: v1.2.3.4" in str(exc_info.value) + + def test_from_string_with_non_numeric_major(self) -> None: + """Test from_string raises error with non-numeric major version.""" + with pytest.raises(GitTagError) as exc_info: + GitTag.from_string("va.2.3") + + assert "Invalid numeric components in tag: va.2.3" in str(exc_info.value) + + def test_from_string_with_non_numeric_minor(self) -> None: + """Test from_string raises error with non-numeric minor version.""" + with pytest.raises(GitTagError) as exc_info: + GitTag.from_string("v1.b.3") + + assert "Invalid numeric components in tag: v1.b.3" in str(exc_info.value) + + def test_from_string_with_non_numeric_patch(self) -> None: + """Test from_string raises error with non-numeric patch version.""" + with pytest.raises(GitTagError) as exc_info: + GitTag.from_string("v1.2.c") + + assert "Invalid numeric components in tag: v1.2.c" in str(exc_info.value) + + def test_from_string_with_empty_string(self) -> None: + """Test from_string raises error with empty string.""" + with pytest.raises(GitTagError) as exc_info: + GitTag.from_string("") + + assert "Invalid tag format:" in str(exc_info.value) + + def test_from_string_with_only_v(self) -> None: + """Test from_string raises error with only 'v' character.""" + with pytest.raises(GitTagError) as exc_info: + GitTag.from_string("v") + + assert "Invalid tag format: v" in str(exc_info.value) + + def test_ordering_equal_tags(self) -> None: + """Test ordering of equal tags.""" + tag1 = GitTag(1, 2, 3, prefix="") + tag2 = GitTag(1, 2, 3, prefix="") + assert tag1 == tag2 + assert not tag1 < tag2 + assert not tag1 > tag2 + + def test_ordering_different_major(self) -> None: + """Test ordering based on major version.""" + tag1 = GitTag(1, 2, 3, prefix="") + tag2 = GitTag(2, 2, 3, prefix="") + assert tag1 < tag2 + assert tag2 > tag1 + + def test_ordering_different_minor(self) -> None: + """Test ordering based on minor version.""" + tag1 = GitTag(1, 2, 3, prefix="") + tag2 = GitTag(1, 3, 3, prefix="") + assert tag1 < tag2 + assert tag2 > tag1 + + def test_ordering_different_patch(self) -> None: + """Test ordering based on patch version.""" + tag1 = GitTag(1, 2, 3, prefix="") + tag2 = GitTag(1, 2, 4, prefix="") + assert tag1 < tag2 + assert tag2 > tag1 + + def test_ordering_multiple_tags(self) -> None: + """Test sorting multiple tags.""" + tags = [ + GitTag(2, 0, 0, prefix=""), + GitTag(1, 0, 0, prefix=""), + GitTag(1, 2, 0, prefix=""), + GitTag(1, 1, 0, prefix=""), + GitTag(1, 1, 5, prefix=""), + ] + sorted_tags = sorted(tags) + assert sorted_tags == [ + GitTag(1, 0, 0, prefix=""), + GitTag(1, 1, 0, prefix=""), + GitTag(1, 1, 5, prefix=""), + GitTag(1, 2, 0, prefix=""), + GitTag(2, 0, 0, prefix=""), + ] + + def test_max_tag(self) -> None: + """Test finding max tag from list.""" + tags = [ + GitTag(1, 0, 0, prefix=""), + GitTag(2, 5, 3, prefix=""), + GitTag(2, 5, 1, prefix=""), + ] + assert max(tags) == GitTag(2, 5, 3, prefix="") + + def test_frozen_dataclass(self) -> None: + """Test that GitTag is immutable.""" + tag = GitTag(1, 2, 3, prefix="") + with pytest.raises(FrozenInstanceError, match="cannot assign to field"): + tag.major = 5 # type: ignore[misc] + + def test_increase_major(self) -> None: + """Test increase_major increments major version and resets minor and patch.""" + tag = GitTag(1, 2, 3, prefix="v") + new_tag = tag.increase_major() + assert new_tag.major == 2 + assert new_tag.minor == 0 + assert new_tag.patch == 0 + assert new_tag.prefix == "v" + + def test_increase_major_preserves_prefix(self) -> None: + """Test increase_major preserves the tag prefix.""" + tag = GitTag(0, 5, 10, prefix="") + new_tag = tag.increase_major() + assert new_tag.major == 1 + assert new_tag.minor == 0 + assert new_tag.patch == 0 + assert new_tag.prefix == "" + + def test_increase_major_returns_new_instance(self) -> None: + """Test increase_major returns a new instance and doesn't modify original.""" + tag = GitTag(1, 2, 3, prefix="v") + new_tag = tag.increase_major() + assert tag.major == 1 + assert tag.minor == 2 + assert tag.patch == 3 + assert new_tag is not tag + + def test_increase_minor(self) -> None: + """Test increase_minor increments minor version and resets patch.""" + tag = GitTag(1, 2, 3, prefix="v") + new_tag = tag.increase_minor() + assert new_tag.major == 1 + assert new_tag.minor == 3 + assert new_tag.patch == 0 + assert new_tag.prefix == "v" + + def test_increase_minor_preserves_prefix(self) -> None: + """Test increase_minor preserves the tag prefix.""" + tag = GitTag(5, 0, 10, prefix="") + new_tag = tag.increase_minor() + assert new_tag.major == 5 + assert new_tag.minor == 1 + assert new_tag.patch == 0 + assert new_tag.prefix == "" + + def test_increase_minor_returns_new_instance(self) -> None: + """Test increase_minor returns a new instance and doesn't modify original.""" + tag = GitTag(1, 2, 3, prefix="v") + new_tag = tag.increase_minor() + assert tag.major == 1 + assert tag.minor == 2 + assert tag.patch == 3 + assert new_tag is not tag + + def test_increase_patch(self) -> None: + """Test increase_patch increments patch version only.""" + tag = GitTag(1, 2, 3, prefix="v") + new_tag = tag.increase_patch() + assert new_tag.major == 1 + assert new_tag.minor == 2 + assert new_tag.patch == 4 + assert new_tag.prefix == "v" + + def test_increase_patch_preserves_prefix(self) -> None: + """Test increase_patch preserves the tag prefix.""" + tag = GitTag(5, 10, 0, prefix="") + new_tag = tag.increase_patch() + assert new_tag.major == 5 + assert new_tag.minor == 10 + assert new_tag.patch == 1 + assert new_tag.prefix == "" + + def test_increase_patch_returns_new_instance(self) -> None: + """Test increase_patch returns a new instance and doesn't modify original.""" + tag = GitTag(1, 2, 3, prefix="v") + new_tag = tag.increase_patch() + assert tag.major == 1 + assert tag.minor == 2 + assert tag.patch == 3 + assert new_tag is not tag + + def test_increase_methods_with_large_numbers(self) -> None: + """Test increase methods work correctly with large version numbers.""" + tag = GitTag(99, 199, 299, prefix="v") + + major_tag = tag.increase_major() + assert major_tag == GitTag(100, 0, 0, prefix="v") + + minor_tag = tag.increase_minor() + assert minor_tag == GitTag(99, 200, 0, prefix="v") + + patch_tag = tag.increase_patch() + assert patch_tag == GitTag(99, 199, 300, prefix="v") + + def test_increase_methods_with_zeros(self) -> None: + """Test increase methods work correctly when starting from zeros.""" + tag = GitTag(0, 0, 0, prefix="v") + + major_tag = tag.increase_major() + assert major_tag == GitTag(1, 0, 0, prefix="v") + + minor_tag = tag.increase_minor() + assert minor_tag == GitTag(0, 1, 0, prefix="v") + + patch_tag = tag.increase_patch() + assert patch_tag == GitTag(0, 0, 1, prefix="v") + + +class TestGetAllTags: + """Test cases for the get_all_tags function.""" + + @patch("devops.git.tag.subprocess.check_output") + def test_get_all_tags_with_multiple_tags( + self, mock_check_output: MagicMock + ) -> None: + """Test retrieving multiple tags from repository.""" + mock_check_output.return_value = "v1.0.0\nv1.1.0\nv2.0.0\n" + + config = GitConfig(tag_prefix="v") + tags = get_all_tags(config=config) + + assert len(tags) == 3 + assert tags[0] == GitTag(1, 0, 0, prefix=config.tag_prefix) + assert tags[1] == GitTag(1, 1, 0, prefix=config.tag_prefix) + assert tags[2] == GitTag(2, 0, 0, prefix=config.tag_prefix) + mock_check_output.assert_called_once_with( + ["git", "tag", "--list"], + stderr=subprocess.DEVNULL, + text=True, + shell=False, + ) + + @patch("devops.git.tag.subprocess.check_output") + def test_get_all_tags_with_single_tag(self, mock_check_output: MagicMock) -> None: + """Test retrieving single tag from repository.""" + mock_check_output.return_value = "1.0.0\n" + + tags = get_all_tags() + + assert len(tags) == 1 + assert tags[0] == GitTag(1, 0, 0, prefix="") + + @patch("devops.git.tag.subprocess.check_output") + def test_get_all_tags_with_empty_repository( + self, mock_check_output: MagicMock + ) -> None: + """Test retrieving tags from empty repository.""" + mock_check_output.return_value = "" + + tags = get_all_tags() + + assert len(tags) == 0 + assert tags == [] + + @patch("devops.git.tag.subprocess.check_output") + def test_get_all_tags_with_whitespace_only( + self, mock_check_output: MagicMock + ) -> None: + """Test retrieving tags when output is only whitespace.""" + mock_check_output.return_value = " \n\n " + + tags = get_all_tags() + + assert len(tags) == 0 + assert tags == [] + + @patch("devops.git.tag.subprocess.check_output") + def test_get_all_tags_subprocess_error_not_allowed( + self, mock_check_output: MagicMock + ) -> None: + """Test get_all_tags raises error on subprocess error when not allowed.""" + mock_check_output.side_effect = subprocess.CalledProcessError(1, "git") + + config = GitConfig(empty_tag_list_allowed=False) + with pytest.raises(GitTagError) as exc_info: + get_all_tags(config) + + msg = ( + "Error retrieving Git tags. " + "Failed to execute git command. Command: 'git tag --list'" + ) + assert msg in str(exc_info.value) + + @patch("devops.git.tag.subprocess.check_output") + def test_get_all_tags_with_invalid_tag_format( + self, mock_check_output: MagicMock + ) -> None: + """Test get_all_tags raises error with invalid tag format.""" + mock_check_output.return_value = "v1.0.0\ninvalid-tag\nv2.0.0\n" + + config = GitConfig(tag_prefix="v") + with pytest.raises(GitTagError) as exc_info: + get_all_tags(config=config) + + assert "Tag 'invalid-tag' does not start with the expected prefix 'v'" in str( + exc_info.value + ) + + @patch("devops.git.tag.subprocess.check_output") + def test_get_all_tags_without_v_prefix(self, mock_check_output: MagicMock) -> None: + """Test get_all_tags handles tags without 'v' prefix.""" + mock_check_output.return_value = "1.0.0\n2.0.0\n" + + tags = get_all_tags() + + assert len(tags) == 2 + assert tags[0] == GitTag(1, 0, 0, prefix="") + assert tags[1] == GitTag(2, 0, 0, prefix="") + + @patch("devops.git.tag.subprocess.check_output") + def test_get_all_tags_mixed_prefix(self, mock_check_output: MagicMock) -> None: + """Test get_all_tags handles mixed prefix tags.""" + mock_check_output.return_value = "1.0.0\n2.0.0\n3.0.0\n" + + tags = get_all_tags() + + assert len(tags) == 3 + assert tags[0] == GitTag(1, 0, 0, prefix="") + assert tags[1] == GitTag(2, 0, 0, prefix="") + assert tags[2] == GitTag(3, 0, 0, prefix="") + + +class TestGetLatestTag: + """Test cases for the get_latest_tag function.""" + + @patch("devops.git.tag.subprocess.check_output") + def test_get_latest_tag_with_multiple_tags( + self, mock_check_output: MagicMock + ) -> None: + """Test getting latest tag from multiple tags.""" + mock_check_output.return_value = "1.0.0\n2.5.3\n2.5.1\n1.9.9\n" + + latest = get_latest_tag() + + assert latest == GitTag(2, 5, 3, GitConfig().tag_prefix) + + @patch("devops.git.tag.subprocess.check_output") + def test_get_latest_tag_with_single_tag(self, mock_check_output: MagicMock) -> None: + """Test getting latest tag when only one tag exists.""" + mock_check_output.return_value = "1.0.0\n" + + latest = get_latest_tag() + + assert latest == GitTag(1, 0, 0, GitConfig().tag_prefix) + + @patch("devops.git.tag.subprocess.check_output") + def test_get_latest_tag_with_no_tags(self, mock_check_output: MagicMock) -> None: + """Test getting latest tag from empty repository returns default tag.""" + mock_check_output.return_value = "" + + latest = get_latest_tag() + + assert latest == GitTag(0, 0, 0, GitConfig().tag_prefix) + + @patch("devops.git.tag.subprocess.check_output") + def test_get_latest_tag_ordering_by_major( + self, mock_check_output: MagicMock + ) -> None: + """Test latest tag is determined by major version.""" + mock_check_output.return_value = "v1.9.9\nv2.0.0\nv1.10.10\n" + + config = GitConfig(tag_prefix="v") + latest = get_latest_tag(config) + + assert latest == GitTag(2, 0, 0, config.tag_prefix) + + @patch("devops.git.tag.subprocess.check_output") + def test_get_latest_tag_ordering_by_minor( + self, mock_check_output: MagicMock + ) -> None: + """Test latest tag is determined by minor version when major is same.""" + mock_check_output.return_value = "1.5.9\n1.10.0\n1.9.10\n" + + latest = get_latest_tag() + + assert latest == GitTag(1, 10, 0, GitConfig().tag_prefix) + + @patch("devops.git.tag.subprocess.check_output") + def test_get_latest_tag_ordering_by_patch( + self, mock_check_output: MagicMock + ) -> None: + """Test latest tag by patch version when major and minor are same.""" + mock_check_output.return_value = "v1.5.9\nv1.5.15\nv1.5.10\n" + + config = GitConfig(tag_prefix="v") + + latest = get_latest_tag(config) + + assert latest == GitTag(1, 5, 15, config.tag_prefix) + + +class TestGitTagError: + """Test cases for the GitTagError exception.""" + + def test_git_tag_error_message(self) -> None: + """Test GitTagError formats message correctly.""" + error = GitTagError("Test error message") + assert str(error) == "GitTagError: Test error message" + assert error.message == "Test error message" + + def test_git_tag_error_inheritance(self) -> None: + """Test GitTagError inherits from Exception.""" + error = GitTagError("Test error") + assert isinstance(error, Exception) + + def test_git_tag_error_can_be_raised(self) -> None: + """Test GitTagError can be raised and caught.""" + msg = "Test error" + with pytest.raises(GitTagError) as exc_info: + raise GitTagError(msg) + + assert "Test error" in str(exc_info.value) diff --git a/tests/rules/test_rules.py b/tests/rules/test_rules.py index a48ece5..378d027 100644 --- a/tests/rules/test_rules.py +++ b/tests/rules/test_rules.py @@ -8,19 +8,22 @@ RuleInputType, RuleType, filter_cpp_rules, + filter_file_rules, filter_line_rules, + is_file_rule, + is_line_rule, ) class TestRuleType: """Tests for RuleType enumeration.""" - def test_rule_type_values(self): + def test_rule_type_values(self) -> None: """Test that RuleType has expected values.""" assert RuleType.GENERAL.value == "GENERAL" assert RuleType.CPP_STYLE.value == "CPP_STYLE" - def test_cpp_rules(self): + def test_cpp_rules(self) -> None: """Test that cpp_rules returns correct set.""" cpp_rules = RuleType.cpp_rules() assert isinstance(cpp_rules, set) @@ -31,7 +34,7 @@ def test_cpp_rules(self): class TestRuleInputType: """Tests for RuleInputType enumeration.""" - def test_rule_input_type_values(self): + def test_rule_input_type_values(self) -> None: """Test that RuleInputType has expected values.""" assert RuleInputType.NONE.value == "NONE" assert RuleInputType.LINE.value == "LINE" @@ -41,16 +44,16 @@ def test_rule_input_type_values(self): class TestRuleCreation: """Tests for Rule class creation.""" - def setup_method(self): + def setup_method(self) -> None: """Reset rule counters before each test.""" Rule.cpp_style_rule_counter = 0 Rule.general_rule_counter = 0 - def test_rule_creation_with_defaults(self): + def test_rule_creation_with_defaults(self) -> None: """Test Rule creation with default parameters.""" rule = Rule( name="test_rule", - func=lambda x: ResultType(ResultTypeEnum.Ok), + func=lambda _x: ResultType(ResultTypeEnum.Ok), ) assert rule.name == "test_rule" assert rule.rule_type == RuleType.GENERAL @@ -58,40 +61,40 @@ def test_rule_creation_with_defaults(self): assert rule.file_types == FileType.all_types() assert rule.description is None - def test_rule_creation_cpp_style(self): + def test_rule_creation_cpp_style(self) -> None: """Test Rule creation with CPP_STYLE type.""" rule = Rule( name="cpp_style_rule", - func=lambda x: ResultType(ResultTypeEnum.Ok), + func=lambda _x: ResultType(ResultTypeEnum.Ok), rule_type=RuleType.CPP_STYLE, ) assert rule.rule_type == RuleType.CPP_STYLE assert rule.file_types == FileType.cpp_types() - def test_rule_creation_with_custom_file_types(self): + def test_rule_creation_with_custom_file_types(self) -> None: """Test Rule creation with custom file types.""" custom_types = {FileType.CPPHeader} rule = Rule( name="custom_rule", - func=lambda x: ResultType(ResultTypeEnum.Ok), + func=lambda _x: ResultType(ResultTypeEnum.Ok), file_types=custom_types, ) assert rule.file_types == custom_types - def test_rule_creation_with_description(self): + def test_rule_creation_with_description(self) -> None: """Test Rule creation with description.""" rule = Rule( name="described_rule", - func=lambda x: ResultType(ResultTypeEnum.Ok), + func=lambda _x: ResultType(ResultTypeEnum.Ok), description="Test description", ) assert rule.description == "Test description" - def test_rule_creation_line_input_type(self): + def test_rule_creation_line_input_type(self) -> None: """Test Rule creation with LINE input type.""" rule = Rule( name="line_rule", - func=lambda x: ResultType(ResultTypeEnum.Ok), + func=lambda _x: ResultType(ResultTypeEnum.Ok), rule_input_type=RuleInputType.LINE, ) assert rule.rule_input_type == RuleInputType.LINE @@ -100,50 +103,50 @@ def test_rule_creation_line_input_type(self): class TestRuleCounters: """Tests for Rule counter functionality.""" - def setup_method(self): + def setup_method(self) -> None: """Reset rule counters before each test.""" Rule.cpp_style_rule_counter = 0 Rule.general_rule_counter = 0 - def test_general_rule_counter_increment(self): + def test_general_rule_counter_increment(self) -> None: """Test that general rule counter increments correctly.""" initial_counter = Rule.general_rule_counter rule = Rule( name="general_rule", - func=lambda x: ResultType(ResultTypeEnum.Ok), + func=lambda _x: ResultType(ResultTypeEnum.Ok), rule_type=RuleType.GENERAL, ) assert rule.rule_identifier == ("GENERAL", initial_counter + 1) assert Rule.general_rule_counter == initial_counter + 1 - def test_cpp_style_rule_counter_increment(self): + def test_cpp_style_rule_counter_increment(self) -> None: """Test that cpp style rule counter increments correctly.""" initial_counter = Rule.cpp_style_rule_counter rule = Rule( name="cpp_style_rule", - func=lambda x: ResultType(ResultTypeEnum.Ok), + func=lambda _x: ResultType(ResultTypeEnum.Ok), rule_type=RuleType.CPP_STYLE, ) assert rule.rule_identifier == ("STYLE", initial_counter + 1) assert Rule.cpp_style_rule_counter == initial_counter + 1 - def test_multiple_rules_increment_counters(self): + def test_multiple_rules_increment_counters(self) -> None: """Test that multiple rules increment counters correctly.""" Rule( name="rule1", - func=lambda x: ResultType(ResultTypeEnum.Ok), + func=lambda _x: ResultType(ResultTypeEnum.Ok), rule_type=RuleType.GENERAL, ) Rule( name="rule2", - func=lambda x: ResultType(ResultTypeEnum.Ok), + func=lambda _x: ResultType(ResultTypeEnum.Ok), rule_type=RuleType.GENERAL, ) assert Rule.general_rule_counter == 2 Rule( name="rule3", - func=lambda x: ResultType(ResultTypeEnum.Ok), + func=lambda _x: ResultType(ResultTypeEnum.Ok), rule_type=RuleType.CPP_STYLE, ) assert Rule.cpp_style_rule_counter == 1 @@ -152,14 +155,15 @@ def test_multiple_rules_increment_counters(self): class TestRuleApplication: """Tests for Rule apply method.""" - def setup_method(self): + def setup_method(self) -> None: """Reset rule counters before each test.""" Rule.cpp_style_rule_counter = 0 Rule.general_rule_counter = 0 - def test_apply_with_string_arg(self): + def test_apply_with_string_arg(self) -> None: """Test Rule apply with string argument.""" - def check_func(line): + + def check_func(line: str) -> ResultType: if "hello" in line: return ResultType(ResultTypeEnum.Ok) return ResultType(ResultTypeEnum.Error, "No hello found") @@ -171,9 +175,10 @@ def check_func(line): result = rule.apply("goodbye world") assert result.value == ResultTypeEnum.Error - def test_apply_with_tuple_arg(self): + def test_apply_with_tuple_arg(self) -> None: """Test Rule apply with tuple argument.""" - def check_func(a, b): + + def check_func(a: str, b: str) -> ResultType: if a == b: return ResultType(ResultTypeEnum.Ok) return ResultType(ResultTypeEnum.Error, "Values don't match") @@ -189,21 +194,21 @@ def check_func(a, b): class TestRuleFiltering: """Tests for rule filtering functions.""" - def setup_method(self): + def setup_method(self) -> None: """Reset rule counters before each test.""" Rule.cpp_style_rule_counter = 0 Rule.general_rule_counter = 0 - def test_filter_cpp_rules(self): + def test_filter_cpp_rules(self) -> None: """Test filter_cpp_rules returns only C++ related rules.""" cpp_rule = Rule( name="cpp_rule", - func=lambda x: ResultType(ResultTypeEnum.Ok), + func=lambda _x: ResultType(ResultTypeEnum.Ok), rule_type=RuleType.CPP_STYLE, ) general_rule = Rule( name="general_rule", - func=lambda x: ResultType(ResultTypeEnum.Ok), + func=lambda _x: ResultType(ResultTypeEnum.Ok), rule_type=RuleType.GENERAL, ) rules = [cpp_rule, general_rule] @@ -213,36 +218,36 @@ def test_filter_cpp_rules(self): assert cpp_rule in filtered assert general_rule not in filtered - def test_filter_cpp_rules_empty_list(self): + def test_filter_cpp_rules_empty_list(self) -> None: """Test filter_cpp_rules with empty list.""" filtered = filter_cpp_rules([]) assert filtered == [] - def test_filter_cpp_rules_no_matches(self): + def test_filter_cpp_rules_no_matches(self) -> None: """Test filter_cpp_rules when no rules match.""" general_rule = Rule( name="general_rule", - func=lambda x: ResultType(ResultTypeEnum.Ok), + func=lambda _x: ResultType(ResultTypeEnum.Ok), rule_type=RuleType.GENERAL, ) filtered = filter_cpp_rules([general_rule]) assert filtered == [] - def test_filter_line_rules(self): + def test_filter_line_rules(self) -> None: """Test filter_line_rules returns only line input rules.""" line_rule = Rule( name="line_rule", - func=lambda x: ResultType(ResultTypeEnum.Ok), + func=lambda _x: ResultType(ResultTypeEnum.Ok), rule_input_type=RuleInputType.LINE, ) file_rule = Rule( name="file_rule", - func=lambda x: ResultType(ResultTypeEnum.Ok), + func=lambda _x: ResultType(ResultTypeEnum.Ok), rule_input_type=RuleInputType.FILE, ) none_rule = Rule( name="none_rule", - func=lambda x: ResultType(ResultTypeEnum.Ok), + func=lambda _x: ResultType(ResultTypeEnum.Ok), rule_input_type=RuleInputType.NONE, ) rules = [line_rule, file_rule, none_rule] @@ -253,7 +258,119 @@ def test_filter_line_rules(self): assert file_rule not in filtered assert none_rule not in filtered - def test_filter_line_rules_empty_list(self): + def test_filter_line_rules_empty_list(self) -> None: """Test filter_line_rules with empty list.""" filtered = filter_line_rules([]) assert filtered == [] + + +class TestFileRuleFiltering: + """Tests for file rule filtering functions.""" + + def setup_method(self) -> None: + """Reset rule counters before each test.""" + Rule.cpp_style_rule_counter = 0 + Rule.general_rule_counter = 0 + + def test_filter_file_rules(self) -> None: + """Test filter_file_rules returns only file input rules.""" + file_rule = Rule( + name="file_rule", + func=lambda _x: ResultType(ResultTypeEnum.Ok), + rule_input_type=RuleInputType.FILE, + ) + line_rule = Rule( + name="line_rule", + func=lambda _x: ResultType(ResultTypeEnum.Ok), + rule_input_type=RuleInputType.LINE, + ) + none_rule = Rule( + name="none_rule", + func=lambda _x: ResultType(ResultTypeEnum.Ok), + rule_input_type=RuleInputType.NONE, + ) + rules = [file_rule, line_rule, none_rule] + + filtered = filter_file_rules(rules) + assert len(filtered) == 1 + assert file_rule in filtered + assert line_rule not in filtered + assert none_rule not in filtered + + def test_filter_file_rules_empty_list(self) -> None: + """Test filter_file_rules with empty list.""" + filtered = filter_file_rules([]) + assert filtered == [] + + def test_filter_file_rules_no_matches(self) -> None: + """Test filter_file_rules when no rules match.""" + line_rule = Rule( + name="line_rule", + func=lambda _x: ResultType(ResultTypeEnum.Ok), + rule_input_type=RuleInputType.LINE, + ) + filtered = filter_file_rules([line_rule]) + assert filtered == [] + + +class TestRuleTypeChecking: + """Tests for is_line_rule and is_file_rule functions.""" + + def setup_method(self) -> None: + """Reset rule counters before each test.""" + Rule.cpp_style_rule_counter = 0 + Rule.general_rule_counter = 0 + + def test_is_line_rule_returns_true(self) -> None: + """Test is_line_rule returns True for line rules.""" + line_rule = Rule( + name="line_rule", + func=lambda _x: ResultType(ResultTypeEnum.Ok), + rule_input_type=RuleInputType.LINE, + ) + assert is_line_rule(line_rule) is True + + def test_is_line_rule_returns_false_for_file_rule(self) -> None: + """Test is_line_rule returns False for file rules.""" + file_rule = Rule( + name="file_rule", + func=lambda _x: ResultType(ResultTypeEnum.Ok), + rule_input_type=RuleInputType.FILE, + ) + assert is_line_rule(file_rule) is False + + def test_is_line_rule_returns_false_for_none_rule(self) -> None: + """Test is_line_rule returns False for NONE rules.""" + none_rule = Rule( + name="none_rule", + func=lambda _x: ResultType(ResultTypeEnum.Ok), + rule_input_type=RuleInputType.NONE, + ) + assert is_line_rule(none_rule) is False + + def test_is_file_rule_returns_true(self) -> None: + """Test is_file_rule returns True for file rules.""" + file_rule = Rule( + name="file_rule", + func=lambda _x: ResultType(ResultTypeEnum.Ok), + rule_input_type=RuleInputType.FILE, + ) + assert is_file_rule(file_rule) is True + + def test_is_file_rule_returns_false_for_line_rule(self) -> None: + """Test is_file_rule returns False for line rules.""" + line_rule = Rule( + name="line_rule", + func=lambda _x: ResultType(ResultTypeEnum.Ok), + rule_input_type=RuleInputType.LINE, + ) + assert is_file_rule(line_rule) is False + + def test_is_file_rule_returns_false_for_none_rule(self) -> None: + """Test is_file_rule returns False for NONE rules.""" + none_rule = Rule( + name="none_rule", + func=lambda _x: ResultType(ResultTypeEnum.Ok), + rule_input_type=RuleInputType.NONE, + ) + assert is_file_rule(none_rule) is False diff --git a/tests/scripts/test_cpp_checks.py b/tests/scripts/test_cpp_checks.py index 4b690aa..67151a6 100644 --- a/tests/scripts/test_cpp_checks.py +++ b/tests/scripts/test_cpp_checks.py @@ -1,28 +1,45 @@ """Unit tests for cpp_checks script module.""" -from unittest.mock import patch +from __future__ import annotations +import typing + +import pytest + +from devops.cpp import build_cpp_rules +from devops.cpp.checks import CppCheckError, run_file_rules, run_line_checks from devops.files import FileType from devops.rules import ResultType, ResultTypeEnum, Rule, RuleInputType, RuleType -from devops.scripts.cpp_checks import main, run_checks, run_line_checks + +if typing.TYPE_CHECKING: + from pathlib import Path + +cpp_rules = build_cpp_rules() class TestRunLineChecks: """Tests for run_line_checks function.""" - def setup_method(self): + def setup_method(self) -> None: """Reset rule counters before each test.""" Rule.cpp_style_rule_counter = 0 Rule.general_rule_counter = 0 - def test_run_line_checks_with_matching_rule(self, tmp_path): - """Test run_line_checks applies rule to matching file type.""" + def test_run_line_checks_with_matching_rule(self, tmp_path: Path) -> None: + """Test run_line_checks applies rule to matching file type. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ test_file = tmp_path / "test.cpp" test_file.write_text("static inline constexpr int x = 42;\n") rule = Rule( name="test_rule", - func=lambda line: ResultType(ResultTypeEnum.Ok), + func=lambda _line: ResultType(ResultTypeEnum.Ok), rule_type=RuleType.CPP_STYLE, rule_input_type=RuleInputType.LINE, ) @@ -31,15 +48,21 @@ def test_run_line_checks_with_matching_rule(self, tmp_path): assert len(results) == 1 assert results[0].value == ResultTypeEnum.Ok - def test_run_line_checks_with_non_matching_file_type(self, tmp_path): - """Test run_line_checks skips rule when file type doesn't match.""" + def test_run_line_checks_with_non_matching_file_type(self, tmp_path: Path) -> None: + """Test run_line_checks skips rule when file type doesn't match. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ test_file = tmp_path / "test.txt" test_file.write_text("test content\n") rule = Rule( name="cpp_only_rule", - func=lambda line: ResultType( - ResultTypeEnum.Error, "Should not run"), + func=lambda _line: ResultType(ResultTypeEnum.Error, "Should not run"), rule_type=RuleType.CPP_STYLE, rule_input_type=RuleInputType.LINE, file_types={FileType.CPPSource}, @@ -48,14 +71,21 @@ def test_run_line_checks_with_non_matching_file_type(self, tmp_path): results = run_line_checks([rule], test_file) assert len(results) == 0 - def test_run_line_checks_multiple_lines(self, tmp_path): - """Test run_line_checks processes all lines.""" + def test_run_line_checks_multiple_lines(self, tmp_path: Path) -> None: + """Test run_line_checks processes all lines. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ test_file = tmp_path / "test.cpp" test_file.write_text("line1\nline2\nline3\n") rule = Rule( name="count_rule", - func=lambda line: ResultType(ResultTypeEnum.Ok), + func=lambda _line: ResultType(ResultTypeEnum.Ok), rule_type=RuleType.CPP_STYLE, rule_input_type=RuleInputType.LINE, ) @@ -63,20 +93,27 @@ def test_run_line_checks_multiple_lines(self, tmp_path): results = run_line_checks([rule], test_file) assert len(results) == 3 - def test_run_line_checks_multiple_rules(self, tmp_path): - """Test run_line_checks applies multiple rules.""" + def test_run_line_checks_multiple_rules(self, tmp_path: Path) -> None: + """Test run_line_checks applies multiple rules. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ test_file = tmp_path / "test.cpp" test_file.write_text("test line\n") rule1 = Rule( name="rule1", - func=lambda line: ResultType(ResultTypeEnum.Ok), + func=lambda _line: ResultType(ResultTypeEnum.Ok), rule_type=RuleType.CPP_STYLE, rule_input_type=RuleInputType.LINE, ) rule2 = Rule( name="rule2", - func=lambda line: ResultType(ResultTypeEnum.Ok), + func=lambda _line: ResultType(ResultTypeEnum.Ok), rule_type=RuleType.CPP_STYLE, rule_input_type=RuleInputType.LINE, ) @@ -84,37 +121,50 @@ def test_run_line_checks_multiple_rules(self, tmp_path): results = run_line_checks([rule1, rule2], test_file) assert len(results) == 2 - def test_run_line_checks_filters_non_line_rules(self, tmp_path): - """Test run_line_checks only applies LINE input type rules.""" + def test_run_line_checks_filters_non_line_rules(self, tmp_path: Path) -> None: + """Test run_line_checks only applies LINE input type rules. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ test_file = tmp_path / "test.cpp" test_file.write_text("test line\n") line_rule = Rule( name="line_rule", - func=lambda line: ResultType(ResultTypeEnum.Ok), + func=lambda _line: ResultType(ResultTypeEnum.Ok), rule_type=RuleType.CPP_STYLE, rule_input_type=RuleInputType.LINE, ) file_rule = Rule( name="file_rule", - func=lambda line: ResultType( - ResultTypeEnum.Error, "Should not run"), + func=lambda _line: ResultType(ResultTypeEnum.Error, "Should not run"), rule_type=RuleType.CPP_STYLE, rule_input_type=RuleInputType.FILE, ) - results = run_line_checks([line_rule, file_rule], test_file) - assert len(results) == 1 - assert results[0].value == ResultTypeEnum.Ok + err_msg = "Non-line rule provided to run_line_checks" + with pytest.raises(CppCheckError, match=err_msg): + run_line_checks([line_rule, file_rule], test_file) + + def test_run_line_checks_empty_file(self, tmp_path: Path) -> None: + """Test run_line_checks handles empty files. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. - def test_run_line_checks_empty_file(self, tmp_path): - """Test run_line_checks handles empty files.""" + """ test_file = tmp_path / "empty.cpp" test_file.write_text("") rule = Rule( name="test_rule", - func=lambda line: ResultType(ResultTypeEnum.Ok), + func=lambda _line: ResultType(ResultTypeEnum.Ok), rule_type=RuleType.CPP_STYLE, rule_input_type=RuleInputType.LINE, ) @@ -122,8 +172,15 @@ def test_run_line_checks_empty_file(self, tmp_path): results = run_line_checks([rule], test_file) assert len(results) == 0 - def test_run_line_checks_no_rules(self, tmp_path): - """Test run_line_checks with no rules.""" + def test_run_line_checks_no_rules(self, tmp_path: Path) -> None: + """Test run_line_checks with no rules. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ test_file = tmp_path / "test.cpp" test_file.write_text("test content\n") @@ -131,155 +188,207 @@ def test_run_line_checks_no_rules(self, tmp_path): assert len(results) == 0 -class TestRunChecks: - """Tests for run_checks function.""" +class TestRunFileRules: + """Tests for run_file_rules function.""" - def setup_method(self): + def setup_method(self) -> None: """Reset rule counters before each test.""" Rule.cpp_style_rule_counter = 0 Rule.general_rule_counter = 0 - @patch("devops.scripts.cpp_checks.get_staged_files") - @patch("devops.scripts.cpp_checks.cpp_check_logger") - def test_run_checks_no_files(self, mock_logger, mock_get_staged): - """Test run_checks logs warning when no files to check.""" - mock_get_staged.return_value = [] + def test_run_file_rules_with_matching_rule(self, tmp_path: Path) -> None: + """Test run_file_rules applies rule to matching file type. - rules = [ - Rule( - name="test_rule", - func=lambda line: ResultType(ResultTypeEnum.Ok), - rule_input_type=RuleInputType.LINE, - ) - ] + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. - run_checks(rules) + """ + test_file = tmp_path / "test.cpp" + test_file.write_text("int main() { return 0; }\n") - mock_logger.warning.assert_called_once_with("No files to check.") + rule = Rule( + name="file_test_rule", + func=lambda _content: ResultType(ResultTypeEnum.Ok), + rule_type=RuleType.CPP_STYLE, + rule_input_type=RuleInputType.FILE, + ) - @patch("devops.scripts.cpp_checks.get_staged_files") - @patch("devops.scripts.cpp_checks.cpp_check_logger") - def test_run_checks_staged_mode(self, mock_logger, mock_get_staged, tmp_path): - """Test run_checks in staged files mode.""" - test_file = tmp_path / "test.cpp" + results = run_file_rules([rule], test_file) + assert len(results) == 1 + assert results[0].value == ResultTypeEnum.Ok + + def test_run_file_rules_with_non_matching_file_type(self, tmp_path: Path) -> None: + """Test run_file_rules skips rule when file type doesn't match. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + test_file = tmp_path / "test.txt" test_file.write_text("test content\n") - mock_get_staged.return_value = [test_file] rule = Rule( - name="test_rule", - func=lambda line: ResultType(ResultTypeEnum.Ok), + name="cpp_only_file_rule", + func=lambda _content: ResultType(ResultTypeEnum.Error, "Should not run"), rule_type=RuleType.CPP_STYLE, - rule_input_type=RuleInputType.LINE, + rule_input_type=RuleInputType.FILE, + file_types={FileType.CPPSource}, ) - with patch.object(Rule, "cpp_style_rule_counter", 0): - with patch.object(Rule, "general_rule_counter", 0): - run_checks([rule]) + results = run_file_rules([rule], test_file) + assert len(results) == 0 + + def test_run_file_rules_multiple_rules(self, tmp_path: Path) -> None: + """Test run_file_rules applies multiple rules. - mock_logger.info.assert_called_with( - "Running checks on staged files...") + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. - @patch("devops.scripts.cpp_checks.get_files_in_dirs") - @patch("devops.scripts.cpp_checks.cpp_check_logger") - @patch("sys.argv", ["cpp_checks", "full"]) - def test_run_checks_full_mode(self, mock_logger, mock_get_files, tmp_path): - """Test run_checks in full mode.""" + """ test_file = tmp_path / "test.cpp" - test_file.write_text("test content\n") - mock_get_files.return_value = [test_file] + test_file.write_text("int main() {}\n") - rule = Rule( - name="test_rule", - func=lambda line: ResultType(ResultTypeEnum.Ok), + rule1 = Rule( + name="file_rule1", + func=lambda _content: ResultType(ResultTypeEnum.Ok), rule_type=RuleType.CPP_STYLE, - rule_input_type=RuleInputType.LINE, + rule_input_type=RuleInputType.FILE, + ) + rule2 = Rule( + name="file_rule2", + func=lambda _content: ResultType(ResultTypeEnum.Ok), + rule_type=RuleType.CPP_STYLE, + rule_input_type=RuleInputType.FILE, ) - with patch.object(Rule, "cpp_style_rule_counter", 0): - with patch.object(Rule, "general_rule_counter", 0): - run_checks([rule]) + results = run_file_rules([rule1, rule2], test_file) + assert len(results) == 2 + + def test_run_file_rules_with_error_result(self, tmp_path: Path) -> None: + """Test run_file_rules returns error results. - mock_logger.info.assert_called_with("Running full checks...") + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. - @patch("devops.scripts.cpp_checks.get_staged_files") - @patch("devops.scripts.cpp_checks.cpp_check_logger") - def test_run_checks_with_errors(self, mock_logger, mock_get_staged, tmp_path): - """Test run_checks logs errors when rule fails.""" + """ test_file = tmp_path / "test.cpp" - test_file.write_text("bad code\n") - mock_get_staged.return_value = [test_file] + test_file.write_text("bad content\n") rule = Rule( - name="failing_rule", - func=lambda line: ResultType(ResultTypeEnum.Error, "Error found"), + name="error_rule", + func=lambda _content: ResultType( + ResultTypeEnum.Error, "Content validation failed" + ), rule_type=RuleType.CPP_STYLE, - rule_input_type=RuleInputType.LINE, + rule_input_type=RuleInputType.FILE, ) - with patch.object(Rule, "cpp_style_rule_counter", 0): - with patch.object(Rule, "general_rule_counter", 0): - run_checks([rule]) - - assert mock_logger.error.called + results = run_file_rules([rule], test_file) + assert len(results) == 1 + assert results[0].value == ResultTypeEnum.Error + assert results[0].description == "Content validation failed" - @patch("devops.scripts.cpp_checks.get_staged_files") - @patch("devops.scripts.cpp_checks.cpp_check_logger") - def test_run_checks_stops_on_first_file_with_errors( - self, mock_logger, mock_get_staged, tmp_path - ): - """Test run_checks returns after first file with errors.""" - file1 = tmp_path / "test1.cpp" - file1.write_text("bad code\n") - file2 = tmp_path / "test2.cpp" - file2.write_text("more code\n") - mock_get_staged.return_value = [file1, file2] + def test_run_file_rules_filters_non_file_rules(self, tmp_path: Path) -> None: + """Test run_file_rules rejects non-FILE input type rules. - call_count = [0] + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. - def counting_func(line): - call_count[0] += 1 - return ResultType(ResultTypeEnum.Error, "Error") + """ + test_file = tmp_path / "test.cpp" + test_file.write_text("test content\n") - rule = Rule( - name="counting_rule", - func=counting_func, + file_rule = Rule( + name="file_rule", + func=lambda _content: ResultType(ResultTypeEnum.Ok), + rule_type=RuleType.CPP_STYLE, + rule_input_type=RuleInputType.FILE, + ) + line_rule = Rule( + name="line_rule", + func=lambda _line: ResultType(ResultTypeEnum.Error, "Should not run"), rule_type=RuleType.CPP_STYLE, rule_input_type=RuleInputType.LINE, ) - with patch.object(Rule, "cpp_style_rule_counter", 0): - with patch.object(Rule, "general_rule_counter", 0): - run_checks([rule]) + err_msg = "Non-file rule provided to run_file_rules" + with pytest.raises(CppCheckError, match=err_msg): + run_file_rules([file_rule, line_rule], test_file) - # Should stop after first file - assert call_count[0] == 1 + def test_run_file_rules_empty_file(self, tmp_path: Path) -> None: + """Test run_file_rules handles empty files. + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. -class TestMain: - """Tests for main function.""" + """ + test_file = tmp_path / "empty.cpp" + test_file.write_text("") - def setup_method(self): - """Reset rule counters before each test.""" - Rule.cpp_style_rule_counter = 0 - Rule.general_rule_counter = 0 + rule = Rule( + name="file_test_rule", + func=lambda _content: ResultType(ResultTypeEnum.Ok), + rule_type=RuleType.CPP_STYLE, + rule_input_type=RuleInputType.FILE, + ) - @patch("devops.scripts.cpp_checks.run_checks") - def test_main_calls_run_checks(self, mock_run_checks): - """Test main function calls run_checks with cpp_rules.""" - from devops.cpp import cpp_rules + results = run_file_rules([rule], test_file) + assert len(results) == 1 + assert results[0].value == ResultTypeEnum.Ok - main() + def test_run_file_rules_no_rules(self, tmp_path: Path) -> None: + """Test run_file_rules with no rules. - mock_run_checks.assert_called_once_with(cpp_rules) + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. - @patch("devops.scripts.cpp_checks.get_staged_files") - @patch("devops.scripts.cpp_checks.cpp_check_logger") - def test_main_integration(self, mock_logger, mock_get_staged, tmp_path): - """Test main function integration.""" + """ test_file = tmp_path / "test.cpp" - test_file.write_text("static inline constexpr int x = 42;\n") - mock_get_staged.return_value = [test_file] + test_file.write_text("test content\n") + + results = run_file_rules([], test_file) + assert len(results) == 0 + + def test_run_file_rules_receives_full_content(self, tmp_path: Path) -> None: + """Test run_file_rules passes full file content to rule. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + test_file = tmp_path / "test.cpp" + expected_content = "line1\nline2\nline3\n" + test_file.write_text(expected_content) + + received_content = [] - main() + def capture_content(content: str) -> ResultType: + received_content.append(content) + return ResultType(ResultTypeEnum.Ok) + + rule = Rule( + name="capture_rule", + func=capture_content, + rule_type=RuleType.CPP_STYLE, + rule_input_type=RuleInputType.FILE, + ) - mock_logger.info.assert_called() + run_file_rules([rule], test_file) + assert len(received_content) == 1 + assert received_content[0] == expected_content diff --git a/tests/scripts/test_cpp_checks_cli.py b/tests/scripts/test_cpp_checks_cli.py new file mode 100644 index 0000000..30c9093 --- /dev/null +++ b/tests/scripts/test_cpp_checks_cli.py @@ -0,0 +1,152 @@ +"""Tests for cpp_checks CLI script.""" + +from __future__ import annotations + +import re +import typing +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from devops.scripts.cpp_checks import app + +if typing.TYPE_CHECKING: + from pathlib import Path + +runner = CliRunner() + + +class TestCppChecksCLI: + """Tests for cpp_checks Typer CLI command.""" + + def test_cpp_checks_command_exists(self) -> None: + """Test that cpp_checks command is registered.""" + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "C++ code quality checks" in result.stdout + + def test_cpp_checks_runs_without_license_header_arg(self) -> None: + """Test cpp_checks command runs without license_header argument.""" + with ( + patch("devops.scripts.cpp_checks.build_cpp_rules") as mock_build, + patch("devops.scripts.cpp_checks.run_cpp_checks") as mock_run, + ): + mock_build.return_value = [] + mock_run.return_value = None + + result = runner.invoke(app) + + # Command should execute successfully + assert result.exit_code == 0 + # Should call build_cpp_rules and run_cpp_checks exactly once + assert mock_build.call_count == 1 + assert mock_run.call_count == 1 + + def test_cpp_checks_with_license_header_argument(self, tmp_path: Path) -> None: + """Test cpp_checks command with license_header argument. + + Parameters + ---------- + tmp_path: Path + Temporary path for creating test files. + + """ + # Create a license header file + header_file = tmp_path / "header.txt" + header_file.write_text("// Copyright\n") + + with ( + patch("devops.scripts.cpp_checks.build_cpp_rules") as mock_build, + patch("devops.scripts.cpp_checks.run_cpp_checks") as mock_run, + ): + mock_build.return_value = [] + mock_run.return_value = None + + result = runner.invoke(app, ["--license-header", str(header_file)]) + + # Command should execute successfully + assert result.exit_code == 0 + # Should call with the provided header file + assert mock_build.call_count == 1 + call_config = mock_build.call_args[0][0] + assert call_config.license_header == str(header_file) + + def test_cpp_checks_uses_global_config_when_no_arg(self) -> None: + """Test cpp_checks uses global config when no license_header provided.""" + with ( + patch("devops.scripts.cpp_checks.build_cpp_rules") as mock_build, + patch("devops.scripts.cpp_checks.run_cpp_checks") as mock_run, + ): + mock_build.return_value = [] + mock_run.return_value = None + + result = runner.invoke(app) + + assert result.exit_code == 0 + # Should use the global config's license_header + assert mock_build.call_count == 1 + + def test_cpp_checks_passes_config_to_run_cpp_checks(self) -> None: + """Test cpp_checks passes configuration to run_cpp_checks.""" + with ( + patch("devops.scripts.cpp_checks.build_cpp_rules") as mock_build, + patch("devops.scripts.cpp_checks.run_cpp_checks") as mock_run, + ): + mock_build.return_value = [] + mock_run.return_value = None + + result = runner.invoke(app) + + assert result.exit_code == 0 + # Should pass config to run_cpp_checks + assert mock_run.call_count == 1 + assert len(mock_run.call_args[0]) == 2 # rules and config + + def test_cpp_checks_passes_rules_to_run_cpp_checks(self) -> None: + """Test cpp_checks passes rules to run_cpp_checks.""" + with ( + patch("devops.scripts.cpp_checks.build_cpp_rules") as mock_build, + patch("devops.scripts.cpp_checks.run_cpp_checks") as mock_run, + ): + mock_rules = [MagicMock(), MagicMock()] + mock_build.return_value = mock_rules + mock_run.return_value = None + + result = runner.invoke(app) + + assert result.exit_code == 0 + # Should pass the rules from build_cpp_rules to run_cpp_checks + assert mock_run.call_count == 1 + assert mock_run.call_args[0][0] == mock_rules + + def test_cpp_checks_creates_config_with_replace(self) -> None: + """Test cpp_checks creates config using dataclass replace.""" + with ( + patch("devops.scripts.cpp_checks.build_cpp_rules") as mock_build, + patch("devops.scripts.cpp_checks.run_cpp_checks") as mock_run, + patch("devops.scripts.cpp_checks.replace") as mock_replace, + patch("devops.scripts.cpp_checks.__GLOBAL_CONFIG__") as mock_global, + ): + mock_build.return_value = [] + mock_run.return_value = None + mock_replace.return_value = MagicMock() + + result = runner.invoke(app, ["--license-header", "/path/to/header.txt"]) + + assert result.exit_code == 0 + # Should use replace to create new config + assert mock_replace.call_count == 1 + # First argument should be the global cpp config + assert mock_replace.call_args[0][0] == mock_global.cpp + # Should specify license_header in kwargs + assert mock_replace.call_args[1]["license_header"] == "/path/to/header.txt" + + def test_cpp_checks_command_name(self) -> None: + """Test cpp_checks command has correct name in CLI.""" + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + # Check that help shows the license-header option + # (strip ANSI codes for comparison) + + clean_output = re.sub(r"\x1b\[[0-9;]*m", "", result.stdout) + assert "license-header" in clean_output.lower() diff --git a/tests/test_init_config.py b/tests/test_init_config.py new file mode 100644 index 0000000..821931e --- /dev/null +++ b/tests/test_init_config.py @@ -0,0 +1,136 @@ +"""Tests for devops.__init__.init_config function.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from devops.config import init_config +from devops.config.config import GlobalConfig + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + from _pytest.logging import LogCaptureFixture + + +def test_init_config_single_config_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test init_config when a single config file exists.""" + # Create a single config file + config_file = tmp_path / "devops.toml" + config_file.write_text('[exclude]\nbuggy_cpp_macros = ["MACRO1"]\n') + + # Change to the temp directory so the config file is found + monkeypatch.chdir(tmp_path) + + # Call init_config + config = init_config() + + # Verify the config was loaded correctly + assert isinstance(config, GlobalConfig) + assert config.exclude.buggy_cpp_macros == ["MACRO1"] + + +def test_init_config_single_hidden_config_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test init_config when a single hidden config file exists.""" + # Create a single hidden config file + config_file = tmp_path / ".devops.toml" + config_file.write_text('[exclude]\nbuggy_cpp_macros = ["MACRO2"]\n') + + # Change to the temp directory so the config file is found + monkeypatch.chdir(tmp_path) + + # Call init_config + config = init_config() + + # Verify the config was loaded correctly + assert isinstance(config, GlobalConfig) + assert config.exclude.buggy_cpp_macros == ["MACRO2"] + + +def test_init_config_no_config_file( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: LogCaptureFixture, +) -> None: + """Test init_config when no config file exists.""" + # Change to the temp directory which has no config files + monkeypatch.chdir(tmp_path) + + # Capture logs + with caplog.at_level(logging.DEBUG): + config = init_config() + + # Verify default config is returned + assert isinstance(config, GlobalConfig) + assert config.exclude.buggy_cpp_macros == [] + + # Verify the debug message was logged + assert "No config file found. Using default configuration." in caplog.text + + +def test_init_config_multiple_config_files( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: LogCaptureFixture, +) -> None: + """Test init_config when multiple config files exist.""" + # Create multiple config files + config_file1 = tmp_path / "devops.toml" + config_file1.write_text('[exclude]\nbuggy_cpp_macros = ["MACRO1"]\n') + + config_file2 = tmp_path / ".devops.toml" + config_file2.write_text('[exclude]\nbuggy_cpp_macros = ["MACRO2"]\n') + + # Change to the temp directory + monkeypatch.chdir(tmp_path) + + # Capture logs at WARNING level + with caplog.at_level(logging.WARNING): + config = init_config() + + # Verify default config is returned (since multiple files were found) + assert isinstance(config, GlobalConfig) + assert config.exclude.buggy_cpp_macros == [] + + # Verify the warning message was logged + assert "Multiple config files found" in caplog.text + assert "devops.toml" in caplog.text + assert ".devops.toml" in caplog.text + assert "Using no config file." in caplog.text + + +def test_init_config_multiple_config_files_warning_contains_all_files( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: LogCaptureFixture, +) -> None: + """Test that the warning message lists all found config files.""" + # Create multiple config files + config_file1 = tmp_path / "devops.toml" + config_file1.write_text('[exclude]\nbuggy_cpp_macros = ["MACRO1"]\n') + + config_file2 = tmp_path / ".devops.toml" + config_file2.write_text('[exclude]\nbuggy_cpp_macros = ["MACRO2"]\n') + + # Change to the temp directory + monkeypatch.chdir(tmp_path) + + # Capture logs at WARNING level + with caplog.at_level(logging.WARNING): + init_config() + + # Verify both file names are in the warning message + warning_messages = [ + record.message for record in caplog.records if record.levelname == "WARNING" + ] + assert len(warning_messages) == 1 + + warning_msg = warning_messages[0] + assert "devops.toml" in warning_msg + assert ".devops.toml" in warning_msg diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index f953e41..2405e47 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -1,8 +1,11 @@ -from devops.utils import check_key_sequence_ordered +"""Tests for devops.utils.check_key_sequence_ordered.""" + from devops.rules import ResultTypeEnum +from devops.utils import check_key_sequence_ordered -def test_check_key_sequence_ordered(): +def test_check_key_sequence_ordered() -> None: + """Test the basic functionality of check_key_sequence_ordered.""" keys = "static inline constexpr" line = "static inline constexpr int x = 42;" result = check_key_sequence_ordered(keys, line) @@ -13,7 +16,7 @@ def test_check_key_sequence_ordered(): assert result.value == ResultTypeEnum.Error -def test_check_key_sequence_ordered_first_key_not_in_line(): +def test_check_key_sequence_ordered_first_key_not_in_line() -> None: """Test when the first key doesn't appear in the line.""" keys = "static inline constexpr" # Line doesn't contain 'static' (first key) @@ -23,7 +26,7 @@ def test_check_key_sequence_ordered_first_key_not_in_line(): assert result.value == ResultTypeEnum.Ok -def test_check_key_sequence_ordered_no_keys_in_line(): +def test_check_key_sequence_ordered_no_keys_in_line() -> None: """Test when none of the keys appear in the line.""" keys = "static inline constexpr" line = "int x = 42;" @@ -31,7 +34,7 @@ def test_check_key_sequence_ordered_no_keys_in_line(): assert result.value == ResultTypeEnum.Ok -def test_check_key_sequence_ordered_keys_multiple_positions(): +def test_check_key_sequence_ordered_keys_multiple_positions() -> None: """Test when keys appear multiple times in different positions.""" keys = "static inline" # 'static' appears twice, second occurrence is followed by 'inline' @@ -41,7 +44,7 @@ def test_check_key_sequence_ordered_keys_multiple_positions(): assert result.value == ResultTypeEnum.Ok -def test_check_key_sequence_ordered_keys_multiple_positions_wrong_order(): +def test_check_key_sequence_ordered_keys_multiple_positions_wrong_order() -> None: """Test when keys appear multiple times but never in correct sequence.""" keys = "static inline constexpr" # 'static' at positions 0, 3; 'inline' at 1; 'constexpr' at 4 @@ -52,7 +55,7 @@ def test_check_key_sequence_ordered_keys_multiple_positions_wrong_order(): assert result.value == ResultTypeEnum.Error -def test_check_key_sequence_ordered_correct_order_not_consecutive(): +def test_check_key_sequence_ordered_correct_order_not_consecutive() -> None: """Test when keys appear in correct order but not consecutively.""" keys = "static inline constexpr" # Keys in order but with other tokens between them @@ -62,7 +65,7 @@ def test_check_key_sequence_ordered_correct_order_not_consecutive(): assert result.value == ResultTypeEnum.Error -def test_check_key_sequence_ordered_single_key(): +def test_check_key_sequence_ordered_single_key() -> None: """Test with a single key.""" keys = "static" line = "static int x = 42;" @@ -70,7 +73,7 @@ def test_check_key_sequence_ordered_single_key(): assert result.value == ResultTypeEnum.Ok -def test_check_key_sequence_ordered_single_key_not_present(): +def test_check_key_sequence_ordered_single_key_not_present() -> None: """Test with a single key that's not present.""" keys = "static" line = "int x = 42;" @@ -78,7 +81,7 @@ def test_check_key_sequence_ordered_single_key_not_present(): assert result.value == ResultTypeEnum.Ok -def test_check_key_sequence_ordered_partial_keys_present(): +def test_check_key_sequence_ordered_partial_keys_present() -> None: """Test when only some keys from the sequence are present.""" keys = "static inline constexpr" # Only 'static' and 'constexpr' are present, 'inline' is missing