diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e651f7fa..9c785561 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,4 @@ +default_stages: [pre-commit] # Exclude some file types from automatic code style exclude: \.(json|csv)$ repos: @@ -29,6 +30,16 @@ repos: # Run the formatter - id: ruff-format + - repo: local + hooks: + # Run EOS custom file name linter + - id: eos-filename-linter + name: EOS filename linter + entry: uv run --locked --exact --extra dev python scripts/lint_filenames.py + language: system + types: [python] + pass_filenames: true + # --- Static type checking --- - repo: local hooks: diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..3bbd600f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,171 @@ +# AGENTS.md — Akkudoktor-EOS + +## Project Overview + +Akkudoktor-EOS is a Python-based energy management and optimization system. It provides energy forecasts, optimization, energy management plans, and integrations with local energy devices and controllers. + +The project prioritizes reliable local operation, clear interfaces, maintainable code, testability, and type safety. + +## General Development Guidelines + +- Follow the existing architecture and coding conventions. +- Prefer small, focused changes that preserve established interfaces and behavior. +- Use type annotations for functions, methods, and relevant variables. +- Write Google-style docstrings for modules, classes, and public functions and methods, following the surrounding code. +- Keep code compatible with the Python version supported by the project. Consult `pyproject.toml` and the project configuration rather than assuming a particular interpreter version. +- Avoid introducing unnecessary dependencies. +- Use descriptive names that communicate purpose and domain meaning. +- Do not use production `assert` statements for input validation or runtime error handling. Use explicit exceptions or appropriate error handling instead. +- Use the project's existing logging facilities and conventions. +- Follow existing patterns for configuration, validation, error reporting, and resource management. +- Preserve compatibility with the project's existing tests and supported integrations unless a change explicitly requires otherwise. + +## Filename Naming Rules + +**Filename conventions are mandatory. All new files and renamed files must comply with the filename rules configured in `pyproject.toml`.** + +Before creating, renaming, or proposing a new file: + +1. Inspect the `[tool.eos-filename-linter]` configuration in `pyproject.toml`. +2. Identify the first matching rule for the file's repository-relative path. +3. Follow that rule's regular expression, allowed-name exceptions, exclusions, and maximum filename length. +4. Do not invent alternative naming conventions or infer that a filename is acceptable merely because it is valid Python. +5. Do not change the linter configuration or add exceptions simply to accommodate a nonconforming filename without a clear justification. + +The custom filename linter is the authoritative source for repository filename conventions. If this document and the linter configuration appear inconsistent, follow the configuration and recommend updating this document when appropriate. + +### Python source modules + +Python modules under `src/akkudoktoreos/` must follow the naming pattern configured for that directory. + +In particular, where the configured rule requires lowercase alphanumeric names, use lowercase letters and digits without underscores or hyphens. Preserve explicitly permitted special filenames, such as `__init__.py`, when listed in `allowed_names`. + +Do not rename existing modules merely to impose a personal preference. Follow the configured rule when adding files or changing filenames. + +### Pytest modules + +Test modules under `tests/` must follow the applicable test filename rule. + +Where the configured rule requires the `test_` prefix followed by lowercase letters and digits only: + +- Begin the filename with `test_`. +- Use lowercase letters and digits after the prefix. +- Do not use underscores or hyphens after the prefix. +- Keep the `.py` extension. +- Respect any configured exceptions and exclusions. + +Examples: + +| Valid | Invalid | +|---|---| +| `test_adapterdevice.py` | `test_adapter_device.py` | +| `test_geneticforecastcoverage.py` | `test_genetic_forecast_coverage.py` | +| `test_invertercontrol.py` | `test_inverter-control.py` | + +If a separate rule applies to files such as `single_test_*.py`, follow that rule instead. + +### Python scripts + +Scripts under `scripts/` must follow the applicable script naming rule. + +Where the configured rule requires lowercase snake_case: + +- Use lowercase letters and digits. +- Use underscores to separate words where appropriate. +- Do not use hyphens. +- Keep the `.py` extension. + +Examples: + +- Valid: `lint_filenames.py` +- Invalid: `lint-filenames.py` +- Invalid: `LintFilenames.py` + +### Filename length + +Respect the maximum filename length configured by the applicable rule. If a rule does not override the global limit, use the global default defined in `pyproject.toml` or by the linter. + +The length limit includes the file extension. + +### Verify filenames + +When adding or renaming files, run the filename linter before completing the change: + +```bash +uv run python scripts/lint_filenames.py +``` + +To check specific files: + +```bash +uv run python scripts/lint_filenames.py path/to/file.py +``` + +When the linter reports a violation: + +- Correct the filename where appropriate. +- Review the suggested name as a diagnostic hint; do not assume it is always semantically ideal. +- Update imports, references, test commands, and other affected paths if a file is renamed. +- Never rename files automatically without reviewing the consequences. + +Do not weaken filename rules merely to make the check pass. + +## Python Quality and Type Checking + +- Follow the project's Ruff configuration for linting and formatting. +- Keep code compatible with the project's mypy configuration. +- Avoid introducing new type-checking errors. +- Prefer explicit, narrow types over unnecessary use of `Any`. +- Handle optional values and error cases explicitly. +- Keep abstractions proportionate to the problem; avoid unnecessary complexity. + +Use the project's configured tools rather than introducing competing formatting or linting conventions. + +## Testing + +- Add or update pytest tests for behavior changes and bug fixes. +- **Prefer adding tests to existing test classes in existing test modules whenever possible.** Extend the relevant class or module rather than creating additional test files. +- Create a new test module only when the functionality represents a distinct aspect that does not fit naturally into an existing test module, or when it targets a different component or test target. +- Follow existing test conventions, fixtures, and markers. +- Prefer deterministic, isolated tests; mock external services and devices where appropriate. +- Cover relevant boundary conditions and error paths. +- Do not weaken tests merely to accommodate implementation defects. + +Run relevant tests and checks before completing changes. Use the project's configured commands and environments. + +## Configuration and Compatibility + +- Follow existing Pydantic and configuration-management patterns. +- Validate external input at appropriate boundaries. +- Preserve established configuration behavior and migration compatibility. +- Avoid silently changing defaults, persisted configuration formats, or public API behavior. +- Update tests and documentation when configuration behavior changes. + +## Energy Management and Device Integration + +- Keep optimization logic separate from device-specific communication where practical. +- Follow existing adapter and controller interfaces. +- Respect the distinction between long-horizon energy optimization and fast local device control. +- Handle communication failures, device availability, and invalid measurements explicitly. +- Avoid introducing cloud dependencies into workflows intended to operate locally. +- Preserve existing energy sign conventions, units, and conversion semantics. Verify them before changing power, energy, or charging behavior. + +## Documentation + +- Update documentation when changing public interfaces, configuration, command-line behavior, or architectural decisions. +- Keep examples consistent with actual configuration and code. +- Document non-obvious constraints and compatibility requirements. +- Ensure filenames used in documentation match the actual repository paths. + +## Completion Checklist + +Before considering a change complete: + +- [ ] New and renamed files comply with the filename rules in `pyproject.toml`. +- [ ] The filename linter passes. +- [ ] Tests have been added or updated as appropriate. +- [ ] Relevant pytest tests pass. +- [ ] Ruff checks pass. +- [ ] Mypy checks pass where applicable. +- [ ] Configuration and documentation have been updated when necessary. +- [ ] No unrelated changes or unnecessary exceptions have been introduced. diff --git a/Dockerfile b/Dockerfile index 3c168d0b..a92638b3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -122,7 +122,7 @@ EXPOSE 8503 EXPOSE 8504 HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ - CMD ["python", "-m", "akkudoktoreos.server.container_healthcheck"] + CMD ["python", "-m", "akkudoktoreos.server.containerhealthcheck"] # Ensure EOS and EOSdash bind to 0.0.0.0 # EOS is started with root privileges. EOS will drop root privileges and switch to user eos. diff --git a/pyproject.toml b/pyproject.toml index bd673c90..055c5885 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -208,6 +208,48 @@ ignore_missing_imports = true module = ["cachebox", "fasthtml.*", "monsterui.*", "pvlib.*", "statsmodels.*"] ignore_missing_imports = true +[tool.eos-filename-linter] +max_filename_length = 64 + +exclude = [ + ".git/**", + ".venv/**", + "docs/_build/**", + "**/*.egg-info/**", +] + +# Test modules except single test modules. +[[tool.eos-filename-linter.rules]] +name = "pytest module: test_ prefix, lowercase letters and digits without underscores or hyphens" +paths = ["tests/**/*.py"] +pattern = "^test_[a-z0-9]+\\.py$" +allowed_names = ["conftest.py"] +exclude = ["tests/single_test_*.py"] +suggestion = "test_prefix_alnum" + +# Single test modules. +[[tool.eos-filename-linter.rules]] +name = "Single pytest modules" +paths = ["tests/single_test_*.py"] +pattern = "^single_test_[a-z0-9]+\\.py$" +suggestion = "single_test_prefix_alnum" + +# Production Python modules. +[[tool.eos-filename-linter.rules]] +name = "python source module: lowercase letters and digits without underscores or hyphens" +paths = ["src/akkudoktoreos/**/*.py"] +pattern = "^[a-z][a-z0-9]*\\.py$" +allowed_names = ["__init__.py", "_version_date.py"] +suggestion = "lowercase_alnum" + +# Standalone scripts. +[[tool.eos-filename-linter.rules]] +name = "python script: lowercase letters, digits, underscores without hyphens" +paths = ["scripts/**/*.py"] +pattern = "^[a-z][a-z0-9_]*\\.py$" +allowed_names = ["__init__.py"] +suggestion = "lowercase_snake" + [tool.commitizen] # Only used as linter name = "cz_conventional_commits" diff --git a/scripts/lint_filenames.py b/scripts/lint_filenames.py new file mode 100644 index 00000000..98535126 --- /dev/null +++ b/scripts/lint_filenames.py @@ -0,0 +1,568 @@ +"""Lint filenames against repository-specific naming conventions. + +This script validates filenames using rules configured in ``pyproject.toml``. +Rules can target directories, enforce regular-expression patterns, allow +specific filenames, set maximum filename lengths, and exclude paths. + +Usage: + Check all applicable repository files:: + + uv run python scripts/lint_filenames.py + + Check selected files:: + + uv run python scripts/lint_filenames.py tests/test_adapter.py + + Specify a configuration file:: + + uv run python scripts/lint_filenames.py --config pyproject.toml + + Run the pre-commit hook:: + + uv run pre-commit run eos-filename-linter --all-files + +Configuration: + Configure ``[tool.eos-filename-linter]`` in ``pyproject.toml``. + + Global ``exclude`` patterns exclude files and directories. + ``max_filename_length`` sets the default maximum filename length, + including the extension. The default is 64 characters. + + Each ``[[tool.eos-filename-linter.rules]]`` entry defines a rule with: + name: Unique descriptive rule name. + paths: Repository-relative glob patterns. + pattern: Regular expression matched against the filename. + allowed_names: Optional filenames exempt from the pattern. + exclude: Optional additional exclusion patterns. + max_filename_length: Optional rule-specific length limit. + suggestion: Optional filename normalization strategy: + test_prefix_alnum: "test_" followed by lowercase letters and digits. + single_test_prefix_alnum: "single_test_" followed by lowercase + letters and digits. + lowercase_alnum: Lowercase letters and digits only, beginning + with a letter. + lowercase_snake: Lowercase snake_case. + + Rules are evaluated in configuration order; the first applicable rule wins. + A rule that excludes a path is skipped, allowing a later rule to apply. + Glob patterns support ``*``, ``?``, and ``**``. + + Test filenames must use the ``test_`` prefix followed by lowercase + letters and digits only. For example, ``test_adapter123.py`` is valid, + while ``test_adapter_device.py`` is not. + + Excluded paths are not checked. During repository-wide scans, excluded + directories are pruned to avoid traversing their contents. + +Exit status: + 0: No violations found. + 1: One or more filename violations found. + 2: Invalid configuration, invalid arguments, or an operational error. + +The linter never renames files. Suggestions are diagnostic hints only. +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +import tomllib +from pathlib import Path +from typing import Any + +DEFAULT_MAX_FILENAME_LENGTH = 64 +PYTHON_EXTENSION = ".py" + +def _glob_to_regex(pattern: str) -> re.Pattern[str]: + """Compile a repository-relative glob into a regular expression. + + Args: + pattern: A slash-separated glob pattern. + + Returns: + A regular expression that matches the entire relative path. + """ + pattern = pattern.replace("\\", "/") + parts: list[str] = [] + index = 0 + + while index < len(pattern): + if pattern[index : index + 2] == "**": + index += 2 + if index < len(pattern) and pattern[index] == "/": + parts.append("(?:.*/)?") + index += 1 + else: + parts.append(".*") + elif pattern[index] == "*": + parts.append("[^/]*") + index += 1 + elif pattern[index] == "?": + parts.append("[^/]") + index += 1 + else: + parts.append(re.escape(pattern[index])) + index += 1 + + return re.compile("^" + "".join(parts) + "$") + +def _matches_any(path: str, patterns: list[str]) -> bool: + """Check whether a path matches at least one glob pattern. + + Args: + path: A repository-relative POSIX path. + patterns: Glob patterns to evaluate. + + Returns: + True if at least one pattern matches. + """ + return any(_glob_to_regex(pattern).fullmatch(path) for pattern in patterns) + +def _is_excluded(path: str, patterns: list[str]) -> bool: + """Check whether a file or directory is excluded. + + A directory exclusion also excludes every descendant of that directory. + + Args: + path: A repository-relative POSIX path. + patterns: Glob patterns to evaluate. + + Returns: + True if the path is excluded. + """ + normalized = path.strip("/") + for pattern in patterns: + normalized_pattern = pattern.replace("\\", "/").strip("/") + if _matches_any(normalized, [normalized_pattern]): + return True + + # An exclusion such as "docs/generated/**" should also match + # the directory "docs/generated" so that it can be pruned. + prefix = normalized_pattern.removesuffix("/**").rstrip("/") + if prefix and ( + normalized == prefix or normalized.startswith(prefix + "/") + ): + return True + + return False + + +def _normalize_alphanumeric(stem: str) -> str: + """Convert a filename stem to lowercase alphanumeric characters. + + CamelCase word boundaries are handled before punctuation and underscores + are removed. + + Args: + stem: Filename stem without its extension. + + Returns: + A lowercase alphanumeric string, or an empty string if no characters + remain. + """ + stem = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1 \2", stem) + stem = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", stem) + return re.sub(r"[^a-zA-Z0-9]", "", stem).lower() + + +def _normalize_snake_case(stem: str) -> str: + """Convert a filename stem to lowercase snake_case. + + Args: + stem: Filename stem without its extension. + + Returns: + A lowercase snake_case string. + """ + stem = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", stem) + stem = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", stem) + stem = re.sub(r"[^a-zA-Z0-9]+", "_", stem) + return stem.strip("_").lower() + + +def _suggest_filename( + filename: str, + rule: dict[str, Any], + max_length: int = DEFAULT_MAX_FILENAME_LENGTH, +) -> str | None: + """Suggest a filename that complies with a configured naming rule. + + The rule's ``suggestion`` setting determines the normalization strategy: + + - ``test_prefix_alnum``: Require ``test_`` followed by lowercase letters + and digits. + - ``lowercase_alnum``: Use lowercase letters and digits only. + - ``lowercase_snake``: Use lowercase snake_case. + + The generated candidate is checked against the rule's regular expression + and maximum filename length before it is returned. + + Args: + filename: Original filename, including its extension. + rule: Applicable naming rule. + max_length: Maximum filename length, including the extension. + + Returns: + A compliant suggested filename, or None if a valid suggestion cannot + be generated. + """ + path = Path(filename) + strategy = rule.get("suggestion") + pattern = rule.get("pattern") + + if not isinstance(pattern, str): + return None + + extension = path.suffix.lower() + stem = path.stem + + if strategy == "test_prefix_alnum": + prefix = "test_" + if stem.startswith(prefix): + stem = stem[len(prefix):] + + normalized = _normalize_alphanumeric(stem) or "module" + available = max_length - len(prefix) - len(extension) + + if available < 1: + return None + + candidate = f"{prefix}{normalized[:available]}{extension}" + + elif strategy == "single_test_prefix_alnum": + prefix = "single_test_" + if stem.startswith(prefix): + stem = stem[len(prefix):] + + normalized = _normalize_alphanumeric(stem) or "module" + available = max_length - len(prefix) - len(extension) + + if available < 1: + return None + + candidate = f"{prefix}{normalized[:available]}{extension}" + + elif strategy == "lowercase_alnum": + normalized = _normalize_alphanumeric(stem) or "module" + + if not normalized[0].isalpha(): + normalized = f"module{normalized}" + + available = max_length - len(extension) + if available < 1: + return None + + candidate = f"{normalized[:available]}{extension}" + + elif strategy == "lowercase_snake": + normalized = _normalize_snake_case(stem) or "module" + available = max_length - len(extension) + + if available < 1: + return None + + candidate = f"{normalized[:available].rstrip('_') or 'm'}{extension}" + + else: + # Unknown strategies should not silently produce misleading names. + return None + + if len(candidate) > max_length: + return None + + if re.fullmatch(pattern, candidate) is None: + return None + + return candidate + + +def _load_config(config_path: Path) -> dict[str, Any]: + """Load and validate the filename linter configuration. + + Args: + config_path: Path to the TOML configuration file. + + Returns: + The linter's configuration dictionary. + + Raises: + ValueError: If required configuration values are invalid. + OSError: If the configuration cannot be read. + tomllib.TOMLDecodeError: If TOML parsing fails. + """ + with config_path.open("rb") as config_file: + document = tomllib.load(config_file) + + config = document.get("tool", {}).get("eos-filename-linter") + if not isinstance(config, dict): + raise ValueError("Missing [tool.eos-filename-linter] configuration.") + + global_limit = config.get( + "max_filename_length", DEFAULT_MAX_FILENAME_LENGTH + ) + if type(global_limit) is not int or global_limit < 1: + raise ValueError("max_filename_length must be a positive integer.") + + for key in ("exclude",): + if key in config and ( + not isinstance(config[key], list) + or not all(isinstance(item, str) for item in config[key]) + ): + raise ValueError(f"{key} must be a list of strings.") + + rules = config.get("rules") + if not isinstance(rules, list) or not rules: + raise ValueError("At least one filename linter rule is required.") + + seen_names: set[str] = set() + for index, rule in enumerate(rules, start=1): + if not isinstance(rule, dict): + raise ValueError(f"Rule {index} must be a TOML table.") + + name = rule.get("name") + if not isinstance(name, str) or not name: + raise ValueError(f"Rule {index} requires a non-empty name.") + if name in seen_names: + raise ValueError(f"Duplicate rule name: {name!r}.") + seen_names.add(name) + + paths = rule.get("paths") + if not isinstance(paths, list) or not paths or not all( + isinstance(item, str) and item for item in paths + ): + raise ValueError(f"Rule {name!r} requires non-empty path patterns.") + + pattern = rule.get("pattern") + if not isinstance(pattern, str): + raise ValueError(f"Rule {name!r} requires a regex pattern.") + try: + re.compile(pattern) + except re.error as exc: + raise ValueError(f"Rule {name!r} has invalid regex: {exc}") from exc + + for key in ("allowed_names", "exclude"): + if key in rule and ( + not isinstance(rule[key], list) + or not all(isinstance(item, str) for item in rule[key]) + ): + raise ValueError(f"Rule {name!r}: {key} must be a list of strings.") + + limit = rule.get("max_filename_length", global_limit) + if type(limit) is not int or limit < 1: + raise ValueError( + f"Rule {name!r}: max_filename_length must be a positive integer." + ) + + suggestion = rule.get("suggestion") + valid_suggestions = { + "test_prefix_alnum", + "single_test_prefix_alnum", + "lowercase_alnum", + "lowercase_snake", + } + + if suggestion is not None and suggestion not in valid_suggestions: + raise ValueError( + f"Rule {name!r}: unknown suggestion strategy " + f"{suggestion!r}. Expected one of " + f"{sorted(valid_suggestions)}." + ) + + return config + +def _relative_path(path: str, root: Path) -> str | None: + """Resolve a path to a repository-relative POSIX path. + + Args: + path: The supplied path. + root: The repository root. + + Returns: + A relative path, or None if the path is outside the repository. + """ + candidate = Path(path) + if not candidate.is_absolute(): + candidate = root / candidate + try: + return candidate.resolve().relative_to(root.resolve()).as_posix() + except ValueError: + return None + +def _find_rule( + relative_path: str, rules: list[dict[str, Any]] +) -> dict[str, Any] | None: + """Return the first applicable rule matching a repository-relative path. + + A rule is applicable when its ``paths`` patterns match the path and the + path is not excluded by that rule's ``exclude`` patterns. Excluded rules + are skipped so that a later, more specific rule can still apply to the + same path. (For example, a general ``tests/**/*.py`` rule that excludes + ``tests/single_test_*`` must not shadow the dedicated single-test rule.) + + Args: + relative_path: The path to match. + rules: Rules in configuration order. + + Returns: + The first applicable rule, or None. + """ + for rule in rules: + if not _matches_any(relative_path, rule["paths"]): + continue + if _is_excluded(relative_path, rule.get("exclude", [])): + continue + return rule + return None + +def _iter_repository_files( + root: Path, config: dict[str, Any] +) -> list[str]: + """Find repository files while pruning excluded directories. + + Args: + root: Repository root. + config: Validated linter configuration. + + Returns: + Repository-relative paths for candidate files. + """ + global_excludes = config.get("exclude", []) + rules = config["rules"] + paths: list[str] = [] + + for current_root, directory_names, filenames in os.walk(root): + current = Path(current_root) + relative_dir = current.relative_to(root).as_posix() + if relative_dir == ".": + relative_dir = "" + + directory_names[:] = [ + name + for name in directory_names + if not _is_excluded( + f"{relative_dir}/{name}".lstrip("/"), global_excludes + ) + ] + + for filename in filenames: + relative_path = f"{relative_dir}/{filename}".lstrip("/") + if _is_excluded(relative_path, global_excludes): + continue + if _find_rule(relative_path, rules) is not None: + paths.append(relative_path) + + return paths + +def lint_paths( + paths: list[str], + root: Path, + config: dict[str, Any], +) -> int: + """Validate filenames and print diagnostics for violations. + + Args: + paths: Files to check. + root: Repository root. + config: Validated linter configuration. + + Returns: + The number of violations found. + """ + global_excludes = config.get("exclude", []) + global_limit = config.get( + "max_filename_length", DEFAULT_MAX_FILENAME_LENGTH + ) + rules = config["rules"] + violations = 0 + + for supplied_path in paths: + relative_path = _relative_path(supplied_path, root) + if relative_path is None: + print(f"ERROR: {supplied_path}: outside repository root {root}", file=sys.stderr) + violations += 1 + continue + + if _is_excluded(relative_path, global_excludes): + continue + + # ``_find_rule`` already skips rules whose own ``exclude`` patterns + # match the path, so a rule returned here is applicable. + rule = _find_rule(relative_path, rules) + if rule is None: + continue + + filename = Path(relative_path).name + max_length = rule.get("max_filename_length", global_limit) + allowed_names = rule.get("allowed_names", []) + pattern_matches = re.fullmatch(rule["pattern"], filename) is not None + too_long = len(filename) > max_length + + if filename in allowed_names and not too_long: + continue + if pattern_matches and not too_long: + continue + + suggestion = _suggest_filename(filename, rule, max_length) + + print(f"{relative_path}") + if not pattern_matches and filename not in allowed_names: + print(f" ERROR: Filename violates rule {rule['name']!r}.") + print(f" Expected pattern: {rule['pattern']}") + + if too_long: + print( + f" ERROR: Filename is {len(filename)} characters long; " + f"maximum is {max_length}." + ) + + if suggestion is not None: + print(f" Suggested name: {suggestion}") + else: + print(" Suggested name: No safe suggestion available.") + + violations += 1 + + return violations + +def main(argv: list[str] | None = None) -> int: + """Run the filename linter command-line interface. + + Args: + argv: Optional command-line arguments. + + Returns: + Exit status: 0 for success, 1 for violations, or 2 for errors. + """ + parser = argparse.ArgumentParser( + description="Lint filenames using Akkudoktor-EOS naming conventions." + ) + parser.add_argument( + "--config", + type=Path, + default=Path("pyproject.toml"), + help="Path to pyproject.toml.", + ) + parser.add_argument("paths", nargs="*", help="Files to check.") + args = parser.parse_args(argv) + + config_path = args.config.resolve() + try: + config = _load_config(config_path) + root = config_path.parent.resolve() + paths = args.paths or _iter_repository_files(root, config) + violations = lint_paths(paths, root, config) + except (OSError, ValueError, tomllib.TOMLDecodeError) as exc: + print(f"Filename linter error: {exc}", file=sys.stderr) + return 2 + + if violations: + print(f"\nFound {violations} filename violation(s).", file=sys.stderr) + return 1 + + print("Filename lint passed.") + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/akkudoktoreos/server/container_healthcheck.py b/src/akkudoktoreos/server/containerhealthcheck.py similarity index 100% rename from src/akkudoktoreos/server/container_healthcheck.py rename to src/akkudoktoreos/server/containerhealthcheck.py diff --git a/src/akkudoktoreos/server/eos.py b/src/akkudoktoreos/server/eos.py index 39c8768e..46a2da02 100755 --- a/src/akkudoktoreos/server/eos.py +++ b/src/akkudoktoreos/server/eos.py @@ -73,7 +73,7 @@ from akkudoktoreos.prediction.load import LoadCommonSettings from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings from akkudoktoreos.prediction.pvforecastpvlib import _cec_inverters, _cec_modules -from akkudoktoreos.server.container_healthcheck import publish_port +from akkudoktoreos.server.containerhealthcheck import publish_port from akkudoktoreos.server.rest.error import ( EOSProblem, create_error_page, diff --git a/tests/test_ems_notify.py b/tests/test_emsnotify.py similarity index 100% rename from tests/test_ems_notify.py rename to tests/test_emsnotify.py diff --git a/tests/test_optimize_run_result.py b/tests/test_genetic0runresult.py similarity index 100% rename from tests/test_optimize_run_result.py rename to tests/test_genetic0runresult.py diff --git a/tests/test_ac_charge_total_limit.py b/tests/test_geneticacchargetotallimit.py similarity index 100% rename from tests/test_ac_charge_total_limit.py rename to tests/test_geneticacchargetotallimit.py diff --git a/tests/test_battery.py b/tests/test_geneticbattery.py similarity index 100% rename from tests/test_battery.py rename to tests/test_geneticbattery.py diff --git a/tests/test_genetic_complete_devices.py b/tests/test_geneticcompletedevices.py similarity index 100% rename from tests/test_genetic_complete_devices.py rename to tests/test_geneticcompletedevices.py diff --git a/tests/test_genetic_complete_optimization.py b/tests/test_geneticcompleteoptimization.py similarity index 100% rename from tests/test_genetic_complete_optimization.py rename to tests/test_geneticcompleteoptimization.py diff --git a/tests/test_genetic_complete_simulation.py b/tests/test_geneticcompletesimulation.py similarity index 100% rename from tests/test_genetic_complete_simulation.py rename to tests/test_geneticcompletesimulation.py diff --git a/tests/test_genetic_complete_solution.py b/tests/test_geneticcompletesolution.py similarity index 100% rename from tests/test_genetic_complete_solution.py rename to tests/test_geneticcompletesolution.py diff --git a/tests/test_genetic_complete_tail.py b/tests/test_geneticcompletetail.py similarity index 100% rename from tests/test_genetic_complete_tail.py rename to tests/test_geneticcompletetail.py diff --git a/tests/test_genetic_complete_timegrid.py b/tests/test_geneticcompletetimegrid.py similarity index 100% rename from tests/test_genetic_complete_timegrid.py rename to tests/test_geneticcompletetimegrid.py diff --git a/tests/test_genetic_config_frozen_per_run.py b/tests/test_geneticconfigfrozenperrun.py similarity index 100% rename from tests/test_genetic_config_frozen_per_run.py rename to tests/test_geneticconfigfrozenperrun.py diff --git a/tests/test_config_optimization_request.py b/tests/test_geneticconfigoptimizerequest.py similarity index 100% rename from tests/test_config_optimization_request.py rename to tests/test_geneticconfigoptimizerequest.py diff --git a/tests/test_genetic_end_to_end_devices.py b/tests/test_geneticendtoenddevices.py similarity index 100% rename from tests/test_genetic_end_to_end_devices.py rename to tests/test_geneticendtoenddevices.py diff --git a/tests/test_genetic_fitness_cache_limit.py b/tests/test_geneticfitnesscachelimit.py similarity index 100% rename from tests/test_genetic_fitness_cache_limit.py rename to tests/test_geneticfitnesscachelimit.py diff --git a/tests/test_genetic_forecast_coverage.py b/tests/test_geneticforecastcoverage.py similarity index 100% rename from tests/test_genetic_forecast_coverage.py rename to tests/test_geneticforecastcoverage.py diff --git a/tests/test_inverter.py b/tests/test_geneticinverter.py similarity index 100% rename from tests/test_inverter.py rename to tests/test_geneticinverter.py diff --git a/tests/test_genetic_parallel_evaluation.py b/tests/test_geneticparallelevaluation.py similarity index 100% rename from tests/test_genetic_parallel_evaluation.py rename to tests/test_geneticparallelevaluation.py diff --git a/tests/test_geneticparams_feedin.py b/tests/test_geneticparamsfeedin.py similarity index 100% rename from tests/test_geneticparams_feedin.py rename to tests/test_geneticparamsfeedin.py diff --git a/tests/test_optimization_pdf_api.py b/tests/test_geneticpdfapi.py similarity index 100% rename from tests/test_optimization_pdf_api.py rename to tests/test_geneticpdfapi.py diff --git a/tests/test_genetic_seeding.py b/tests/test_geneticseeding.py similarity index 100% rename from tests/test_genetic_seeding.py rename to tests/test_geneticseeding.py diff --git a/tests/test_inverter_efficiency.py b/tests/test_genticinverterefficiency.py similarity index 100% rename from tests/test_inverter_efficiency.py rename to tests/test_genticinverterefficiency.py diff --git a/tests/test_inverter_efficiency_curve.py b/tests/test_genticinverterefficiencycurve.py similarity index 100% rename from tests/test_inverter_efficiency_curve.py rename to tests/test_genticinverterefficiencycurve.py diff --git a/tests/test_lazy_imports.py b/tests/test_lazyimports.py similarity index 100% rename from tests/test_lazy_imports.py rename to tests/test_lazyimports.py diff --git a/tests/test_battery_capacity.py b/tests/test_measurementbatterycapacity.py similarity index 100% rename from tests/test_battery_capacity.py rename to tests/test_measurementbatterycapacity.py diff --git a/tests/test_measurement_energy.py b/tests/test_measurementenergy.py similarity index 100% rename from tests/test_measurement_energy.py rename to tests/test_measurementenergy.py diff --git a/tests/test_measurement_household.py b/tests/test_measurementhousehold.py similarity index 100% rename from tests/test_measurement_household.py rename to tests/test_measurementhousehold.py diff --git a/tests/test_optimization_compatibility.py b/tests/test_optimizationcompatibility.py similarity index 100% rename from tests/test_optimization_compatibility.py rename to tests/test_optimizationcompatibility.py diff --git a/tests/test_rest_error.py b/tests/test_resterror.py similarity index 100% rename from tests/test_rest_error.py rename to tests/test_resterror.py diff --git a/tests/test_scriptlintfilenames.py b/tests/test_scriptlintfilenames.py new file mode 100644 index 00000000..a2ff96a0 --- /dev/null +++ b/tests/test_scriptlintfilenames.py @@ -0,0 +1,435 @@ +"""Tests for the EOS filename linter.""" + +from __future__ import annotations + +import importlib.util +import re +from pathlib import Path +from types import ModuleType + +import pytest + + +def _load_linter_module() -> ModuleType: + """Load the filename linter directly from its source file. + + Returns: + The imported filename linter module. + + Raises: + ImportError: If the module cannot be loaded. + """ + script_path = ( + Path(__file__).resolve().parents[1] + / "scripts" + / "lint_filenames.py" + ) + spec = importlib.util.spec_from_file_location( + "lint_filenames", + script_path, + ) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot load filename linter from {script_path}") + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_linter = _load_linter_module() + +_find_rule = _linter._find_rule +_glob_to_regex = _linter._glob_to_regex +_is_excluded = _linter._is_excluded +_load_config = _linter._load_config +_suggest_filename = _linter._suggest_filename +lint_paths = _linter.lint_paths + + +@pytest.fixture +def layered_config() -> dict[str, object]: + """Provide a general tests rule plus a dedicated single-test rule. + + The general rule excludes ``single_test_*`` files so that the + dedicated rule must be reached for them (regression guard for + rule-level exclusions during rule selection). + """ + return { + "max_filename_length": 64, + "exclude": [], + "rules": [ + { + "name": "tests", + "paths": ["tests/**/*.py"], + "pattern": r"test_[a-z0-9]+\.py", + "allowed_names": ["conftest.py"], + "exclude": ["tests/single_test_*.py"], + }, + { + "name": "single_test", + "paths": ["tests/single_test_*.py"], + "pattern": r"single_test_[a-z0-9]+\.py", + "suggestion": "single_test_prefix_alnum", + }, + ], + } + + +class TestGlobMatching: + """Tests for repository-relative glob matching.""" + + def test_matches_direct_child(self) -> None: + """Match a Python file directly inside a directory.""" + assert _glob_to_regex("tests/*.py").fullmatch("tests/testfoo.py") + + def test_matches_nested_child(self) -> None: + """Match nested Python files using a double-star glob.""" + assert _glob_to_regex("tests/**/*.py").fullmatch( + "tests/adapter/testfoo.py" + ) + + def test_matches_zero_nested_directories(self) -> None: + """Allow a double-star glob to match files at its root.""" + assert _glob_to_regex("tests/**/*.py").fullmatch("tests/testfoo.py") + + def test_does_not_match_different_directory(self) -> None: + """Reject paths outside the configured directory.""" + assert not _glob_to_regex("tests/**/*.py").fullmatch( + "src/testfoo.py" + ) + +class TestExclusions: + """Tests for file and directory exclusions.""" + + @pytest.mark.parametrize( + ("path", "patterns", "expected"), + [ + (".venv/lib/module.py", [".venv/**"], True), + ("docs/_build/index.html", ["docs/_build/**"], True), + ("tests/testfoo.py", ["docs/**"], False), + ("docs/generated", ["docs/generated/**"], True), + ("docs/generated/page.html", ["docs/generated/**"], True), + ], + ) + def test_exclusion_matching( + self, path: str, patterns: list[str], expected: bool + ) -> None: + """Match exclusions against files and directory paths.""" + assert _is_excluded(path, patterns) is expected + + +class TestRuleMatching: + """Tests for selecting the first applicable rule.""" + + def test_selects_first_matching_rule(self) -> None: + """Preserve configured rule precedence.""" + rules = [ + {"name": "tests", "paths": ["tests/**/*.py"]}, + {"name": "python", "paths": ["**/*.py"]}, + ] + + assert _find_rule("tests/testfoo.py", rules) is rules[0] + + def test_returns_none_without_matching_rule(self) -> None: + """Ignore paths that have no applicable naming rule.""" + assert _find_rule("README.md", []) is None + + def test_skips_rule_that_excludes_the_path(self) -> None: + """Fall through to the next rule when a rule excludes the path.""" + rules = [ + { + "name": "tests", + "paths": ["tests/**/*.py"], + "exclude": ["tests/single_test_*.py"], + }, + { + "name": "single_test", + "paths": ["tests/single_test_*.py"], + }, + ] + + assert ( + _find_rule("tests/single_test_adapter.py", rules) is rules[1] + ) + + def test_returns_none_when_all_matching_rules_exclude_path(self) -> None: + """Return None when every path-matching rule excludes the path.""" + rules = [ + { + "name": "tests", + "paths": ["tests/**/*.py"], + "exclude": ["tests/single_test_*.py"], + }, + { + "name": "single_test", + "paths": ["tests/single_test_*.py"], + "exclude": ["tests/single_test_*.py"], + }, + ] + + assert _find_rule("tests/single_test_adapter.py", rules) is None + + def test_does_not_skip_rule_whose_exclusion_does_not_match(self) -> None: + """Keep the first rule when its exclusion does not apply.""" + rules = [ + { + "name": "tests", + "paths": ["tests/**/*.py"], + "exclude": ["tests/single_test_*.py"], + }, + { + "name": "single_test", + "paths": ["tests/single_test_*.py"], + }, + ] + + assert _find_rule("tests/test_adapter.py", rules) is rules[0] + + +class TestFilenameSuggestions: + """Tests for configuration-driven filename suggestions.""" + + @pytest.mark.parametrize( + ("filename", "rule", "expected"), + [ + ( + "SMAControl.py", + { + "name": "Python module", + "suggestion": "lowercase_snake", + "pattern": r"[a-z][a-z0-9_]*\.py", + }, + "sma_control.py", + ), + ( + "test_adapter_device.py", + { + "name": "Pytest module", + "suggestion": "test_prefix_alnum", + "pattern": r"test_[a-z0-9]+\.py", + }, + "test_adapterdevice.py", + ), + ( + "test_Adapter.py", + { + "name": "Pytest module", + "suggestion": "test_prefix_alnum", + "pattern": r"test_[a-z0-9]+\.py", + }, + "test_adapter.py", + ), + ], + ) + def test_suggests_filename_according_to_rule( + self, + filename: str, + rule: dict[str, str], + expected: str, + ) -> None: + """Generate a filename matching the configured naming convention.""" + assert _suggest_filename(filename, rule, 64) == expected + + def test_shortens_suggestion_to_maximum_length(self) -> None: + """Ensure suggested filenames respect the configured limit.""" + rule = { + "name": "Pytest module", + "suggestion": "test_prefix_alnum", + "pattern": r"test_[a-z0-9]+\.py", + } + + suggestion = _suggest_filename( + "test_" + "a" * 100 + ".py", + rule, + 20, + ) + + assert suggestion is not None + assert len(suggestion) <= 20 + assert re.fullmatch(rule["pattern"], suggestion) + + def test_returns_none_for_unknown_strategy(self) -> None: + """Do not guess when no suggestion strategy is configured.""" + rule = { + "name": "Custom rule", + "suggestion": "unknown", + "pattern": r"[a-z]+\.py", + } + + assert _suggest_filename("BAD_NAME.py", rule, 64) is None + +class TestConfiguration: + """Tests for configuration loading and validation.""" + + def test_loads_valid_configuration(self, tmp_path: Path) -> None: + """Load a minimal valid TOML configuration.""" + config_path = tmp_path / "pyproject.toml" + config_path.write_text( + """ +[tool.eos-filename-linter] +max_filename_length = 64 +exclude = [".venv/**"] + +[[tool.eos-filename-linter.rules]] +name = "pytest_module" +paths = ["tests/**/*.py"] +pattern = "^test_[a-z0-9]+\\\\.py$" +allowed_names = ["conftest.py"] +""", + encoding="utf-8", + ) + + config = _load_config(config_path) + + assert config["max_filename_length"] == 64 + assert config["rules"][0]["name"] == "pytest_module" + + def test_rejects_invalid_regex(self, tmp_path: Path) -> None: + """Reject an invalid regular expression.""" + config_path = tmp_path / "pyproject.toml" + config_path.write_text( + """ +[tool.eos-filename-linter] + +[[tool.eos-filename-linter.rules]] +name = "invalid" +paths = ["**/*.py"] +pattern = "[" +""", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="invalid regex"): + _load_config(config_path) + +class TestLintPaths: + """Tests for filename validation and diagnostic output.""" + + @pytest.fixture + def config(self) -> dict[str, object]: + """Provide a minimal linter configuration.""" + return { + "max_filename_length": 64, + "exclude": ["ignored/**"], + "rules": [ + { + "name": "pytest_module", + "paths": ["tests/**/*.py"], + "pattern": r"test_[a-z0-9]+\.py", + "allowed_names": ["conftest.py"], + } + ], + } + + def test_accepts_valid_test_filename( + self, tmp_path: Path, config: dict[str, object], capsys: pytest.CaptureFixture[str] + ) -> None: + """Accept a filename that satisfies the test naming rule.""" + (tmp_path / "tests").mkdir() + path = tmp_path / "tests" / "test_adapter123.py" + path.touch() + + assert lint_paths([str(path)], tmp_path, config) == 0 + assert capsys.readouterr().out == "" + + @pytest.mark.parametrize( + "filename", + ["test_adapter_device.py", "test_Adapter.py", "test-adapter.py"], + ) + def test_rejects_invalid_test_filenames( + self, + tmp_path: Path, + config: dict[str, object], + filename: str, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Reject test filenames that violate the naming convention.""" + (tmp_path / "tests").mkdir() + path = tmp_path / "tests" / filename + path.touch() + + assert lint_paths([str(path)], tmp_path, config) == 1 + output = capsys.readouterr().out + assert "ERROR" in output + assert "Suggested name:" in output + + def test_rejects_overlong_filename( + self, + tmp_path: Path, + config: dict[str, object], + capsys: pytest.CaptureFixture[str], + ) -> None: + """Report a filename that exceeds the configured limit.""" + (tmp_path / "tests").mkdir() + filename = "test_" + "a" * 60 + ".py" + path = tmp_path / "tests" / filename + path.touch() + + assert lint_paths([str(path)], tmp_path, config) == 1 + output = capsys.readouterr().out + assert "maximum is 64" in output + assert "Suggested name:" in output + + def test_ignores_excluded_file( + self, + tmp_path: Path, + config: dict[str, object], + ) -> None: + """Skip a file covered by a global exclusion.""" + (tmp_path / "ignored").mkdir() + path = tmp_path / "ignored" / "test_bad_name.py" + path.touch() + + assert lint_paths([str(path)], tmp_path, config) == 0 + + def test_reports_violation_via_dedicated_rule_after_exclusion( + self, + tmp_path: Path, + layered_config: dict[str, object], + capsys: pytest.CaptureFixture[str], + ) -> None: + """Apply the dedicated rule when the general rule excludes the file. + + Regression test: an excluded general rule must not shadow the + dedicated ``single_test`` rule for ``tests/single_test_*.py``. + """ + (tmp_path / "tests").mkdir() + path = tmp_path / "tests" / "single_test_BAD_name.py" + path.touch() + + assert lint_paths([str(path)], tmp_path, layered_config) == 1 + output = capsys.readouterr().out + assert "single_test" in output + assert "Suggested name:" in output + assert "single_test_badname.py" in output + + def test_accepts_valid_single_test_filename_via_dedicated_rule( + self, + tmp_path: Path, + layered_config: dict[str, object], + ) -> None: + """Accept a compliant filename selected via the dedicated rule.""" + (tmp_path / "tests").mkdir() + path = tmp_path / "tests" / "single_test_adapter.py" + path.touch() + + assert lint_paths([str(path)], tmp_path, layered_config) == 0 + + +_iter_repository_files = _linter._iter_repository_files + +class TestRepositoryScan: + """Tests for repository-wide file discovery.""" + + def test_includes_file_reachable_only_via_later_rule( + self, + tmp_path: Path, + layered_config: dict[str, object], + ) -> None: + """Include files selected by a later rule after an exclusion.""" + (tmp_path / "tests").mkdir() + (tmp_path / "tests" / "single_test_BAD_name.py").touch() + + files = _iter_repository_files(tmp_path, layered_config) + + assert "tests/single_test_BAD_name.py" in files