Application and library conventions for plain Python projects (no web framework): src/ package layout, CLI tooling (argparse/click/typer), configuration management (pydantic-settings), logging setup, module design, entry points, and packaging conventions. Activated automatically by python-plugin/stack.md as a convention skill for the development phase. Use this skill to: - Organise Python application code in a src/ layout with proper package structure. - Build CLI tools with argparse, click,...
Scanned 9/22/2026
Install to Claude Code
npx -y skills add AratKruglik/claude-sdlc --skill python-app-conventions --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Python App Conventions?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/aratkruglik-python-app-conventions)More formats (shields.io, HTML) on the badges page.
---
name: python-app-conventions
description: |
Application and library conventions for plain Python projects (no web framework): src/ package layout, CLI tooling (argparse/click/typer), configuration management (pydantic-settings), logging setup, module design, entry points, and packaging conventions. Activated automatically by python-plugin/stack.md as a convention skill for the development phase.
Use this skill to:
- Organise Python application code in a src/ layout with proper package structure.
- Build CLI tools with argparse, click, or typer and register them as console scripts.
- Manage configuration from environment variables using pydantic-settings.
- Set up structured logging for production-grade applications.
- Package a Python project correctly with pyproject.toml.
Do NOT use this skill for:
- Language idioms (type hints, dataclasses, enums, match/case) — see python-foundation:python-conventions.
- Package manager commands (ruff, mypy, pip/poetry/uv) — see python-foundation:python-tooling.
- Testing patterns — see python-foundation:pytest-testing.
- Web framework patterns (Django/FastAPI/Flask) — see those framework plugin skills.
user-invocable: false
paths: ["**/*.py"]
---
# Python Application Conventions
## Detection — understanding the project type
Before writing code, read `pyproject.toml` (or `setup.py` / `requirements.txt`) to understand what kind of project this is:
```toml
# pyproject.toml signals to look for:
[project.scripts] # → CLI tool; entry points are registered here
myapp = "myapp.cli:main"
[project]
dependencies = [ # → check for framework deps (fastapi, django, flask)
"pydantic-settings", # → configuration via env vars
"click", # → CLI framework in use
]
[build-system]
requires = ["poetry-core"] # → Poetry project
requires = ["hatchling"] # → Hatch project
requires = ["setuptools"] # → setuptools / pip project
```
No `fastapi`, `django`, or `flask` in dependencies → plain Python project; this skill applies.
---
## Source layout
### Preferred: `src/` layout
```
myproject/
├── pyproject.toml
├── README.md
├── src/
│ └── mypackage/
│ ├── __init__.py # expose public API only — not everything
│ ├── __main__.py # enables: python -m mypackage
│ ├── py.typed # PEP 561 marker — enables mypy type checking by consumers
│ ├── cli.py # CLI entry point (argparse / click / typer)
│ ├── config.py # pydantic-settings Settings class
│ ├── core.py # core business logic
│ └── exporters/
│ ├── __init__.py
│ └── csv_exporter.py
└── tests/
├── conftest.py
├── test_core.py
└── exporters/
└── test_csv_exporter.py
```
`__init__.py` exposes the public API explicitly:
```python
# src/mypackage/__init__.py
from mypackage.core import Pipeline
from mypackage.exporters.csv_exporter import CsvExporter
__all__ = ["Pipeline", "CsvExporter"]
```
### Acceptable: flat layout (small projects / scripts)
```
myproject/
├── pyproject.toml
├── mypackage.py # single-module library
└── tests/
└── test_mypackage.py
```
Or a package without `src/`:
```
myproject/
├── pyproject.toml
├── mypackage/
│ ├── __init__.py
│ └── core.py
└── tests/
└── conftest.py
```
Match whichever layout the project already uses. Never restructure an existing project unless the BA spec explicitly requires it.
---
## CLI with argparse (stdlib, no additional deps)
Use argparse when the project has no CLI framework in its dependencies and adding one is out of scope.
```python
# src/mypackage/cli.py
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from mypackage.config import Settings
from mypackage.core import Pipeline
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="mypackage",
description="Process data files and export results.",
)
sub = parser.add_subparsers(dest="command", required=True)
run_cmd = sub.add_parser("run", help="Run the pipeline.")
run_cmd.add_argument("input", type=Path, help="Input file path.")
run_cmd.add_argument(
"--output",
type=Path,
default=None,
help="Output directory (default: from MYAPP_OUTPUT_DIR env var).",
)
run_cmd.add_argument(
"--format",
choices=["csv", "json"],
default="csv",
help="Output format.",
)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
settings = Settings()
output_dir = args.output or settings.output_dir
try:
pipeline = Pipeline(settings=settings)
pipeline.run(input_path=args.input, output_dir=output_dir, fmt=args.format)
except FileNotFoundError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
```
---
## CLI with click (feature-rich, composable commands)
Use click when it is already in the project's dependencies, or when the CLI has many subcommands, option validation, or prompt interactions.
```python
# src/mypackage/cli.py
from __future__ import annotations
from pathlib import Path
import click
from mypackage.config import Settings
from mypackage.core import Pipeline
@click.group()
def cli() -> None:
"""Process data files and export results."""
@cli.command()
@click.argument("input", type=click.Path(exists=True, path_type=Path))
@click.option(
"--output",
type=click.Path(path_type=Path),
default=None,
help="Output directory. Defaults to MYAPP_OUTPUT_DIR env var.",
)
@click.option(
"--format",
"fmt",
type=click.Choice(["csv", "json"]),
default="csv",
show_default=True,
)
def run(input: Path, output: Path | None, fmt: str) -> None:
"""Run the pipeline on INPUT file."""
settings = Settings()
output_dir = output or settings.output_dir
Pipeline(settings=settings).run(input_path=input, output_dir=output_dir, fmt=fmt)
def main() -> None:
cli()
```
**When to prefer each CLI framework:**
| Framework | Choose when |
|---|---|
| `argparse` | No CLI deps allowed; stdlib only; simple, stable CLI |
| `click` | Feature-rich CLI (prompts, colors, progress bars); composable command groups; already in the project |
| `typer` | Type-annotated, FastAPI-style API; rapid prototyping; team already uses FastAPI/Pydantic |
Match what the project already uses. Do not introduce a new CLI framework without BA approval.
---
## CLI with typer
Use typer when it is already in the project's dependencies, or when the team prefers type-annotated CLI definitions.
```python
# src/mypackage/cli.py
from __future__ import annotations
from pathlib import Path
from typing import Annotated
import typer
from mypackage.config import Settings
from mypackage.core import Pipeline
app = typer.Typer(help="Process data files and export results.")
@app.command()
def run(
input: Annotated[Path, typer.Argument(help="Input file path.", exists=True)],
output: Annotated[
Path | None,
typer.Option(help="Output directory. Defaults to MYAPP_OUTPUT_DIR env var."),
] = None,
fmt: Annotated[str, typer.Option("--format", help="Output format.")] = "csv",
) -> None:
"""Run the pipeline on INPUT."""
settings = Settings()
output_dir = output or settings.output_dir
Pipeline(settings=settings).run(input_path=input, output_dir=output_dir, fmt=fmt)
def main() -> None:
app()
```
---
## Configuration with pydantic-settings
Read all configuration from environment variables (and optionally a `.env` file). Never call `os.environ.get()` inline — consolidate all env var reads into a single `Settings` class.
```python
# src/mypackage/config.py
from __future__ import annotations
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="MYAPP_", # MYAPP_OUTPUT_DIR, MYAPP_LOG_LEVEL, etc.
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
output_dir: Path = Field(default=Path("/tmp/myapp-output"), description="Output directory for exported files.")
log_level: str = Field(default="INFO", description="Logging level (DEBUG, INFO, WARNING, ERROR).")
api_key: str = Field(description="External API key. Required. Set via MYAPP_API_KEY env var.")
max_workers: int = Field(default=4, ge=1, le=32, description="Thread pool size for parallel processing.")
# Singleton — import this throughout the codebase instead of creating new instances
settings = Settings()
```
**Nested settings** for complex configuration:
```python
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
class DatabaseSettings(BaseModel):
host: str = "localhost"
port: int = 5432
name: str = "myapp"
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="MYAPP_", env_nested_delimiter="__")
database: DatabaseSettings = DatabaseSettings()
# Set via: MYAPP_DATABASE__HOST=db.prod.example.com
```
**Rules:**
- Always use `env_prefix` to namespace your application's env vars.
- Mark required fields (no default) — pydantic-settings raises `ValidationError` at startup if they are missing, giving a clear error message.
- Never read `os.environ` directly in business logic — always go through `Settings`.
---
## Structured logging
Use Python's standard `logging` module. Configure it once at the application entry point. Never use `print()` for diagnostics.
```python
# src/mypackage/logging_config.py
from __future__ import annotations
import logging
import sys
def configure_logging(level: str = "INFO") -> None:
"""Configure root logger for the application. Call once at startup."""
logging.basicConfig(
level=level.upper(),
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
stream=sys.stderr,
)
```
**In every module**, get a module-scoped logger:
```python
# src/mypackage/core.py
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
class Pipeline:
def run(self, input_path: Path, ...) -> None:
logger.info("Starting pipeline run", extra={"input": str(input_path)})
try:
result = self._process(input_path)
logger.debug("Processing complete, %d records produced", len(result))
except OSError as exc:
logger.error("Failed to read input file: %s", exc)
raise
```
**JSON logging for production** (use `python-json-logger` or `structlog` when already in the project):
```python
# with python-json-logger
import logging
from pythonjsonlogger.json import JsonFormatter
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
logging.root.addHandler(handler)
logging.root.setLevel("INFO")
```
**Rule:** never use `print()` for diagnostics outside of `__main__.py` / `cli.py` (where printing to stdout is intentional CLI output, not debug noise).
---
## Entry points and pyproject.toml packaging
Register CLI commands as console scripts so they are available after `pip install` / `poetry install`:
```toml
# pyproject.toml
[project]
name = "mypackage"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"pydantic-settings>=2.0",
"click>=8.0",
]
[project.scripts]
myapp = "mypackage.cli:main" # installs `myapp` command
myapp-admin = "mypackage.admin_cli:main" # second entry point if needed
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov",
"ruff",
"mypy",
]
```
`__main__.py` allows `python -m mypackage` without installing the package:
```python
# src/mypackage/__main__.py
import sys
from mypackage.cli import main
sys.exit(main())
```
---
## Module design anti-patterns
| Do NOT | Do instead |
|---|---|
| Import everything in `__init__.py` | Expose only the public API (`__all__`) — lazy imports or explicit imports of public symbols only |
| Use mutable default arguments (`def f(items=[])`) | Use `None` as default and initialise inside the function (`if items is None: items = []`) |
| Use global mutable state (`_cache = {}` at module level) | Inject dependencies via constructor or function argument; use `functools.lru_cache` for pure memoisation |
| Use `print()` for diagnostics in library code | Use `logging.getLogger(__name__)` — callers control the log level and destination |
| Catch bare `except:` | Catch specific exceptions (`except ValueError:`, `except OSError as e:`) |
| Inline `os.environ.get("API_KEY")` throughout codebase | Centralise all env-var reads in a `Settings` class (pydantic-settings or python-decouple) |
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!