Python Guide
Applies to: Python 3.11+, APIs, CLIs, Data Pipelines, Automation
Core Principles
- Type Hints Everywhere: All function signatures, class attributes, and module-level variables must have type annotations
- Explicit Over Implicit: No
* imports, no mutable default arguments, no implicit type coercions
- Virtual Environments Always: Never install into system Python; use
venv, uv, or poetry
- Pytest Over unittest: Use pytest for all testing; fixtures and parametrize over setUp/tearDown
- PEP 8 + Ruff: Enforce style mechanically; never rely on manual formatting
Guardrails
Python Version
- Target Python 3.11+ (use
match statements, ExceptionGroup, tomllib)
- Set
requires-python = ">=3.11" in pyproject.toml
- Use
from __future__ import annotations for forward references in 3.11
- Never use features removed in 3.12+ (
distutils, imp, legacy typing aliases)
Code Style
- Run
ruff check and ruff format before every commit
- Max line length: 88 characters (Black default)
- Imports: stdlib, blank line, third-party, blank line, local (enforced by
isort/ruff)
- Naming:
snake_case for functions/variables, PascalCase for classes, UPPER_SNAKE for constants
- No bare
except: — always catch specific exceptions
- No mutable default arguments (
def f(items=None): not def f(items=[]):)
- Prefer f-strings over
.format() or % formatting
- Use
pathlib.Path instead of os.path for all file operations
Type Hints
- All public functions MUST have full type annotations (params + return)
- Use
collections.abc types: Sequence, Mapping, Iterable (not List, Dict)
- Use
X | None union syntax (not Optional[X])
- Use
TypeAlias for complex types: UserMap: TypeAlias = dict[str, User]
- Use
Protocol for structural subtyping (duck typing with safety)
- Use
@overload for functions returning different types based on input
- Run
mypy --strict in CI (no type: ignore without explanation)
from collections.abc import Sequence
def find_users(
ids: Sequence[str],
*,
active_only: bool = True,
) -> list[User]:
"""Fetch users by ID list, optionally filtering inactive."""
...
Error Handling
- Never use bare
except: or except Exception: without re-raising
- Create domain-specific exception hierarchies rooted in a base class
- Use
raise ... from err to preserve exception chains
- Log at the boundary, raise in the interior (don't log-and-raise)
- Use
contextlib.suppress() instead of empty except blocks
- Always close resources with
with statements or contextlib.closing
Dependencies
- Define all deps in
pyproject.toml (not setup.py or bare requirements.txt)
- Pin exact versions in lock files (
uv.lock, poetry.lock, pip-compile output)
- Keep
requirements.txt only as a generated artifact, never hand-edited
- Separate
[project.optional-dependencies] for dev, test, docs
- Audit with
pip-audit or safety before adding new packages
- Prefer stdlib solutions:
tomllib, pathlib, dataclasses, enum, logging
Project Structure
myproject/
├── src/
│ └── myproject/ # Importable package (src layout)
│ ├── __init__.py
│ ├── py.typed # PEP 561 marker for type stubs
│ ├── domain/ # Business logic, entities
│ │ ├── __init__.py
│ │ ├── models.py
│ │ └── exceptions.py
│ ├── service/ # Application services
│ │ └── __init__.py
│ ├── repository/ # Data access layer
│ │ └── __init__.py
│ └── api/ # HTTP/CLI interface
│ └── __init__.py
├── tests/
│ ├── conftest.py # Shared fixtures
│ ├── unit/
│ └── integration/
├── pyproject.toml # Single source of truth for config
├── uv.lock # Or poetry.lock
└── README.md
- Use src layout (
src/myproject/) to prevent accidental local imports
- Keep
conftest.py at test root for shared fixtures; nest for scope
- Include
py.typed marker for downstream type checking
- No
__init__.py in tests/ (pytest discovers without it)
- One module = one responsibility; split at ~200 lines
Error Handling Patterns
Exception Hierarchy
class AppError(Exception):
"""Base exception for the application."""
def __init__(self, message: str, *, code: str = "UNKNOWN") -> None:
super().__init__(message)
self.code = code
class NotFoundError(AppError):
"""Raised when a requested resource does not exist."""
def __init__(self, resource: str, identifier: str) -> None:
super().__init__(
f"{resource} with id '{identifier}' not found",
code="NOT_FOUND",
)
self.resource = resource
self.identifier = identifier
class ValidationError(AppError):
"""Raised when input data fails validation."""
def __init__(self, field: str, reason: str) -> None:
super().__init__(
f"Validation failed for '{field}': {reason}",
code="VALIDATION_ERROR",
)
Context Managers for Cleanup
from contextlib import contextmanager
from collections.abc import Generator
@contextmanager
def managed_connection(url: str) -> Generator[Connection, None, None]:
conn = Connection(url)
try:
conn.open()
yield conn
except ConnectionError as err:
raise AppError("Database unavailable") from err
finally:
conn.close()
Error Chaining
def get_user(user_id: str) -> User:
try:
row = db.fetch_one("SELECT * FROM users WHERE id = %s", (user_id,))
except DatabaseError as err:
raise AppError(f"Failed to fetch user {user_id}") from err
if row is None:
raise NotFoundError("User", user_id)
return User.from_row(row)
Testing
Standards
- Test files:
test_*.py (same name as module: models.py -> test_models.py)
- Test functions:
test_<unit>_<scenario>_<expected> (e.g., test_get_user_not_found_raises)
- Use
conftest.py for fixtures shared across a directory
- Coverage target: >80% for business logic, >60% overall
- Mark slow tests:
@pytest.mark.slow and exclude from default runs
- No
unittest.TestCase — use plain functions with pytest assertions
- Use
tmp_path fixture for file operations (auto-cleanup)
Fixtures and Parametrize
import pytest
from myproject.domain.models import User
@pytest.fixture
def sample_user() -> User:
return User(id="u-123", name="Ada Lovelace", email="ada@example.com")
@pytest.mark.parametrize(
("email", "is_valid"),
[
("user@example.com", True),
("user@.com", False),
("", False),
("user@domain", False),
],
)
def test_validate_email(email: str, is_valid: bool) -> None:
assert validate_email(email) == is_valid
def test_get_user_returns_user(sample_user: User) -> None:
repo = InMemoryUserRepo(users=[sample_user])
result = repo.get("u-123")
assert result == sample_user
def test_get_user_not_found_raises() -> None:
repo = InMemoryUserRepo(users=[])
with pytest.raises(NotFoundError, match="User.*not found"):
repo.get("nonexistent")
Mocking External Dependencies
from unittest.mock import AsyncMock, patch
async def test_send_notification_retries_on_failure() -> None:
mock_client = AsyncMock()
mock_client.post.side_effect = [ConnectionError, None]
with patch("myproject.service.notify.http_client", mock_client):
await send_notification(user_id="u-123", message="hello")
assert mock_client.post.call_count == 2
Tooling
pyproject.toml Configuration
[project]
name = "myproject"
requires-python = ">=3.11"
[project.optional-dependencies]
dev = ["ruff", "mypy", "pytest", "pytest-cov", "pytest-asyncio"]
[tool.ruff]
target-version = "py311"
line-length = 88
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"N", # pep8-naming
"UP", # pyupgrade
"B", # flake8-bugbear
"S", # flake8-bandit (security)
"A", # flake8-builtins
"C4", # flake8-comprehensions
"SIM", # flake8-simplify
"RUF", # ruff-specific rules
]
[tool.mypy]
strict = true
warn_return_any = true
disallow_untyped_defs = true
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"]
asyncio_mode = "auto"
[tool.coverage.run]
source = ["src/myproject"]
branch = true
[tool.coverage.report]
fail_under = 60
show_missing = true
exclude_lines = ["if TYPE_CHECKING:", "pragma: no cover"]
Essential Commands
ruff check . # Lint (replaces flake8, isort, pyupgrade)
ruff format . # Format (replaces black)
mypy . # Type check (strict mode)
pytest # Run all tests
pytest --cov=src -q # Coverage summary
pytest -m "not slow" # Skip slow tests
pip-audit # Check dependencies for vulnerabilities
python -m build # Build sdist + wheel
Advanced Topics
For detailed patterns and examples, see:
- references/patterns.md -- Async patterns, dataclass/Pydantic models, context managers, decorators, type hints (generics, Protocol, TypeVar)
- references/pitfalls.md -- Common Python gotchas and do/don't examples
- references/security.md -- Input sanitization, secrets management, SQL injection prevention
External References
1---2name: python-guide3description: Python guardrails, patterns, and best practices for AI-assisted development. Use when working with Python files (.py), requirements.txt, pyproject.toml, or when the user mentions Python. Provides type hint conventions, virtual environment standards, testing with pytest, and async patterns.4license: MIT5---6
7# Python Guide
8
9> Applies to: Python 3.11+, APIs, CLIs, Data Pipelines, Automation
10
11## Core Principles
12
131. **Type Hints Everywhere**: All function signatures, class attributes, and module-level variables must have type annotations
142. **Explicit Over Implicit**: No `*` imports, no mutable default arguments, no implicit type coercions
153. **Virtual Environments Always**: Never install into system Python; use `venv`, `uv`, or `poetry`
164. **Pytest Over unittest**: Use pytest for all testing; fixtures and parametrize over setUp/tearDown
175. **PEP 8 + Ruff**: Enforce style mechanically; never rely on manual formatting
18
19## Guardrails
20
21### Python Version
22
23- Target Python 3.11+ (use `match` statements, `ExceptionGroup`, `tomllib`)
24- Set `requires-python = ">=3.11"` in `pyproject.toml`
25- Use `from __future__ import annotations` for forward references in 3.11
26- Never use features removed in 3.12+ (`distutils`, `imp`, legacy `typing` aliases)
27
28### Code Style
29
30- Run `ruff check` and `ruff format` before every commit
31- Max line length: 88 characters (Black default)
32- Imports: stdlib, blank line, third-party, blank line, local (enforced by `isort`/`ruff`)
33- Naming: `snake_case` for functions/variables, `PascalCase` for classes, `UPPER_SNAKE` for constants
34- No bare `except:` — always catch specific exceptions
35- No mutable default arguments (`def f(items=None):` not `def f(items=[]):`)
36- Prefer f-strings over `.format()` or `%` formatting
37- Use `pathlib.Path` instead of `os.path` for all file operations
38
39### Type Hints
40
41- All public functions MUST have full type annotations (params + return)
42- Use `collections.abc` types: `Sequence`, `Mapping`, `Iterable` (not `List`, `Dict`)
43- Use `X | None` union syntax (not `Optional[X]`)
44- Use `TypeAlias` for complex types: `UserMap: TypeAlias = dict[str, User]`
45- Use `Protocol` for structural subtyping (duck typing with safety)
46- Use `@overload` for functions returning different types based on input
47- Run `mypy --strict` in CI (no `type: ignore` without explanation)
48
49```python
50from collections.abc import Sequence
51
52def find_users(
53 ids: Sequence[str],
54 *,
55 active_only: bool = True,
56) -> list[User]:
57 """Fetch users by ID list, optionally filtering inactive."""
58 ...
59```
60
61### Error Handling
62
63- Never use bare `except:` or `except Exception:` without re-raising
64- Create domain-specific exception hierarchies rooted in a base class
65- Use `raise ... from err` to preserve exception chains
66- Log at the boundary, raise in the interior (don't log-and-raise)
67- Use `contextlib.suppress()` instead of empty except blocks
68- Always close resources with `with` statements or `contextlib.closing`
69
70### Dependencies
71
72- Define all deps in `pyproject.toml` (not `setup.py` or bare `requirements.txt`)
73- Pin exact versions in lock files (`uv.lock`, `poetry.lock`, `pip-compile` output)
74- Keep `requirements.txt` only as a generated artifact, never hand-edited
75- Separate `[project.optional-dependencies]` for dev, test, docs
76- Audit with `pip-audit` or `safety` before adding new packages
77- Prefer stdlib solutions: `tomllib`, `pathlib`, `dataclasses`, `enum`, `logging`
78
79## Project Structure
80
81```
82myproject/
83├── src/
84│ └── myproject/ # Importable package (src layout)
85│ ├── __init__.py
86│ ├── py.typed # PEP 561 marker for type stubs
87│ ├── domain/ # Business logic, entities
88│ │ ├── __init__.py
89│ │ ├── models.py
90│ │ └── exceptions.py
91│ ├── service/ # Application services
92│ │ └── __init__.py
93│ ├── repository/ # Data access layer
94│ │ └── __init__.py
95│ └── api/ # HTTP/CLI interface
96│ └── __init__.py
97├── tests/
98│ ├── conftest.py # Shared fixtures
99│ ├── unit/
100│ └── integration/
101├── pyproject.toml # Single source of truth for config
102├── uv.lock # Or poetry.lock
103└── README.md
104```
105
106- Use **src layout** (`src/myproject/`) to prevent accidental local imports
107- Keep `conftest.py` at test root for shared fixtures; nest for scope
108- Include `py.typed` marker for downstream type checking
109- No `__init__.py` in `tests/` (pytest discovers without it)
110- One module = one responsibility; split at ~200 lines
111
112## Error Handling Patterns
113
114### Exception Hierarchy
115
116```python
117class AppError(Exception):
118 """Base exception for the application."""
119
120 def __init__(self, message: str, *, code: str = "UNKNOWN") -> None:
121 super().__init__(message)
122 self.code = code
123
124
125class NotFoundError(AppError):
126 """Raised when a requested resource does not exist."""
127
128 def __init__(self, resource: str, identifier: str) -> None:
129 super().__init__(
130 f"{resource} with id '{identifier}' not found",
131 code="NOT_FOUND",
132 )
133 self.resource = resource
134 self.identifier = identifier
135
136
137class ValidationError(AppError):
138 """Raised when input data fails validation."""
139
140 def __init__(self, field: str, reason: str) -> None:
141 super().__init__(
142 f"Validation failed for '{field}': {reason}",
143 code="VALIDATION_ERROR",
144 )
145```
146
147### Context Managers for Cleanup
148
149```python
150from contextlib import contextmanager
151from collections.abc import Generator
152
153@contextmanager
154def managed_connection(url: str) -> Generator[Connection, None, None]:
155 conn = Connection(url)
156 try:
157 conn.open()
158 yield conn
159 except ConnectionError as err:
160 raise AppError("Database unavailable") from err
161 finally:
162 conn.close()
163```
164
165### Error Chaining
166
167```python
168def get_user(user_id: str) -> User:
169 try:
170 row = db.fetch_one("SELECT * FROM users WHERE id = %s", (user_id,))
171 except DatabaseError as err:
172 raise AppError(f"Failed to fetch user {user_id}") from err
173 if row is None:
174 raise NotFoundError("User", user_id)
175 return User.from_row(row)
176```
177
178## Testing
179
180### Standards
181
182- Test files: `test_*.py` (same name as module: `models.py` -> `test_models.py`)
183- Test functions: `test_<unit>_<scenario>_<expected>` (e.g., `test_get_user_not_found_raises`)
184- Use `conftest.py` for fixtures shared across a directory
185- Coverage target: >80% for business logic, >60% overall
186- Mark slow tests: `@pytest.mark.slow` and exclude from default runs
187- No `unittest.TestCase` — use plain functions with pytest assertions
188- Use `tmp_path` fixture for file operations (auto-cleanup)
189
190### Fixtures and Parametrize
191
192```python
193import pytest
194from myproject.domain.models import User
195
196@pytest.fixture
197def sample_user() -> User:
198 return User(id="u-123", name="Ada Lovelace", email="ada@example.com")
199
200
201@pytest.mark.parametrize(
202 ("email", "is_valid"),
203 [
204 ("user@example.com", True),
205 ("user@.com", False),
206 ("", False),
207 ("user@domain", False),
208 ],
209)
210def test_validate_email(email: str, is_valid: bool) -> None:
211 assert validate_email(email) == is_valid
212
213
214def test_get_user_returns_user(sample_user: User) -> None:
215 repo = InMemoryUserRepo(users=[sample_user])
216 result = repo.get("u-123")
217 assert result == sample_user
218
219
220def test_get_user_not_found_raises() -> None:
221 repo = InMemoryUserRepo(users=[])
222 with pytest.raises(NotFoundError, match="User.*not found"):
223 repo.get("nonexistent")
224```
225
226### Mocking External Dependencies
227
228```python
229from unittest.mock import AsyncMock, patch
230
231async def test_send_notification_retries_on_failure() -> None:
232 mock_client = AsyncMock()
233 mock_client.post.side_effect = [ConnectionError, None]
234
235 with patch("myproject.service.notify.http_client", mock_client):
236 await send_notification(user_id="u-123", message="hello")
237
238 assert mock_client.post.call_count == 2
239```
240
241## Tooling
242
243### pyproject.toml Configuration
244
245```toml
246[project]
247name = "myproject"
248requires-python = ">=3.11"
249
250[project.optional-dependencies]
251dev = ["ruff", "mypy", "pytest", "pytest-cov", "pytest-asyncio"]
252
253[tool.ruff]
254target-version = "py311"
255line-length = 88
256
257[tool.ruff.lint]
258select = [
259 "E", # pycodestyle errors
260 "W", # pycodestyle warnings
261 "F", # pyflakes
262 "I", # isort
263 "N", # pep8-naming
264 "UP", # pyupgrade
265 "B", # flake8-bugbear
266 "S", # flake8-bandit (security)
267 "A", # flake8-builtins
268 "C4", # flake8-comprehensions
269 "SIM", # flake8-simplify
270 "RUF", # ruff-specific rules
271]
272
273[tool.mypy]
274strict = true
275warn_return_any = true
276disallow_untyped_defs = true
277
278[tool.pytest.ini_options]
279testpaths = ["tests"]
280markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"]
281asyncio_mode = "auto"
282
283[tool.coverage.run]
284source = ["src/myproject"]
285branch = true
286
287[tool.coverage.report]
288fail_under = 60
289show_missing = true
290exclude_lines = ["if TYPE_CHECKING:", "pragma: no cover"]
291```
292
293### Essential Commands
294
295```bash
296ruff check . # Lint (replaces flake8, isort, pyupgrade)
297ruff format . # Format (replaces black)
298mypy . # Type check (strict mode)
299pytest # Run all tests
300pytest --cov=src -q # Coverage summary
301pytest -m "not slow" # Skip slow tests
302pip-audit # Check dependencies for vulnerabilities
303python -m build # Build sdist + wheel
304```
305
306## Advanced Topics
307
308For detailed patterns and examples, see:
309
310- [references/patterns.md](references/patterns.md) -- Async patterns, dataclass/Pydantic models, context managers, decorators, type hints (generics, Protocol, TypeVar)
311- [references/pitfalls.md](references/pitfalls.md) -- Common Python gotchas and do/don't examples
312- [references/security.md](references/security.md) -- Input sanitization, secrets management, SQL injection prevention
313
314## External References
315
316- [PEP 8 -- Style Guide](https://peps.python.org/pep-0008/)
317- [PEP 484 -- Type Hints](https://peps.python.org/pep-0484/)
318- [Ruff Documentation](https://docs.astral.sh/ruff/)
319- [mypy Documentation](https://mypy.readthedocs.io/)
320- [pytest Documentation](https://docs.pytest.org/)
321- [Python Packaging Guide](https://packaging.python.org/)
322- [Effective Python (Brett Slatkin)](https://effectivepython.com/)