Unify documentation rules across languages and fix the Python template

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jan Doubravský
2026-08-18 10:33:00 +02:00
co-authored by Claude Opus 5
parent 19e9b8f2fa
commit 5c0f2f758f
19 changed files with 502 additions and 244 deletions
+28
View File
@@ -0,0 +1,28 @@
# Python — append on top of the shared core (Project template/.gitignore).
# --- Virtual environment ---
.venv/
# --- Bytecode and tool caches ---
__pycache__/
*.py[cod]
.mypy_cache/
.ruff_cache/
.pytest_cache/
.coverage
# --- Build artefacts ---
build/
*.egg-info/
# --- Distribution ---
# Applications built with PyInstaller COMMIT dist/ — the repository is the
# distribution channel (DESIGN_DOCUMENT.md, section 13), so leave the line below
# commented out. Libraries publish to PyPI and DO ignore dist/
# (DESIGN_DOCUMENT_MODULE.md, section 14) — uncomment it there.
# dist/
# --- Lock file ---
# Applications commit poetry.lock — leave the line below commented out.
# Libraries do not commit it — uncomment it there.
# poetry.lock
+30 -10
View File
@@ -19,13 +19,13 @@
All detailed documentation of features and systems belongs in the `docs/` folder, not in the project root.
The root directory contains only the core documents: `DESIGN_DOCUMENT.md`, `AGENTS.md`, `PROJECT.md`, `CHANGELOG.md`.
The root directory contains only the core documents: `README.md`, `AGENTS.md`, `DESIGN_DOCUMENT.md`, `PROJECT.md`, `CHANGELOG.md`.
---
## 1. Code Style
- **PEP8** with 150-character lines (Ruff)
- **PEP8** with 120-character lines (Ruff)
- **4 spaces** indentation
- **snake_case** functions/variables, **PascalCase** classes, **SCREAMING_SNAKE_CASE** constants
- **Type hints** required on all functions
@@ -76,7 +76,7 @@ Use **loguru** for all internal logging. Never log secrets, passwords, tokens, o
File sink retains **max 10 log files** (`retention=10`). No rotation by size — each run creates a new file via `{time}` in the filename.
The `DEBUG` sink is only active when `constants.DEBUG` is `True` (controlled by `ENV_DEBUG=true` in `.env`).
The `DEBUG` sink is only active when `constants.DEFAULT_DEBUG` is `True` (controlled by `ENV_DEBUG=true` in `.env`).
Additional sinks (e.g. GUI log panels) may be added per project as needed.
@@ -131,6 +131,7 @@ Run before every commit:
```bash
poetry run ruff check
poetry run mypy
poetry run pytest
```
---
@@ -147,6 +148,10 @@ poetry run <cmd> # Run command in virtualenv
Never edit `pyproject.toml` directly to add or remove dependencies.
### poetry.lock
`poetry.lock` **is committed** for applications — it pins the exact dependency graph and keeps builds reproducible.
---
## 12. Project Structure
@@ -171,12 +176,24 @@ When a project is distributed as a standalone executable (no Python required on
- Use **PyInstaller** to compile each entry point into a single `.exe`
- Each tool has its own `.spec` file in the project root
- All console tools must use `console=True` in the `.spec` — tools rely on `input()` and `print()` for user interaction
- **Console tools use `console=True`** in the `.spec` — they rely on `input()` and `print()` for user interaction. GUI applications turn the console off with `ENV_BUILD_CONSOLE=false`; that flag is independent of `ENV_DEBUG`
- Compiled executables are stored in `dist/` and **committed to the repository** — the repository serves as the distribution channel for internal teams
- `.gitignore` must **not** exclude `dist/` in projects that use this deployment model
Build command:
### prebuild.py
`prebuild.py` runs before PyInstaller. It verifies that the active interpreter is the project `.venv`, prints the resolved version, and rewrites the `console=` line in the `.spec` to match `.env`.
| Variable | Default | Effect |
|----------|---------|--------|
| `ENV_BUILD_CONSOLE` | `true` | Value written into `console=` in the `.spec` |
| `ENV_BUILD_SPEC` | `<project folder>.spec` | Which `.spec` file to update |
`prebuild.py` touches one `.spec` per run — with several entry points, set `ENV_BUILD_SPEC` for each build.
Build commands:
```bash
poetry run python prebuild.py
poetry run pyinstaller ToolName.spec
```
@@ -200,13 +217,16 @@ poetry run pyinstaller ToolName.spec
### Task notation
Tasks are written as single-line comments directly in code, or in `PROJECT.md` for cross-cutting concerns:
Tasks are written as single-line comments directly in code, using the **Todo Tree** tags defined in `AGENTS.md` (`TODO`, `FIXME`, `BUG`, `HACK`, `NOTE`). `PROJECT.md` carries only cross-cutting tasks that have no single place in the code.
```python
# TODO: one-liner description of a task to be done
# FIXME: one-liner description of a known bug to be fixed
# TODO: extract this into a separate loader
# FIXME: crashes on an empty file
# BUG: rounding is off by one cent on negative amounts
# HACK: temporary workaround until the API adds paging
# NOTE: order matters here, the parser is stateful
```
No other task format is used — no checkboxes, no numbered lists in documentation.
No other task format is used — **no checkboxes, no numbered lists in documentation**.
If a `# TODO:` comment already exists at a specific location in code, do not repeat it in `PROJECT.md`.
If a tag already exists at a specific location in code, do not repeat it in `PROJECT.md`.
+10 -7
View File
@@ -18,13 +18,13 @@
All detailed documentation of features and systems belongs in the `docs/` folder, not in the project root.
The root directory contains only the core documents: `DESIGN_DOCUMENT_MODULE.md`, `AGENTS.md`, `PROJECT.md`, `CHANGELOG.md`.
The root directory contains only the core documents: `README.md`, `AGENTS.md`, `DESIGN_DOCUMENT_MODULE.md`, `PROJECT.md`, `CHANGELOG.md`.
---
## 1. Code Style
- **PEP8** with 150-character lines (Ruff)
- **PEP8** with 120-character lines (Ruff)
- **4 spaces** indentation
- **snake_case** functions/variables, **PascalCase** classes, **SCREAMING_SNAKE_CASE** constants
- **Type hints** required on all functions
@@ -248,13 +248,16 @@ poetry publish # Publishes to PyPI (requires credentials)
### Task notation
Tasks are written as single-line comments directly in code, or in `PROJECT.md` for cross-cutting concerns:
Tasks are written as single-line comments directly in code, using the **Todo Tree** tags defined in `AGENTS.md` (`TODO`, `FIXME`, `BUG`, `HACK`, `NOTE`). `PROJECT.md` carries only cross-cutting tasks that have no single place in the code.
```python
# TODO: one-liner description of a task to be done
# FIXME: one-liner description of a known bug to be fixed
# TODO: extract this into a separate loader
# FIXME: crashes on an empty file
# BUG: rounding is off by one cent on negative amounts
# HACK: temporary workaround until the API adds paging
# NOTE: order matters here, the parser is stateful
```
No other task format is used — no checkboxes, no numbered lists in documentation.
No other task format is used — **no checkboxes, no numbered lists in documentation**.
If a `# TODO:` comment already exists at a specific location in code, do not repeat it in `PROJECT.md`.
If a tag already exists at a specific location in code, do not repeat it in `PROJECT.md`.
+5 -6
View File
@@ -16,10 +16,8 @@ template/
├── pyproject.toml # Poetry config (ruff, mypy, pytest)
├── src/
│ ├── __init__.py
── core/
├── __init__.py
│ ├── _version.py # Version fallback for PyInstaller
│ └── constants.py # Version extraction from toml + DEBUG mode
── _version.py # Version fallback for PyInstaller
└── constants.py # Version extraction from toml + DEBUG mode
└── tests/
├── __init__.py
└── test_constants.py # Basic test
@@ -28,14 +26,15 @@ template/
## Key Features
- **Version extraction** from `pyproject.toml` with `_version.py` fallback for PyInstaller builds
- **DEBUG mode** via `ENV_DEBUG=true` in `.env` (adds " DEV" suffix to version)
- **DEBUG mode** via `ENV_DEBUG=true` in `.env` (adds "DEV" suffix to version: v1.2.3DEV)
- **loguru** for logging (never print)
- **Poetry** for dependency management
- **pytest** for testing (no unittest)
- **ruff + mypy** for linting and type checking
- **ruff + mypy** for linting and type checking (120-character lines)
## Rules
- No `.example` suffixes - the folder itself is the separator
- Generic/reusable format
- Keep files simple and minimal
- `.gitignore` is the shared core from `Project template/.gitignore` with `Python/.gitignore` appended
+10 -5
View File
@@ -1,7 +1,9 @@
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
from src.constants import VERSION
load_dotenv()
@@ -33,17 +35,20 @@ else:
print(f"✓ Version: {VERSION}")
env_debug = os.getenv("ENV_DEBUG", "false").lower() == "true"
console_mode = env_debug
env_debug = os.getenv("ENV_DEBUG", "false").lower() in ("true", "1", "yes")
# console=True is the default — console tools rely on input() and print().
# GUI applications turn it off with ENV_BUILD_CONSOLE=false in .env.
console_mode = os.getenv("ENV_BUILD_CONSOLE", "true").lower() in ("true", "1", "yes")
default_spec = Path(__file__).parent.name + ".spec"
spec_filename = os.getenv("ENV_BUILD_SPEC", default_spec)
print(f"\n{'-' * 50}")
print("BUILD SETTINGS")
print(f"{'-' * 50}")
print(f"ENV_DEBUG: {env_debug}")
print(f"Console mode: {console_mode}")
print(f"Spec file: {spec_filename}")
print(f"ENV_DEBUG: {env_debug}")
print(f"ENV_BUILD_CONSOLE: {console_mode}")
print(f"Spec file: {spec_filename}")
spec_path = Path(__file__).parent / spec_filename
if spec_path.exists():
+21 -12
View File
@@ -1,6 +1,8 @@
"""
Generic application constants template.
Requires Python 3.11+ (uses the stdlib `tomllib` module).
Usage in your project:
1. Copy this file to src/constants.py
2. Fill in APP_NAME and APP_FULL_NAME
@@ -14,14 +16,14 @@ Version loading priority:
Debug mode:
Controlled exclusively via .env: ENV_DEBUG=true
Accepted true-values: true, 1, yes (case-insensitive)
When enabled, VERSION carries a "DEV" suffix with no separator: v1.2.3DEV
"""
import os
import tomllib
from pathlib import Path
import tomllib
from dotenv import load_dotenv
from loguru import logger
load_dotenv()
@@ -38,22 +40,28 @@ def _load_version() -> str:
# 1. pyproject.toml
try:
with open(_PYPROJECT, "rb") as f:
version = tomllib.load(f)["project"]["version"]
# Write fallback for frozen/PyInstaller builds
_VERSION_FILE.write_text(
f'"""Auto-generated — do not edit manually."""\n__version__ = "{version}"\n',
encoding="utf-8",
)
return version
except (FileNotFoundError, KeyError):
version: str = tomllib.load(f)["project"]["version"]
except (OSError, KeyError, tomllib.TOMLDecodeError):
pass
else:
# Write the fallback used by frozen/PyInstaller builds, which do not ship
# pyproject.toml. A read-only or missing target is not a fatal condition.
try:
_VERSION_FILE.write_text(
f'"""Auto-generated — do not edit manually."""\n__version__ = "{version}"\n',
encoding="utf-8",
)
except OSError:
pass
return version
# 2. _version.py
try:
from src._version import __version__ # type: ignore[import]
return __version__
from src._version import __version__
except ImportError:
pass
else:
return __version__
# 3. last resort
return "0.0.0"
@@ -63,6 +71,7 @@ def _load_version() -> str:
# Debug mode
# ---------------------------------------------------------------------------
def _load_debug() -> bool:
return os.getenv("ENV_DEBUG", "false").lower() in ("true", "1", "yes")
+111 -98
View File
@@ -1,93 +1,125 @@
"""Tests for constants module."""
"""Tests for the constants module template."""
import re
import sys
from pathlib import Path
from unittest.mock import mock_open, patch
import pytest
from src import constants
from src.constants import (
APP_FULL_NAME,
APP_NAME,
APP_TITLE,
APP_VERSION,
ENV_DEBUG,
get_debug_mode,
get_version,
DEFAULT_DEBUG,
VERSION,
)
# ---------------------------------------------------------------------------
# get_version()
# ---------------------------------------------------------------------------
SEMVER = re.compile(r"^\d+\.\d+\.\d+")
def test_get_version_returns_string() -> None:
"""get_version() should return a string."""
assert isinstance(get_version(), str)
def test_get_version_semver_format() -> None:
"""get_version() should return a semver-like string X.Y.Z."""
version = get_version()
assert re.match(r"^\d+\.\d+\.\d+", version), f"Not semver: {version!r}"
def test_get_version_fallback_when_toml_missing(tmp_path: Path) -> None:
"""get_version() returns '0.0.0-unknown' when pyproject.toml and _version.py are both missing."""
missing = tmp_path / "nonexistent.toml"
with patch("src.constants._PYPROJECT_PATH", missing):
result = get_version()
# Either fallback _version.py exists (from previous run) or returns unknown
assert isinstance(result, str)
assert len(result) > 0
def test_get_version_unknown_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""get_version() returns '0.0.0-unknown' when all sources are unavailable."""
missing = tmp_path / "nonexistent.toml"
monkeypatch.setattr("src.constants._PYPROJECT_PATH", missing)
# Patch _version import to also fail
with patch("src.constants.Path.write_text", side_effect=OSError):
with patch.dict("sys.modules", {"src._version": None}):
result = get_version()
assert isinstance(result, str)
def _write_pyproject(tmp_path: Path, version: str = "1.2.3") -> Path:
"""Create a minimal pyproject.toml carrying the given version."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(f'[project]\nname = "demo"\nversion = "{version}"\n', encoding="utf-8")
return pyproject
# ---------------------------------------------------------------------------
# get_debug_mode()
# _load_version()
# ---------------------------------------------------------------------------
def test_get_debug_mode_returns_bool() -> None:
"""get_debug_mode() should always return a bool."""
assert isinstance(get_debug_mode(), bool)
def test_load_version_reads_pyproject(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""_load_version() takes the version from [project] in pyproject.toml."""
monkeypatch.setattr(constants, "_PYPROJECT", _write_pyproject(tmp_path))
monkeypatch.setattr(constants, "_VERSION_FILE", tmp_path / "_version.py")
assert constants._load_version() == "1.2.3"
def test_get_debug_mode_true(monkeypatch: pytest.MonkeyPatch) -> None:
"""get_debug_mode() returns True when ENV_DEBUG=true."""
monkeypatch.setenv("ENV_DEBUG", "true")
assert get_debug_mode() is True
def test_load_version_writes_fallback_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""_load_version() regenerates _version.py so frozen builds keep the version."""
version_file = tmp_path / "_version.py"
monkeypatch.setattr(constants, "_PYPROJECT", _write_pyproject(tmp_path))
monkeypatch.setattr(constants, "_VERSION_FILE", version_file)
constants._load_version()
assert '__version__ = "1.2.3"' in version_file.read_text(encoding="utf-8")
def test_get_debug_mode_true_variants(monkeypatch: pytest.MonkeyPatch) -> None:
"""get_debug_mode() accepts '1' and 'yes' as truthy values."""
for value in ("1", "yes", "YES", "True", "TRUE"):
monkeypatch.setenv("ENV_DEBUG", value)
assert get_debug_mode() is True, f"Expected True for ENV_DEBUG={value!r}"
def test_load_version_survives_unwritable_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""An unwritable _version.py location must not break version loading."""
monkeypatch.setattr(constants, "_PYPROJECT", _write_pyproject(tmp_path))
monkeypatch.setattr(constants, "_VERSION_FILE", tmp_path / "missing_dir" / "_version.py")
assert constants._load_version() == "1.2.3"
def test_get_debug_mode_false(monkeypatch: pytest.MonkeyPatch) -> None:
"""get_debug_mode() returns False when ENV_DEBUG=false."""
monkeypatch.setenv("ENV_DEBUG", "false")
assert get_debug_mode() is False
def test_load_version_falls_back_to_version_module(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Without pyproject.toml the version comes from src/_version.py."""
from src import _version
monkeypatch.setattr(constants, "_PYPROJECT", tmp_path / "nonexistent.toml")
assert constants._load_version() == _version.__version__
def test_get_debug_mode_false_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
"""get_debug_mode() returns False when ENV_DEBUG is not set."""
def test_load_version_missing_version_key(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A pyproject.toml without [project] version falls through to the next source."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[project]\nname = "demo"\n', encoding="utf-8")
monkeypatch.setattr(constants, "_PYPROJECT", pyproject)
monkeypatch.setitem(sys.modules, "src._version", None)
assert constants._load_version() == "0.0.0"
def test_load_version_malformed_toml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A malformed pyproject.toml falls through instead of raising."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text("[project\nversion = ", encoding="utf-8")
monkeypatch.setattr(constants, "_PYPROJECT", pyproject)
monkeypatch.setitem(sys.modules, "src._version", None)
assert constants._load_version() == "0.0.0"
def test_load_version_last_resort(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""With no source available at all, _load_version() returns '0.0.0'."""
monkeypatch.setattr(constants, "_PYPROJECT", tmp_path / "nonexistent.toml")
monkeypatch.setitem(sys.modules, "src._version", None)
assert constants._load_version() == "0.0.0"
# ---------------------------------------------------------------------------
# _load_debug()
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("value", ["true", "TRUE", "True", "1", "yes", "YES"])
def test_load_debug_truthy_values(value: str, monkeypatch: pytest.MonkeyPatch) -> None:
"""_load_debug() accepts 'true', '1' and 'yes' in any casing."""
monkeypatch.setenv("ENV_DEBUG", value)
assert constants._load_debug() is True
@pytest.mark.parametrize("value", ["false", "FALSE", "0", "no", "", "maybe"])
def test_load_debug_falsy_values(value: str, monkeypatch: pytest.MonkeyPatch) -> None:
"""Anything outside the accepted true-values is False."""
monkeypatch.setenv("ENV_DEBUG", value)
assert constants._load_debug() is False
def test_load_debug_defaults_to_false(monkeypatch: pytest.MonkeyPatch) -> None:
"""_load_debug() returns False when ENV_DEBUG is not set."""
monkeypatch.delenv("ENV_DEBUG", raising=False)
assert get_debug_mode() is False
assert constants._load_debug() is False
# ---------------------------------------------------------------------------
@@ -95,47 +127,28 @@ def test_get_debug_mode_false_when_unset(monkeypatch: pytest.MonkeyPatch) -> Non
# ---------------------------------------------------------------------------
def test_env_debug_is_bool() -> None:
"""ENV_DEBUG should be a bool."""
assert isinstance(ENV_DEBUG, bool)
def test_app_name_is_set() -> None:
"""APP_NAME and APP_FULL_NAME must be filled in per project."""
assert isinstance(APP_NAME, str) and APP_NAME
assert isinstance(APP_FULL_NAME, str) and APP_FULL_NAME
def test_app_version_is_string() -> None:
"""APP_VERSION should be a string."""
assert isinstance(APP_VERSION, str)
def test_default_debug_is_bool() -> None:
"""DEFAULT_DEBUG should be a bool."""
assert isinstance(DEFAULT_DEBUG, bool)
def test_app_version_semver_format() -> None:
"""APP_VERSION should follow semver format X.Y.Z."""
assert re.match(r"^\d+\.\d+\.\d+", APP_VERSION), f"Not semver: {APP_VERSION!r}"
def test_version_is_prefixed_semver() -> None:
"""VERSION is the project version prefixed with 'v'."""
assert VERSION.startswith("v")
assert SEMVER.match(VERSION.removeprefix("v")), f"Not semver: {VERSION!r}"
def test_app_name_value() -> None:
"""APP_NAME should be 'X4 SavEd'."""
assert APP_NAME == "X4 SavEd"
def test_version_dev_suffix_matches_debug_flag() -> None:
"""VERSION ends with 'DEV' (no separator) exactly when DEFAULT_DEBUG is True."""
assert VERSION.endswith("DEV") is DEFAULT_DEBUG
def test_app_title_contains_name_and_version() -> None:
"""APP_TITLE should contain APP_NAME and APP_VERSION."""
assert APP_NAME in APP_TITLE
assert APP_VERSION in APP_TITLE
def test_app_title_dev_suffix_when_debug(monkeypatch: pytest.MonkeyPatch) -> None:
"""APP_TITLE ends with '-DEV' when ENV_DEBUG is True."""
import importlib
import src.constants as consts
monkeypatch.setenv("ENV_DEBUG", "true")
monkeypatch.setattr(consts, "ENV_DEBUG", True)
title = f"{consts.APP_NAME} v{consts.APP_VERSION}" + ("-DEV" if True else "")
assert title.endswith("-DEV")
def test_app_title_no_dev_suffix_when_not_debug(monkeypatch: pytest.MonkeyPatch) -> None:
"""APP_TITLE does not end with '-DEV' when ENV_DEBUG is False."""
import src.constants as consts
monkeypatch.setattr(consts, "ENV_DEBUG", False)
title = f"{consts.APP_NAME} v{consts.APP_VERSION}" + ("-DEV" if False else "")
assert not title.endswith("-DEV")
def test_app_title_is_full_name_and_version() -> None:
"""APP_TITLE joins APP_FULL_NAME and VERSION with a single space."""
assert APP_TITLE == f"{APP_FULL_NAME} {VERSION}"