Organize documentation into topic folders (Claude, Python, Zscaler, Project template)
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
# Python Development Guidelines
|
||||
|
||||
**Document Version:** v8
|
||||
|
||||
> **Note on Versioning:**
|
||||
> - This document version is independent — reused across projects
|
||||
> - **Project version** source of truth: `pyproject.toml`
|
||||
> - Version propagates: `pyproject.toml` → `constants.py` → code
|
||||
> - `CHANGELOG.md` uses project version from `pyproject.toml`
|
||||
|
||||
## Related Documents
|
||||
|
||||
- **README.md** — Project overview, tool descriptions, build instructions
|
||||
- **AGENTS.md** — Rules for AI assistants
|
||||
- **PROJECT.md** — Project goals and current state
|
||||
- **CHANGELOG.md** — Version history
|
||||
|
||||
### Documentation Organization
|
||||
|
||||
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`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Code Style
|
||||
|
||||
- **PEP8** with 150-character lines (Ruff)
|
||||
- **4 spaces** indentation
|
||||
- **snake_case** functions/variables, **PascalCase** classes, **SCREAMING_SNAKE_CASE** constants
|
||||
- **Type hints** required on all functions
|
||||
- **Import order**: stdlib → third-party → local
|
||||
|
||||
---
|
||||
|
||||
## 2. SOLID Principles
|
||||
|
||||
- **SRP** — One class = one responsibility
|
||||
- **OCP** — Open for extension, closed for modification
|
||||
- **LSP** — Subclasses substitutable for parents
|
||||
- **ISP** — Small interfaces over large ones
|
||||
- **DIP** — Depend on abstractions
|
||||
|
||||
---
|
||||
|
||||
## 3. Dependency Injection
|
||||
|
||||
Pass dependencies via constructor. Never instantiate dependencies inside a class.
|
||||
|
||||
---
|
||||
|
||||
## 4. Protocols Over Inheritance
|
||||
|
||||
Prefer `typing.Protocol` and composition over class inheritance.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Classes
|
||||
|
||||
Use `@dataclass` for internal data structures, `pydantic.BaseModel` for data that requires validation.
|
||||
|
||||
---
|
||||
|
||||
## 6. Logging and Console Output
|
||||
|
||||
### Logging — loguru
|
||||
|
||||
Use **loguru** for all internal logging. Never log secrets, passwords, tokens, or API keys.
|
||||
|
||||
#### Log sinks
|
||||
|
||||
| Sink | Level | Format |
|
||||
|------|-------|--------|
|
||||
| File `logs/{AppName}_{time}.log` | `DEBUG` | full (timestamp + level + message) |
|
||||
| stdout | `INFO` | full |
|
||||
|
||||
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`).
|
||||
|
||||
Additional sinks (e.g. GUI log panels) may be added per project as needed.
|
||||
|
||||
#### Log levels
|
||||
|
||||
| Level | When to use |
|
||||
|-------|-------------|
|
||||
| `DEBUG` | Per-item detail: individual file reads/writes, cache hits, per-row operations |
|
||||
| `INFO` | User-visible milestones: file loaded, process started/finished, file saved |
|
||||
| `WARNING` | Recoverable issues: fallback used, unexpected but non-fatal state |
|
||||
| `ERROR` | Failures the user must know about: missing file, failed save, missing config — operation cannot continue |
|
||||
|
||||
### Console output — print()
|
||||
|
||||
`print()` is **allowed** for direct user-facing console communication: input prompts, progress lines, and result summaries. This applies to console tools that interact with the user through stdin/stdout.
|
||||
|
||||
`print()` is **not** a substitute for loguru. It must not be used for debugging or internal event tracking.
|
||||
|
||||
---
|
||||
|
||||
## 7. Environment and Secrets
|
||||
|
||||
- Secrets in `.env` file; controlled by `ENV_DEBUG=true/false`
|
||||
- Load via `python-dotenv` and `os.getenv()`
|
||||
- Never commit `.env`
|
||||
|
||||
---
|
||||
|
||||
## 8. Error Handling
|
||||
|
||||
Define specific exception types. Use a fail-fast approach — surface errors early rather than silently continuing.
|
||||
|
||||
---
|
||||
|
||||
## 9. Testing
|
||||
|
||||
- **pytest** only — no `unittest`, no `TestCase` classes, no `self.assert*`
|
||||
- Arrange-Act-Assert pattern
|
||||
- Test naming: `test_<action>_<context>`
|
||||
|
||||
---
|
||||
|
||||
## 10. Tooling
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| **Ruff** | Formatting and linting |
|
||||
| **mypy** | Static type checking |
|
||||
| **pytest** | Testing |
|
||||
|
||||
Run before every commit:
|
||||
```bash
|
||||
poetry run ruff check
|
||||
poetry run mypy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Poetry
|
||||
|
||||
```bash
|
||||
poetry install # Install all dependencies
|
||||
poetry add <pkg> # Add runtime dependency
|
||||
poetry add --group dev <pkg> # Add dev dependency
|
||||
poetry remove <pkg> # Remove dependency
|
||||
poetry run <cmd> # Run command in virtualenv
|
||||
```
|
||||
|
||||
Never edit `pyproject.toml` directly to add or remove dependencies.
|
||||
|
||||
---
|
||||
|
||||
## 12. Project Structure
|
||||
|
||||
```
|
||||
project/
|
||||
├── entry_point.py # Entry point(s) — named by purpose or tool name
|
||||
├── src/ # All application modules
|
||||
├── tests/ # Tests
|
||||
├── docs/ # Detailed documentation
|
||||
├── .venv/ # Virtual environment (managed by Poetry)
|
||||
└── pyproject.toml # Project config and dependencies
|
||||
```
|
||||
|
||||
A project may have multiple entry points (e.g. `cli.py`, `gui.py`, or per-tool scripts).
|
||||
|
||||
---
|
||||
|
||||
## 13. Distribution and Deployment
|
||||
|
||||
When a project is distributed as a standalone executable (no Python required on target machine):
|
||||
|
||||
- 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
|
||||
- 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:
|
||||
```bash
|
||||
poetry run pyinstaller ToolName.spec
|
||||
```
|
||||
|
||||
> This section applies only to projects that produce standalone executables. Skip for libraries or web services.
|
||||
|
||||
---
|
||||
|
||||
## 14. Versioning
|
||||
|
||||
- Follow **semantic versioning**: `MAJOR.MINOR.PATCH`
|
||||
- Version is defined in `pyproject.toml` under `[project]`
|
||||
- Always ask before bumping the version — never increment automatically
|
||||
- Update `CHANGELOG.md` before bumping the version
|
||||
|
||||
---
|
||||
|
||||
## 15. Documentation and Task Management
|
||||
|
||||
- Keep `PROJECT.md` and `CHANGELOG.md` up to date when making changes
|
||||
- Document architectural changes in this file or in `docs/`
|
||||
|
||||
### Task notation
|
||||
|
||||
Tasks are written as single-line comments directly in code, or in `PROJECT.md` for cross-cutting concerns:
|
||||
|
||||
```python
|
||||
# TODO: one-liner description of a task to be done
|
||||
# FIXME: one-liner description of a known bug to be fixed
|
||||
```
|
||||
|
||||
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`.
|
||||
@@ -0,0 +1,260 @@
|
||||
# Python Library Development Guidelines
|
||||
|
||||
**Document Version:** v1
|
||||
|
||||
> **Note on Versioning:**
|
||||
> - This document version is independent — reused across projects
|
||||
> - **Project version** source of truth: `pyproject.toml` under `[project]`
|
||||
> - `CHANGELOG.md` uses project version from `pyproject.toml`
|
||||
|
||||
## Related Documents
|
||||
|
||||
- **README.md** — Project overview, public API description, installation and usage examples
|
||||
- **AGENTS.md** — Rules for AI assistants
|
||||
- **PROJECT.md** — Project goals and current state
|
||||
- **CHANGELOG.md** — Version history
|
||||
|
||||
### Documentation Organization
|
||||
|
||||
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`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Code Style
|
||||
|
||||
- **PEP8** with 150-character lines (Ruff)
|
||||
- **4 spaces** indentation
|
||||
- **snake_case** functions/variables, **PascalCase** classes, **SCREAMING_SNAKE_CASE** constants
|
||||
- **Type hints** required on all functions
|
||||
- **Import order**: stdlib → third-party → local
|
||||
|
||||
---
|
||||
|
||||
## 2. SOLID Principles
|
||||
|
||||
- **SRP** — One class = one responsibility
|
||||
- **OCP** — Open for extension, closed for modification
|
||||
- **LSP** — Subclasses substitutable for parents
|
||||
- **ISP** — Small interfaces over large ones
|
||||
- **DIP** — Depend on abstractions
|
||||
|
||||
---
|
||||
|
||||
## 3. Dependency Injection
|
||||
|
||||
Pass dependencies via constructor. Never instantiate dependencies inside a class.
|
||||
|
||||
---
|
||||
|
||||
## 4. Protocols Over Inheritance
|
||||
|
||||
Prefer `typing.Protocol` and composition over class inheritance.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Classes
|
||||
|
||||
Use `@dataclass` for internal data structures, `pydantic.BaseModel` for data that requires validation.
|
||||
|
||||
---
|
||||
|
||||
## 6. Logging
|
||||
|
||||
This is a library. Libraries must **never configure logging sinks** — that is the responsibility of the consuming application.
|
||||
|
||||
### Usage in library code
|
||||
|
||||
```python
|
||||
from loguru import logger
|
||||
|
||||
def some_function() -> None:
|
||||
logger.debug("Detail message")
|
||||
logger.info("Milestone message")
|
||||
```
|
||||
|
||||
Always use the module-level `logger` from loguru directly. Never call `logger.add()` or `logger.remove()` inside library code.
|
||||
|
||||
### Behavior for consumers
|
||||
|
||||
Loguru has a default sink to `stderr` enabled. Consumers who also use loguru get library logs automatically in their configured sinks. Consumers who want to suppress or filter library logs use the package name as the identifier:
|
||||
|
||||
```python
|
||||
from loguru import logger
|
||||
|
||||
logger.disable("<package_name>") # suppress all
|
||||
logger.add(sys.stderr, filter={"<package_name>": "WARNING"}) # WARNING and above only
|
||||
logger.enable("<package_name>") # re-enable
|
||||
```
|
||||
|
||||
This must be documented in `README.md`.
|
||||
|
||||
### Rules
|
||||
|
||||
- Never call `logger.add()` inside library code — no file sinks, no stdout sinks
|
||||
- Never call `logger.remove()` inside library code
|
||||
- Never log secrets, passwords, tokens, or API keys
|
||||
- Never use `print()` inside library code — not for debugging, not for output
|
||||
|
||||
#### Log levels
|
||||
|
||||
| Level | When to use |
|
||||
|-------|-------------|
|
||||
| `DEBUG` | Per-item detail: individual operations, cache hits, internal state |
|
||||
| `INFO` | Significant milestones visible to the consuming application |
|
||||
| `WARNING` | Recoverable issues: fallback used, unexpected but non-fatal state |
|
||||
| `ERROR` | Failures the caller must know about — operation cannot continue |
|
||||
|
||||
---
|
||||
|
||||
## 7. Environment and Secrets
|
||||
|
||||
- Libraries do not use `.env` files or `python-dotenv`
|
||||
- Configuration is passed by the caller via arguments or constructor parameters
|
||||
- Never read `os.getenv()` inside library code unless explicitly documented as a supported configuration mechanism
|
||||
|
||||
---
|
||||
|
||||
## 8. Error Handling
|
||||
|
||||
Define specific exception types in `src/<package>/exceptions.py`. Use a fail-fast approach — surface errors early rather than silently continuing. All public exceptions must be exported from the top-level `__init__.py`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Testing
|
||||
|
||||
- **pytest** only — no `unittest`, no `TestCase` classes, no `self.assert*`
|
||||
- Arrange-Act-Assert pattern
|
||||
- Test naming: `test_<action>_<context>`
|
||||
- Do not commit `poetry.lock` — this is a library; consumers pin their own dependencies
|
||||
|
||||
---
|
||||
|
||||
## 10. Tooling
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| **Ruff** | Formatting and linting |
|
||||
| **mypy** | Static type checking |
|
||||
| **pytest** | Testing |
|
||||
|
||||
Run before every commit:
|
||||
```bash
|
||||
poetry run ruff check
|
||||
poetry run mypy
|
||||
poetry run pytest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Poetry
|
||||
|
||||
```bash
|
||||
poetry install # Install all dependencies
|
||||
poetry add <pkg> # Add runtime dependency
|
||||
poetry add --group dev <pkg> # Add dev dependency
|
||||
poetry remove <pkg> # Remove dependency
|
||||
poetry run <cmd> # Run command in virtualenv
|
||||
poetry build # Build sdist and wheel into dist/
|
||||
poetry publish # Publish to PyPI
|
||||
```
|
||||
|
||||
Never edit `pyproject.toml` directly to add or remove dependencies.
|
||||
|
||||
### pyproject.toml configuration for a library
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "your-library"
|
||||
version = "0.1.0"
|
||||
description = "Short description"
|
||||
requires-python = ">=3.x"
|
||||
dependencies = []
|
||||
|
||||
[tool.poetry]
|
||||
packages = [{include = "your_library", from = "src"}]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
```
|
||||
|
||||
Do **not** add `[tool.poetry.scripts]` — libraries have no entry points.
|
||||
|
||||
### poetry.lock
|
||||
|
||||
Do **not** commit `poetry.lock` for libraries. It is only committed for applications. Add `poetry.lock` to `.gitignore`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Project Structure
|
||||
|
||||
```
|
||||
project/
|
||||
├── src/
|
||||
│ └── your_library/
|
||||
│ ├── __init__.py # Public API — export everything the caller needs
|
||||
│ ├── exceptions.py # All public exception types
|
||||
│ └── ... # Internal modules
|
||||
├── tests/ # Tests
|
||||
├── docs/ # Detailed documentation
|
||||
├── .venv/ # Virtual environment (managed by Poetry)
|
||||
└── pyproject.toml # Project config and dependencies
|
||||
```
|
||||
|
||||
- No entry point scripts in the project root — this is a library, not an application
|
||||
- `__init__.py` defines the public API; callers import from the top-level package only
|
||||
|
||||
---
|
||||
|
||||
## 13. Public API
|
||||
|
||||
- Everything intended for external use must be exported from `src/<package>/__init__.py`
|
||||
- Use `__all__` to explicitly declare the public surface
|
||||
- Internal modules are prefixed with `_` or kept unexported
|
||||
- Public API must be stable across patch versions; breaking changes require a major version bump
|
||||
|
||||
---
|
||||
|
||||
## 14. Distribution
|
||||
|
||||
Build and publish with Poetry:
|
||||
|
||||
```bash
|
||||
poetry build # Creates dist/*.tar.gz and dist/*.whl
|
||||
poetry publish # Publishes to PyPI (requires credentials)
|
||||
```
|
||||
|
||||
`dist/` is **not committed** to the repository — add it to `.gitignore`.
|
||||
|
||||
---
|
||||
|
||||
## 15. Versioning
|
||||
|
||||
- Follow **semantic versioning**: `MAJOR.MINOR.PATCH`
|
||||
- Version is defined in `pyproject.toml` under `[project]`
|
||||
- Always ask before bumping the version — never increment automatically
|
||||
- Update `CHANGELOG.md` before bumping the version
|
||||
- Breaking changes to the public API require a major version bump
|
||||
|
||||
---
|
||||
|
||||
## 16. Documentation and Task Management
|
||||
|
||||
- Keep `PROJECT.md` and `CHANGELOG.md` up to date when making changes
|
||||
- `README.md` must contain installation instructions and usage examples for the public API
|
||||
- Document architectural changes in this file or in `docs/`
|
||||
|
||||
### Task notation
|
||||
|
||||
Tasks are written as single-line comments directly in code, or in `PROJECT.md` for cross-cutting concerns:
|
||||
|
||||
```python
|
||||
# TODO: one-liner description of a task to be done
|
||||
# FIXME: one-liner description of a known bug to be fixed
|
||||
```
|
||||
|
||||
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`.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Template Generation Request
|
||||
|
||||
Create a `template/` folder in project root with reusable files for new Python projects.
|
||||
|
||||
## Required Structure
|
||||
|
||||
```
|
||||
template/
|
||||
├── .env # Environment variables (sample)
|
||||
├── .gitignore # Git ignore rules
|
||||
├── AGENTS.md # AI assistant rules
|
||||
├── CHANGELOG.md # Changelog template
|
||||
├── DESIGN_DOCUMENT.md # Development guidelines
|
||||
├── PROJECT.md # Project documentation template
|
||||
├── main.py # Entry point with loguru
|
||||
├── 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
|
||||
└── tests/
|
||||
├── __init__.py
|
||||
└── test_constants.py # Basic test
|
||||
```
|
||||
|
||||
## 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)
|
||||
- **loguru** for logging (never print)
|
||||
- **Poetry** for dependency management
|
||||
- **pytest** for testing (no unittest)
|
||||
- **ruff + mypy** for linting and type checking
|
||||
|
||||
## Rules
|
||||
|
||||
- No `.example` suffixes - the folder itself is the separator
|
||||
- Generic/reusable format
|
||||
- Keep files simple and minimal
|
||||
@@ -0,0 +1,66 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
from src.constants import VERSION
|
||||
|
||||
load_dotenv()
|
||||
|
||||
print("=" * 50)
|
||||
print("PREBUILD CONFIGURATION")
|
||||
print("=" * 50)
|
||||
|
||||
# Check if running in virtual environment
|
||||
project_root = Path(__file__).parent
|
||||
expected_venv_path = project_root / ".venv"
|
||||
current_executable = Path(sys.executable)
|
||||
|
||||
print(f"\nPython executable: {sys.executable}")
|
||||
|
||||
is_correct_venv = False
|
||||
try:
|
||||
current_executable.relative_to(expected_venv_path)
|
||||
is_correct_venv = True
|
||||
except ValueError:
|
||||
is_correct_venv = False
|
||||
|
||||
if is_correct_venv:
|
||||
print("✓ Correct environment selected for building")
|
||||
else:
|
||||
print("✗ Wrong environment selected")
|
||||
print(f" Expected: {expected_venv_path}")
|
||||
print(f" Current: {current_executable.parent.parent}")
|
||||
|
||||
print(f"✓ Version: {VERSION}")
|
||||
|
||||
env_debug = os.getenv("ENV_DEBUG", "false").lower() == "true"
|
||||
console_mode = env_debug
|
||||
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}")
|
||||
|
||||
spec_path = Path(__file__).parent / spec_filename
|
||||
if spec_path.exists():
|
||||
with open(spec_path, "r", encoding="utf-8") as f:
|
||||
spec_content = f.read()
|
||||
|
||||
if f"console={not console_mode}" in spec_content:
|
||||
new_spec_content = spec_content.replace(
|
||||
f"console={not console_mode}",
|
||||
f"console={console_mode}"
|
||||
)
|
||||
with open(spec_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_spec_content)
|
||||
print(f"✓ Updated {spec_filename}: console={console_mode}")
|
||||
else:
|
||||
print(f"✓ {spec_filename} already configured: console={console_mode}")
|
||||
else:
|
||||
print(f"✗ {spec_filename} not found!")
|
||||
|
||||
print(f"{'-' * 50}\n")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Auto-generated version - DO NOT EDIT MANUALLY!
|
||||
|
||||
This file is automatically generated from pyproject.toml.
|
||||
Serves as fallback for cases when TOML is not available.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Generic application constants template.
|
||||
|
||||
Usage in your project:
|
||||
1. Copy this file to src/constants.py
|
||||
2. Fill in APP_NAME and APP_FULL_NAME
|
||||
3. Import VERSION, APP_TITLE, DEFAULT_DEBUG where needed
|
||||
|
||||
Version loading priority:
|
||||
1. pyproject.toml [project] version (preferred)
|
||||
2. src/_version.py __version__ (generated fallback for frozen builds)
|
||||
3. "0.0.0" (last resort)
|
||||
|
||||
Debug mode:
|
||||
Controlled exclusively via .env: ENV_DEBUG=true
|
||||
Accepted true-values: true, 1, yes (case-insensitive)
|
||||
"""
|
||||
|
||||
import os
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ROOT = Path(__file__).parent.parent
|
||||
_PYPROJECT = _ROOT / "pyproject.toml"
|
||||
_VERSION_FILE = Path(__file__).parent / "_version.py"
|
||||
|
||||
|
||||
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):
|
||||
pass
|
||||
|
||||
# 2. _version.py
|
||||
try:
|
||||
from src._version import __version__ # type: ignore[import]
|
||||
return __version__
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 3. last resort
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Debug mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_debug() -> bool:
|
||||
return os.getenv("ENV_DEBUG", "false").lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public constants ← fill in APP_NAME / APP_FULL_NAME for each project
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
APP_NAME: str = "MyApp"
|
||||
APP_FULL_NAME: str = "My Application"
|
||||
|
||||
_VERSION_NUMBER: str = _load_version()
|
||||
DEFAULT_DEBUG: bool = _load_debug()
|
||||
|
||||
VERSION: str = f"v{_VERSION_NUMBER}" + ("DEV" if DEFAULT_DEBUG else "")
|
||||
APP_TITLE: str = f"{APP_FULL_NAME} {VERSION}"
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Tests for constants module."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from unittest.mock import mock_open, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.constants import (
|
||||
APP_NAME,
|
||||
APP_TITLE,
|
||||
APP_VERSION,
|
||||
ENV_DEBUG,
|
||||
get_debug_mode,
|
||||
get_version,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_version()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_debug_mode()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_debug_mode_returns_bool() -> None:
|
||||
"""get_debug_mode() should always return a bool."""
|
||||
assert isinstance(get_debug_mode(), bool)
|
||||
|
||||
|
||||
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_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_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_get_debug_mode_false_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""get_debug_mode() returns False when ENV_DEBUG is not set."""
|
||||
monkeypatch.delenv("ENV_DEBUG", raising=False)
|
||||
assert get_debug_mode() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_env_debug_is_bool() -> None:
|
||||
"""ENV_DEBUG should be a bool."""
|
||||
assert isinstance(ENV_DEBUG, bool)
|
||||
|
||||
|
||||
def test_app_version_is_string() -> None:
|
||||
"""APP_VERSION should be a string."""
|
||||
assert isinstance(APP_VERSION, str)
|
||||
|
||||
|
||||
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_app_name_value() -> None:
|
||||
"""APP_NAME should be 'X4 SavEd'."""
|
||||
assert APP_NAME == "X4 SavEd"
|
||||
|
||||
|
||||
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")
|
||||
Reference in New Issue
Block a user