@baselane/packs 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/aliases.js +1 -0
  2. package/package.json +2 -2
  3. package/packs/database-review/pack.json +75 -9
  4. package/packs/database-review/rendered/.claude/agents/database-reviewer.md +75 -2
  5. package/packs/database-review/rendered/.claude/commands/migration-review.md +6 -0
  6. package/packs/database-review/rendered/.claude/settings.json +11 -0
  7. package/packs/database-review/rendered/.claude/skills/database-migrations/SKILL.md +431 -0
  8. package/packs/database-review/rendered/.claude/skills/mysql-patterns/SKILL.md +414 -0
  9. package/packs/database-review/rendered/.claude/skills/postgres-patterns/SKILL.md +149 -0
  10. package/packs/database-review/rendered/.github/copilot-instructions.md +1096 -14
  11. package/packs/database-review/rendered/AGENTS.md +45 -16
  12. package/packs/database-review/rendered/CLAUDE.md +4 -0
  13. package/packs/database-review/rendered/GEMINI.md +987 -0
  14. package/packs/go-rules/pack.json +65 -9
  15. package/packs/go-rules/rendered/.claude/agents/go-build-resolver.md +90 -0
  16. package/packs/go-rules/rendered/.claude/agents/go-reviewer.md +72 -2
  17. package/packs/go-rules/rendered/.claude/commands/go-build.md +6 -0
  18. package/packs/go-rules/rendered/.claude/commands/go-review.md +1 -1
  19. package/packs/go-rules/rendered/.claude/commands/go-test.md +6 -0
  20. package/packs/go-rules/rendered/.claude/settings.json +9 -0
  21. package/packs/go-rules/rendered/.claude/skills/golang-patterns/SKILL.md +673 -0
  22. package/packs/go-rules/rendered/.claude/skills/golang-testing/SKILL.md +719 -0
  23. package/packs/go-rules/rendered/.github/copilot-instructions.md +1783 -7
  24. package/packs/go-rules/rendered/AGENTS.md +244 -8
  25. package/packs/go-rules/rendered/CLAUDE.md +5 -0
  26. package/packs/go-rules/rendered/GEMINI.md +1394 -0
  27. package/packs/python-rules/pack.json +87 -10
  28. package/packs/python-rules/rendered/.claude/agents/django-reviewer.md +159 -0
  29. package/packs/python-rules/rendered/.claude/agents/fastapi-reviewer.md +70 -0
  30. package/packs/python-rules/rendered/.claude/agents/python-reviewer.md +92 -2
  31. package/packs/python-rules/rendered/.claude/commands/django-review.md +6 -0
  32. package/packs/python-rules/rendered/.claude/commands/fastapi-review.md +6 -0
  33. package/packs/python-rules/rendered/.claude/commands/python-review.md +1 -1
  34. package/packs/python-rules/rendered/.claude/settings.json +1 -1
  35. package/packs/python-rules/rendered/.claude/skills/django-security/SKILL.md +642 -0
  36. package/packs/python-rules/rendered/.claude/skills/django-tdd/SKILL.md +728 -0
  37. package/packs/python-rules/rendered/.claude/skills/fastapi-patterns/SKILL.md +512 -0
  38. package/packs/python-rules/rendered/.claude/skills/python-patterns/SKILL.md +749 -0
  39. package/packs/python-rules/rendered/.claude/skills/python-testing/SKILL.md +815 -0
  40. package/packs/python-rules/rendered/.github/copilot-instructions.md +3815 -12
  41. package/packs/python-rules/rendered/AGENTS.md +73 -13
  42. package/packs/python-rules/rendered/CLAUDE.md +9 -0
  43. package/packs/python-rules/rendered/GEMINI.md +3448 -0
  44. package/packs/security-review/pack.json +51 -7
  45. package/packs/security-review/rendered/.claude/agents/security-reviewer.md +29 -2
  46. package/packs/security-review/rendered/.claude/settings.json +11 -0
  47. package/packs/security-review/rendered/.claude/skills/security-review/SKILL.md +302 -0
  48. package/packs/security-review/rendered/.claude/skills/security-review/cloud-infrastructure-security.md +171 -0
  49. package/packs/security-review/rendered/.claude/skills/security-scan-config/SKILL.md +56 -0
  50. package/packs/security-review/rendered/.github/copilot-instructions.md +658 -15
  51. package/packs/security-review/rendered/AGENTS.md +283 -17
  52. package/packs/security-review/rendered/CLAUDE.md +2 -0
  53. package/packs/security-review/rendered/GEMINI.md +352 -0
  54. package/packs/software-engineer-harness/pack.json +249 -13
  55. package/packs/software-engineer-harness/rendered/.claude/agents/code-explorer.md +67 -0
  56. package/packs/software-engineer-harness/rendered/.claude/agents/performance-optimizer.md +444 -0
  57. package/packs/software-engineer-harness/rendered/.claude/agents/pr-test-analyzer.md +43 -0
  58. package/packs/software-engineer-harness/rendered/.claude/commands/feature-dev.md +6 -0
  59. package/packs/software-engineer-harness/rendered/.claude/settings.json +29 -0
  60. package/packs/software-engineer-harness/rendered/.claude/skills/api-design/SKILL.md +522 -0
  61. package/packs/software-engineer-harness/rendered/.claude/skills/architecture-decision-records/SKILL.md +178 -0
  62. package/packs/software-engineer-harness/rendered/.claude/skills/backend-patterns/SKILL.md +560 -0
  63. package/packs/software-engineer-harness/rendered/.claude/skills/codebase-onboarding/SKILL.md +232 -0
  64. package/packs/software-engineer-harness/rendered/.claude/skills/deployment-patterns/SKILL.md +426 -0
  65. package/packs/software-engineer-harness/rendered/.claude/skills/docker-patterns/SKILL.md +363 -0
  66. package/packs/software-engineer-harness/rendered/.claude/skills/e2e-testing/SKILL.md +325 -0
  67. package/packs/software-engineer-harness/rendered/.claude/skills/tdd-workflow/SKILL.md +580 -0
  68. package/packs/software-engineer-harness/rendered/.github/copilot-instructions.md +3948 -2
  69. package/packs/software-engineer-harness/rendered/AGENTS.md +194 -4
  70. package/packs/software-engineer-harness/rendered/CLAUDE.md +20 -0
  71. package/packs/software-engineer-harness/rendered/GEMINI.md +3208 -0
  72. package/packs/typescript-rules/pack.json +68 -5
  73. package/packs/typescript-rules/rendered/.claude/agents/react-reviewer.md +165 -0
  74. package/packs/typescript-rules/rendered/.claude/agents/type-design-analyzer.md +48 -0
  75. package/packs/typescript-rules/rendered/.claude/commands/react-review.md +6 -0
  76. package/packs/typescript-rules/rendered/.claude/settings.json +9 -0
  77. package/packs/typescript-rules/rendered/.claude/skills/react-patterns/SKILL.md +340 -0
  78. package/packs/typescript-rules/rendered/.claude/skills/react-performance/SKILL.md +573 -0
  79. package/packs/typescript-rules/rendered/.claude/skills/react-testing/SKILL.md +422 -0
  80. package/packs/typescript-rules/rendered/.github/copilot-instructions.md +2459 -53
  81. package/packs/typescript-rules/rendered/AGENTS.md +893 -17
  82. package/packs/typescript-rules/rendered/CLAUDE.md +6 -0
  83. package/packs/typescript-rules/rendered/GEMINI.md +1335 -0
  84. package/packs/ecc-essentials/pack.json +0 -93
  85. package/packs/ecc-essentials/rendered/.claude/settings.json +0 -37
  86. package/packs/ecc-essentials/rendered/.github/copilot-instructions.md +0 -110
  87. package/packs/ecc-essentials/rendered/AGENTS.md +0 -74
  88. package/packs/ecc-essentials/rendered/CLAUDE.md +0 -14
  89. package/packs/ecc-essentials/rendered/GEMINI.md +0 -25
  90. /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/agents/build-error-resolver.md +0 -0
  91. /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/agents/refactor-cleaner.md +0 -0
  92. /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/agents/silent-failure-hunter.md +0 -0
  93. /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/agents/tdd-guide.md +0 -0
  94. /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/commands/fix-build.md +0 -0
  95. /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/commands/hunt-silent-failures.md +0 -0
  96. /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/commands/tdd.md +0 -0
  97. /package/packs/{ecc-essentials → software-engineer-harness}/rendered/.claude/skills/tdd-red-green-refactor/SKILL.md +0 -0
@@ -2,42 +2,3845 @@
2
2
 
3
3
  ## Python conventions (beyond what the linter enforces)
4
4
 
5
- `ruff` and `black` already enforce PEP 8, import order, bare `except`, and mutable default arguments — this pack is the judgment they can't encode. Keep the mechanical checks in CI (`ruff check .`, `bandit -r src/`, `pytest --cov`); the reviewer covers the rest.
5
+ `ruff` and `black` already enforce PEP 8, import order, bare `except`, and mutable default arguments — this pack is the judgment they can't encode. Keep the mechanical checks in CI (`ruff check .`, `bandit -r src/`, `pytest --cov`); the reviewers cover the rest.
6
+
7
+ ### Standards and formatting
8
+
9
+ - Follow PEP 8 conventions; annotate every function signature, including internal helpers.
10
+ - Format with **black**, sort imports with **isort**, lint with **ruff**.
11
+ - Import order: stdlib, then third-party, then local — enforced by isort.
12
+
13
+ ### Core principles
14
+
15
+ - **Readability counts.** Code should be obvious; prefer a clear loop over a clever one-liner.
16
+ - **Explicit is better than implicit.** Avoid hidden side effects (e.g. a bare `some_module.setup()` import with no visible call site).
17
+ - **EAFP over LBYL.** Prefer `try`/`except KeyError` over `if key in dictionary` — Python favors asking forgiveness over checking permission first.
6
18
 
7
19
  ### Data shapes
8
20
 
9
21
  - Model value objects as `@dataclass(frozen=True)` or `NamedTuple`, not a mutable class with public attributes.
10
22
  - Use a `Protocol` when a caller needs only an interface, not a concrete base class; use plain `@dataclass` for request/DTO shapes at API and service boundaries.
11
- - Acquire every resource (files, locks, connections) through a context manager (`with`); use generators for lazy, memory-efficient iteration.
12
-
13
- ### Types
14
-
15
- - Annotate every function signature, including internal helpers — not just the public API a type checker would force.
23
+ - Validate invariants in `__post_init__` (e.g. reject a malformed email or an out-of-range age at construction, not later).
24
+ - Acquire every resource (files, locks, connections, DB transactions) through a context manager (`with`); write custom ones with `@contextlib.contextmanager` or an `__enter__`/`__exit__` class when reuse pays off. Use generators for lazy, memory-efficient iteration and to avoid building large intermediate lists.
16
25
 
17
26
  ### Errors
18
27
 
19
28
  - Catch specific exception types and preserve the chain when wrapping (`raise NewError(...) from err`); never swallow into a bare `except` or drop the cause.
29
+ - Build a small custom exception hierarchy (`AppError` → `ValidationError`, `NotFoundError`, ...) instead of raising bare `Exception` or built-ins for domain errors.
20
30
 
21
31
  ### Boundaries
22
32
 
23
33
  - Validate external input at the boundary before it reaches business logic; fail fast with a clear message (Pydantic or an explicit check).
24
- - Read required secrets with `os.environ["KEY"]` so a missing value raises at startup — never `os.environ.get(...)` with a fallback that lets a misconfigured app boot.
34
+ - Read required secrets with `os.environ["KEY"]` so a missing value raises at startup — never `os.environ.get(...)` with a fallback that lets a misconfigured app boot silently.
35
+
36
+ ### Concurrency
37
+
38
+ - Use `ThreadPoolExecutor` for I/O-bound work, `ProcessPoolExecutor` for CPU-bound work, and `asyncio`/`aiohttp` for concurrent I/O — pick the tool that matches what's actually blocking.
39
+ - Guard shared mutable state with `threading.Lock`; don't mix sync and async call paths in the same function.
40
+ - Batch queries instead of issuing one per loop iteration (N+1).
25
41
 
26
- ### Flag in review (a linter won't)
42
+ ### Performance
27
43
 
44
+ - Avoid `result += str(item)` in a loop (O(n²) due to string immutability) — use `"".join(...)` or `io.StringIO`.
45
+ - Use `__slots__` on high-volume value classes to cut per-instance memory.
46
+ - Prefer a generator over building a full list when the data is only consumed once.
47
+
48
+ ### Anti-patterns (flag in review — a linter won't catch these)
49
+
50
+ - Mutable default arguments: `def f(x=[])` — use `def f(x=None)` and create the list inside.
51
+ - `type(obj) == list` instead of `isinstance(obj, list)`.
52
+ - `value == None` instead of `value is None`.
53
+ - `from module import *` — namespace pollution.
28
54
  - A mutable class used where a frozen value object belongs.
29
55
  - A concrete base class used where a `Protocol` would decouple the caller.
30
56
  - A wrapped exception that drops `from err` and loses the cause.
31
57
  - A required secret read via `.get()` with a silent fallback default.
32
58
 
59
+ ### Security
60
+
61
+ - Read secrets from the environment (`os.environ["KEY"]`, or `python-dotenv`'s `load_dotenv()` in development) — never hardcode them.
62
+ - Run **bandit** for static security analysis: `bandit -r src/`.
63
+ - SQL injection: never interpolate user input into a query string — use parameterized queries or the ORM.
64
+ - Command injection: never pass unvalidated input to a shell — use `subprocess` with a list of args, not `shell=True` with a formatted string.
65
+ - Path traversal: validate user-controlled paths with `normpath` and reject `..` segments.
66
+ - Avoid `eval`/`exec` on untrusted input, unsafe deserialization (`pickle`, `yaml.load` without `SafeLoader`), and weak crypto (MD5/SHA1 for anything security-sensitive).
67
+
68
+ ### Testing
69
+
70
+ - Use **pytest**; follow the TDD cycle (red — write a failing test, green — minimal code to pass, refactor — clean up while green).
71
+ - Target 80%+ coverage overall, 100% on critical paths: `pytest --cov=src --cov-report=term-missing`.
72
+ - Use `pytest.mark` to categorize tests (`unit`, `integration`, `slow`) and `pytest -m "not slow"` to skip the slow ones in a fast local loop.
73
+ - Mock external dependencies (`unittest.mock.patch`), not the code under test; prefer `tmp_path` over hand-rolled temp files.
74
+
75
+ ### Reference
76
+
77
+ See skills `python-patterns` (idioms, decorators, concurrency, package layout), `python-testing` (pytest fixtures, mocking, parametrization), `fastapi-patterns` (async API layering), and `django-security`/`django-tdd` for framework-specific depth.
78
+
33
79
  ## Workflow pack: Python rules
34
80
 
35
- Python conventions a linter can't check — frozen dataclasses for value objects, Protocols for structural typing, raise ... from, os.environ[] over silent .get() fallbacks — with a reviewer and a ruff guard; style and coverage belong in CI.
81
+ Full ECC Python corpus: conventions, three framework-aware reviewers (Python/FastAPI/Django), five deep-reference skills, and a ruff guard hook.
82
+
83
+ When acting as the python-reviewer role: You are a senior Python code reviewer ensuring high standards of Pythonic code and best practices.
84
+
85
+ When invoked:
86
+ 1. Run `git diff -- '*.py'` to see recent Python file changes
87
+ 2. Run static analysis tools if available (ruff, mypy, pylint, black --check)
88
+ 3. Focus on modified `.py` files
89
+ 4. Begin review immediately
90
+
91
+ ## Review Priorities
92
+
93
+ ### CRITICAL — Security
94
+ - **SQL Injection**: f-strings in queries — use parameterized queries
95
+ - **Command Injection**: unvalidated input in shell commands — use subprocess with list args
96
+ - **Path Traversal**: user-controlled paths — validate with normpath, reject `..`
97
+ - **Eval/exec abuse**, **unsafe deserialization**, **hardcoded secrets**
98
+ - **Weak crypto** (MD5/SHA1 for security), **YAML unsafe load**
99
+
100
+ ### CRITICAL — Error Handling
101
+ - **Bare except**: `except: pass` — catch specific exceptions
102
+ - **Swallowed exceptions**: silent failures — log and handle
103
+ - **Missing context managers**: manual file/resource management — use `with`
104
+
105
+ ### HIGH — Type Hints
106
+ - Public functions without type annotations
107
+ - Using `Any` when specific types are possible
108
+ - Missing `Optional` for nullable parameters
109
+
110
+ ### HIGH — Pythonic Patterns
111
+ - Use list comprehensions over C-style loops
112
+ - Use `isinstance()` not `type() ==`
113
+ - Use `Enum` not magic numbers
114
+ - Use `"".join()` not string concatenation in loops
115
+ - **Mutable default arguments**: `def f(x=[])` — use `def f(x=None)`
116
+
117
+ ### HIGH — Code Quality
118
+ - Functions > 50 lines, > 5 parameters (use dataclass)
119
+ - Deep nesting (> 4 levels)
120
+ - Duplicate code patterns
121
+ - Magic numbers without named constants
122
+
123
+ ### HIGH — Concurrency
124
+ - Shared state without locks — use `threading.Lock`
125
+ - Mixing sync/async incorrectly
126
+ - N+1 queries in loops — batch query
127
+
128
+ ### MEDIUM — Best Practices
129
+ - PEP 8: import order, naming, spacing
130
+ - Missing docstrings on public functions
131
+ - `print()` instead of `logging`
132
+ - `from module import *` — namespace pollution
133
+ - `value == None` — use `value is None`
134
+ - Shadowing builtins (`list`, `dict`, `str`)
135
+
136
+ ## Diagnostic Commands
137
+
138
+ ```bash
139
+ mypy . # Type checking
140
+ ruff check . # Fast linting
141
+ black --check . # Format check
142
+ bandit -r . # Security scan
143
+ pytest --cov=app --cov-report=term-missing # Test coverage
144
+ ```
145
+
146
+ ## Review Output Format
147
+
148
+ ```text
149
+ [SEVERITY] Issue title
150
+ File: path/to/file.py:42
151
+ Issue: Description
152
+ Fix: What to change
153
+ ```
154
+
155
+ ## Approval Criteria
156
+
157
+ - **Approve**: No CRITICAL or HIGH issues
158
+ - **Warning**: MEDIUM issues only (can merge with caution)
159
+ - **Block**: CRITICAL or HIGH issues found
160
+
161
+ ## Framework Checks
162
+
163
+ - **Django**: `select_related`/`prefetch_related` for N+1, `atomic()` for multi-step, migrations
164
+ - **FastAPI**: CORS config, Pydantic validation, response models, no blocking in async
165
+ - **Flask**: Proper error handlers, CSRF protection
166
+
167
+ ## Reference
168
+
169
+ For detailed Python patterns, security examples, and code samples, see skill: `python-patterns`.
170
+
171
+ ---
172
+
173
+ Review with the mindset: "Would this code pass review at a top Python shop or open-source project?"
174
+
175
+ When acting as the fastapi-reviewer role: You are a senior FastAPI reviewer focused on production Python APIs.
176
+
177
+ ## Review Scope
178
+
179
+ - FastAPI app construction, routing, middleware, and exception handling.
180
+ - Pydantic request, update, and response models.
181
+ - Async database and HTTP patterns.
182
+ - Dependency injection for database sessions, auth, pagination, and settings.
183
+ - Authentication, authorization, CORS, rate limits, logging, and secret handling.
184
+ - Test dependency overrides and client setup.
185
+ - OpenAPI metadata and generated docs.
186
+
187
+ ## Out of Scope
188
+
189
+ - Non-FastAPI frameworks unless they directly interact with the FastAPI app.
190
+ - Broad Python style review already covered by `python-reviewer`.
191
+ - Dependency additions without a concrete problem and maintenance rationale.
192
+
193
+ ## Review Workflow
194
+
195
+ 1. Locate the app entry point, usually `main.py`, `app.py`, or `app/main.py`.
196
+ 2. Identify routers, schemas, dependencies, database session setup, and tests.
197
+ 3. Run available local checks when safe, such as `pytest`, `ruff`, `mypy`, or `uv run pytest`.
198
+ 4. Review the changed files first, then inspect adjacent definitions needed to prove findings.
199
+ 5. Report only actionable issues with file and line references when available.
200
+
201
+ ## Finding Priorities
202
+
203
+ ### Critical
204
+
205
+ - Hardcoded secrets or tokens.
206
+ - SQL built through string interpolation.
207
+ - Passwords, token hashes, or internal auth fields exposed in response models.
208
+ - Auth dependencies that can be bypassed or do not validate expiry/signature.
209
+
210
+ ### High
211
+
212
+ - Blocking database or HTTP clients inside async routes.
213
+ - Database sessions created inline in handlers instead of dependencies.
214
+ - Test overrides targeting the wrong dependency.
215
+ - `allow_origins=["*"]` combined with credentialed CORS.
216
+ - Missing request validation for write endpoints.
217
+
218
+ ### Medium
219
+
220
+ - Missing pagination on list endpoints.
221
+ - OpenAPI docs missing response models or error response descriptions.
222
+ - Duplicated route logic that should move into a service/dependency.
223
+ - Missing timeout settings for external HTTP clients.
224
+
225
+ ## Output Format
226
+
227
+ ```text
228
+ [SEVERITY] Short issue title
229
+ File: path/to/file.py:42
230
+ Issue: What is wrong and why it matters.
231
+ Fix: Concrete change to make.
232
+ ```
233
+
234
+ End with:
235
+
236
+ - `Tests checked:` commands run or why they were skipped.
237
+ - `Residual risk:` anything important that could not be verified.
238
+
239
+ When acting as the django-reviewer role: You are a senior Django code reviewer ensuring production-grade quality, security, and performance.
240
+
241
+ **Note**: This agent focuses on Django-specific concerns. Ensure `python-reviewer` has been invoked for general Python quality checks before or after this review.
242
+
243
+ When invoked:
244
+ 1. Run `git diff -- '*.py'` to see recent Python file changes
245
+ 2. Run `python manage.py check` if a Django project is present
246
+ 3. Run `ruff check .` and `mypy .` if available
247
+ 4. Focus on modified `.py` files and any related migrations
248
+ 5. Assume CI checks have passed (orchestration gated); if CI status needs verification, run `gh pr checks` to confirm green before proceeding
249
+
250
+ ## Review Priorities
251
+
252
+ ### CRITICAL — Security
253
+
254
+ - **SQL Injection**: Raw SQL with f-strings or `%` formatting — use `%s` parameters or ORM
255
+ - **`mark_safe` on user input**: Never without explicit `escape()` first
256
+ - **CSRF exemption without reason**: `@csrf_exempt` on non-webhook views
257
+ - **`DEBUG = True` in production settings**: Leaks full stack traces
258
+ - **Hardcoded `SECRET_KEY`**: Must come from environment variable
259
+ - **Missing `permission_classes` on DRF views**: Defaults to global — verify intent
260
+ - **`eval()`/`exec()` on user input**: Immediate block
261
+ - **File upload without extension/size validation**: Path traversal risk
262
+
263
+ ### CRITICAL — ORM Correctness
264
+
265
+ - **N+1 queries in loops**: Accessing related objects without `select_related`/`prefetch_related`
266
+ ```python
267
+ # Bad
268
+ for order in Order.objects.all():
269
+ print(order.user.email) # N+1
36
270
 
37
- When acting as the python-reviewer role: You are a Python reviewer. Read the changed Python files and check them against the conventions in context: frozen dataclasses/NamedTuples for value objects, Protocols for structural typing, context managers for resources, full signature annotations, specific-exception handling with `raise ... from`, boundary input validation, and `os.environ[...]` secrets that fail fast. PEP 8, import order, and coverage are the linter/CI's job — don't re-litigate them. Cite the exact file:line for each finding, name the concrete failure mode, and give severity. If the change is sound, say so plainly rather than inventing findings.
271
+ # Good
272
+ for order in Order.objects.select_related('user').all():
273
+ print(order.user.email)
274
+ ```
275
+ - **Missing `atomic()` for multi-step writes**: Use `transaction.atomic()` for any sequence of DB writes
276
+ - **`bulk_create` without `update_conflicts`**: Silent data loss on duplicate keys
277
+ - **`get()` without `DoesNotExist` handling**: Unhandled exception risk
278
+ - **Queryset used after `delete()`**: Stale queryset reference
279
+
280
+ ### CRITICAL — Migration Safety
281
+
282
+ - **Model change without migration**: Run `python manage.py makemigrations --check`
283
+ - **Backward-incompatible column drop**: Must be done in two deployments (nullable first)
284
+ - **`RunPython` without `reverse_code`**: Migration cannot be reversed
285
+ - **`atomic = False` without justification**: Leaves DB in partial state on failure
286
+
287
+ ### HIGH — DRF Patterns
288
+
289
+ - **Serializer without explicit `fields`**: `fields = '__all__'` exposes all columns including sensitive ones
290
+ - **No pagination on list endpoints**: Unbounded queries can return millions of rows
291
+ - **Missing `read_only_fields`**: Auto-generated fields (id, created_at) editable by API
292
+ - **`perform_create` not used**: Injecting user context should happen in `perform_create`, not `validate`
293
+ - **No throttling on auth endpoints**: Login/registration open to brute force
294
+ - **Nested writable serializers without `update()`**: Default update silently ignores nested data
295
+
296
+ ### HIGH — Performance
297
+
298
+ - **Queryset evaluated in template context**: Use `.values()` or pass list; avoid lazy evaluation in templates
299
+ - **Missing `db_index` on FK/filter fields**: Full table scan on filtered queries
300
+ - **Synchronous external API call in view**: Blocks the request thread — offload to Celery
301
+ - **`len(queryset)` instead of `.count()`**: Forces full fetch
302
+ - **`exists()` not used for existence checks**: `if queryset:` fetches objects unnecessarily
303
+
304
+ ```python
305
+ # Bad
306
+ if Product.objects.filter(sku=sku):
307
+ ...
308
+
309
+ # Good
310
+ if Product.objects.filter(sku=sku).exists():
311
+ ...
312
+ ```
313
+
314
+ ### HIGH — Code Quality
315
+
316
+ - **Business logic in views or serializers**: Move to `services.py`
317
+ - **Signal logic that belongs in a service**: Signals make flow hard to trace — use explicitly
318
+ - **Mutable default in model field**: `default=[]` or `default={}` — use `default=list`
319
+ - **`save()` called without `update_fields`**: Overwrites all columns — risk of clobbering concurrent writes
320
+
321
+ ```python
322
+ # Bad
323
+ user.last_active = now()
324
+ user.save()
325
+
326
+ # Good
327
+ user.last_active = now()
328
+ user.save(update_fields=['last_active'])
329
+ ```
330
+
331
+ ### MEDIUM — Best Practices
332
+
333
+ - **`str(queryset)` or slicing for debug**: Use Django shell, not production code
334
+ - **Accessing `request.user` in serializer `validate()`**: Pass via context, not direct access
335
+ - **`print()` instead of `logger`**: Use `logging.getLogger(__name__)`
336
+ - **Missing `related_name`**: Reverse accessors like `user_set` are confusing
337
+ - **`blank=True` without `null=True` on non-string fields**: DB stores empty string for non-string types
338
+ - **Hardcoded URLs**: Use `reverse()` or `reverse_lazy()`
339
+ - **Missing `__str__` on models**: Django admin and logging are broken without it
340
+ - **App not using `AppConfig.ready()`**: Signal receivers not connected properly
341
+
342
+ ### MEDIUM — Testing Gaps
343
+
344
+ - **No test for permission boundary**: Verify unauthorized access returns 403/401
345
+ - **`force_authenticate` instead of proper token**: Tests skip auth logic entirely
346
+ - **Missing `@pytest.mark.django_db`**: Tests silently hit no DB
347
+ - **Factory not used**: Raw `Model.objects.create()` in tests is fragile
348
+
349
+ ## Diagnostic Commands
350
+
351
+ ```bash
352
+ python manage.py check # Django system check
353
+ python manage.py makemigrations --check # Detect missing migrations
354
+ ruff check . # Fast linter
355
+ mypy . --ignore-missing-imports # Type checking
356
+ bandit -r . -ll # Security scan (medium+)
357
+ pytest --cov=apps --cov-report=term-missing -q # Tests + coverage
358
+ ```
359
+
360
+ ## Review Output Format
361
+
362
+ ```text
363
+ [SEVERITY] Issue title
364
+ File: apps/orders/views.py:42
365
+ Issue: Description of the problem
366
+ Fix: What to change and why
367
+ ```
368
+
369
+ ## Approval Criteria
370
+
371
+ - **Approve**: No CRITICAL or HIGH issues
372
+ - **Warning**: MEDIUM issues only (can merge with caution)
373
+ - **Block**: CRITICAL or HIGH issues found
374
+
375
+ ## Framework-Specific Checks
376
+
377
+ - **Migrations**: Every model change must have a migration. Two-phase for column removal.
378
+ - **DRF**: All public endpoints need explicit `permission_classes`. Pagination on all list views.
379
+ - **Celery**: Tasks must be idempotent. Use `bind=True` + `self.retry()` for transient failures.
380
+ - **Django Admin**: Never expose sensitive fields. Use `readonly_fields` for auto-generated data.
381
+ - **Signals**: Prefer explicit service calls. If signals are used, register in `AppConfig.ready()`.
382
+
383
+ ## Reference
384
+
385
+ For Django architecture patterns and ORM examples, see security configuration checklists, see `skill: django-security`.
386
+ For testing patterns and fixtures, see skill: `django-tdd`.
387
+
388
+ ---
389
+
390
+ Review with the mindset: "Would this code safely serve 10,000 concurrent users without data loss, security breach, or a 3am pager alert?"
38
391
 
39
392
  Workflow steps:
40
393
 
41
- - python-review: Invoke the python-reviewer subagent on the changed Python files (the diff since $ARGUMENTS, or the working tree if no ref is given). It checks this pack's conventions — frozen dataclasses for value objects, Protocols for structural typing, `raise ... from`, and os.environ[] over silent .get() fallbacks — and reports file:line + severity. Style and coverage are CI's job; don't re-litigate them. Fix any Critical or Important findings before merging.
394
+ - python-review: Invoke the python-reviewer subagent on the changed Python files (the diff since $ARGUMENTS, or the working tree if no ref is given). It checks this pack's conventions — frozen dataclasses for value objects, Protocols for structural typing, `raise ... from`, and os.environ[] over silent .get() fallbacks, plus security, type hints, and code quality — and reports file:line + severity. Style and coverage are CI's job; don't re-litigate them. Fix any CRITICAL or HIGH findings before merging.
395
+ - fastapi-review: Invoke the fastapi-reviewer subagent on the changed FastAPI files (the diff since $ARGUMENTS, or the working tree if no ref is given). It checks app construction, routing, Pydantic request/response schemas, async dependency injection, auth/CORS/rate limits, and test dependency overrides, reporting file:line + severity plus `Tests checked:` and `Residual risk:`. Fix any Critical or High findings before merging.
396
+ - django-review: Invoke the django-reviewer subagent on the changed Django files (the diff since $ARGUMENTS, or the working tree if no ref is given). It checks ORM correctness (N+1, atomic(), bulk_create), DRF serializer/permission/pagination patterns, migration safety, and security misconfigurations (mark_safe, CSRF, DEBUG, SECRET_KEY), reporting file:line + severity. Run `python-review` too for general Python quality. Fix any CRITICAL or HIGH findings before merging.
397
+
398
+ - After an edit, lint and typecheck with ruff when it's installed and the repo looks like a Python project.
399
+
400
+ ## Skills
401
+
402
+ ### python-patterns
403
+
404
+ Pythonic idioms, type hints, error handling, context managers, comprehensions/generators, dataclasses, decorators, concurrency, package layout, and anti-patterns. Use when writing, reviewing, or refactoring Python code, or designing a package/module layout.
405
+
406
+ # Python Development Patterns
407
+
408
+ Idiomatic Python patterns and best practices for building robust, efficient, and maintainable applications.
409
+
410
+ ## When to Activate
411
+
412
+ - Writing new Python code
413
+ - Reviewing Python code
414
+ - Refactoring existing Python code
415
+ - Designing Python packages/modules
416
+
417
+ ## Core Principles
418
+
419
+ ### 1. Readability Counts
420
+
421
+ Python prioritizes readability. Code should be obvious and easy to understand.
422
+
423
+ ```python
424
+ # Good: Clear and readable
425
+ def get_active_users(users: list[User]) -> list[User]:
426
+ """Return only active users from the provided list."""
427
+ return [user for user in users if user.is_active]
428
+
429
+
430
+ # Bad: Clever but confusing
431
+ def get_active_users(u):
432
+ return [x for x in u if x.a]
433
+ ```
434
+
435
+ ### 2. Explicit is Better Than Implicit
436
+
437
+ Avoid magic; be clear about what your code does.
438
+
439
+ ```python
440
+ # Good: Explicit configuration
441
+ import logging
442
+
443
+ logging.basicConfig(
444
+ level=logging.INFO,
445
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
446
+ )
447
+
448
+ # Bad: Hidden side effects
449
+ import some_module
450
+ some_module.setup() # What does this do?
451
+ ```
452
+
453
+ ### 3. EAFP - Easier to Ask Forgiveness Than Permission
454
+
455
+ Python prefers exception handling over checking conditions.
456
+
457
+ ```python
458
+ # Good: EAFP style
459
+ def get_value(dictionary: dict, key: str, default_value: Any = None) -> Any:
460
+ try:
461
+ return dictionary[key]
462
+ except KeyError:
463
+ return default_value
464
+
465
+ # Bad: LBYL (Look Before You Leap) style
466
+ def get_value(dictionary: dict, key: str, default_value: Any = None) -> Any:
467
+ if key in dictionary:
468
+ return dictionary[key]
469
+ else:
470
+ return default_value
471
+ ```
472
+
473
+ ## Type Hints
474
+
475
+ ### Basic Type Annotations
476
+
477
+ ```python
478
+ from typing import Optional, List, Dict, Any
479
+
480
+ def process_user(
481
+ user_id: str,
482
+ data: Dict[str, Any],
483
+ active: bool = True
484
+ ) -> Optional[User]:
485
+ """Process a user and return the updated User or None."""
486
+ if not active:
487
+ return None
488
+ return User(user_id, data)
489
+ ```
490
+
491
+ ### Modern Type Hints (Python 3.9+)
492
+
493
+ ```python
494
+ # Python 3.9+ - Use built-in types
495
+ def process_items(items: list[str]) -> dict[str, int]:
496
+ return {item: len(item) for item in items}
497
+
498
+ # Python 3.8 and earlier - Use typing module
499
+ from typing import List, Dict
500
+
501
+ def process_items(items: List[str]) -> Dict[str, int]:
502
+ return {item: len(item) for item in items}
503
+ ```
504
+
505
+ ### Type Aliases and TypeVar
506
+
507
+ ```python
508
+ from typing import TypeVar, Union
509
+
510
+ # Type alias for complex types
511
+ JSON = Union[dict[str, Any], list[Any], str, int, float, bool, None]
512
+
513
+ def parse_json(data: str) -> JSON:
514
+ return json.loads(data)
515
+
516
+ # Generic types
517
+ T = TypeVar('T')
518
+
519
+ def first(items: list[T]) -> T | None:
520
+ """Return the first item or None if list is empty."""
521
+ return items[0] if items else None
522
+ ```
523
+
524
+ ### Protocol-Based Duck Typing
525
+
526
+ ```python
527
+ from typing import Protocol
528
+
529
+ class Renderable(Protocol):
530
+ def render(self) -> str:
531
+ """Render the object to a string."""
532
+
533
+ def render_all(items: list[Renderable]) -> str:
534
+ """Render all items that implement the Renderable protocol."""
535
+ return "\n".join(item.render() for item in items)
536
+ ```
537
+
538
+ ## Error Handling Patterns
539
+
540
+ ### Specific Exception Handling
541
+
542
+ ```python
543
+ # Good: Catch specific exceptions
544
+ def load_config(path: str) -> Config:
545
+ try:
546
+ with open(path) as f:
547
+ return Config.from_json(f.read())
548
+ except FileNotFoundError as e:
549
+ raise ConfigError(f"Config file not found: {path}") from e
550
+ except json.JSONDecodeError as e:
551
+ raise ConfigError(f"Invalid JSON in config: {path}") from e
552
+
553
+ # Bad: Bare except
554
+ def load_config(path: str) -> Config:
555
+ try:
556
+ with open(path) as f:
557
+ return Config.from_json(f.read())
558
+ except:
559
+ return None # Silent failure!
560
+ ```
561
+
562
+ ### Exception Chaining
563
+
564
+ ```python
565
+ def process_data(data: str) -> Result:
566
+ try:
567
+ parsed = json.loads(data)
568
+ except json.JSONDecodeError as e:
569
+ # Chain exceptions to preserve the traceback
570
+ raise ValueError(f"Failed to parse data: {data}") from e
571
+ ```
572
+
573
+ ### Custom Exception Hierarchy
574
+
575
+ ```python
576
+ class AppError(Exception):
577
+ """Base exception for all application errors."""
578
+ pass
579
+
580
+ class ValidationError(AppError):
581
+ """Raised when input validation fails."""
582
+ pass
583
+
584
+ class NotFoundError(AppError):
585
+ """Raised when a requested resource is not found."""
586
+ pass
587
+
588
+ # Usage
589
+ def get_user(user_id: str) -> User:
590
+ user = db.find_user(user_id)
591
+ if not user:
592
+ raise NotFoundError(f"User not found: {user_id}")
593
+ return user
594
+ ```
595
+
596
+ ## Context Managers
597
+
598
+ ### Resource Management
599
+
600
+ ```python
601
+ # Good: Using context managers
602
+ def process_file(path: str) -> str:
603
+ with open(path, 'r') as f:
604
+ return f.read()
605
+
606
+ # Bad: Manual resource management
607
+ def process_file(path: str) -> str:
608
+ f = open(path, 'r')
609
+ try:
610
+ return f.read()
611
+ finally:
612
+ f.close()
613
+ ```
614
+
615
+ ### Custom Context Managers
616
+
617
+ ```python
618
+ from contextlib import contextmanager
619
+
620
+ @contextmanager
621
+ def timer(name: str):
622
+ """Context manager to time a block of code."""
623
+ start = time.perf_counter()
624
+ yield
625
+ elapsed = time.perf_counter() - start
626
+ print(f"{name} took {elapsed:.4f} seconds")
627
+
628
+ # Usage
629
+ with timer("data processing"):
630
+ process_large_dataset()
631
+ ```
632
+
633
+ ### Context Manager Classes
634
+
635
+ ```python
636
+ class DatabaseTransaction:
637
+ def __init__(self, connection):
638
+ self.connection = connection
639
+
640
+ def __enter__(self):
641
+ self.connection.begin_transaction()
642
+ return self
643
+
644
+ def __exit__(self, exc_type, exc_val, exc_tb):
645
+ if exc_type is None:
646
+ self.connection.commit()
647
+ else:
648
+ self.connection.rollback()
649
+ return False # Don't suppress exceptions
650
+
651
+ # Usage
652
+ with DatabaseTransaction(conn):
653
+ user = conn.create_user(user_data)
654
+ conn.create_profile(user.id, profile_data)
655
+ ```
656
+
657
+ ## Comprehensions and Generators
658
+
659
+ ### List Comprehensions
660
+
661
+ ```python
662
+ # Good: List comprehension for simple transformations
663
+ names = [user.name for user in users if user.is_active]
664
+
665
+ # Bad: Manual loop
666
+ names = []
667
+ for user in users:
668
+ if user.is_active:
669
+ names.append(user.name)
670
+
671
+ # Complex comprehensions should be expanded
672
+ # Bad: Too complex
673
+ result = [x * 2 for x in items if x > 0 if x % 2 == 0]
674
+
675
+ # Good: Use a generator function
676
+ def filter_and_transform(items: Iterable[int]) -> list[int]:
677
+ result = []
678
+ for x in items:
679
+ if x > 0 and x % 2 == 0:
680
+ result.append(x * 2)
681
+ return result
682
+ ```
683
+
684
+ ### Generator Expressions
685
+
686
+ ```python
687
+ # Good: Generator for lazy evaluation
688
+ total = sum(x * x for x in range(1_000_000))
689
+
690
+ # Bad: Creates large intermediate list
691
+ total = sum([x * x for x in range(1_000_000)])
692
+ ```
693
+
694
+ ### Generator Functions
695
+
696
+ ```python
697
+ def read_large_file(path: str) -> Iterator[str]:
698
+ """Read a large file line by line."""
699
+ with open(path) as f:
700
+ for line in f:
701
+ yield line.strip()
702
+
703
+ # Usage
704
+ for line in read_large_file("huge.txt"):
705
+ process(line)
706
+ ```
707
+
708
+ ## Data Classes and Named Tuples
709
+
710
+ ### Data Classes
711
+
712
+ ```python
713
+ from dataclasses import dataclass, field
714
+ from datetime import datetime
715
+
716
+ @dataclass
717
+ class User:
718
+ """User entity with automatic __init__, __repr__, and __eq__."""
719
+ id: str
720
+ name: str
721
+ email: str
722
+ created_at: datetime = field(default_factory=datetime.now)
723
+ is_active: bool = True
724
+
725
+ # Usage
726
+ user = User(
727
+ id="123",
728
+ name="Alice",
729
+ email="alice@example.com"
730
+ )
731
+ ```
732
+
733
+ ### Data Classes with Validation
734
+
735
+ ```python
736
+ @dataclass
737
+ class User:
738
+ email: str
739
+ age: int
740
+
741
+ def __post_init__(self):
742
+ # Validate email format
743
+ if "@" not in self.email:
744
+ raise ValueError(f"Invalid email: {self.email}")
745
+ # Validate age range
746
+ if self.age < 0 or self.age > 150:
747
+ raise ValueError(f"Invalid age: {self.age}")
748
+ ```
749
+
750
+ ### Named Tuples
751
+
752
+ ```python
753
+ from typing import NamedTuple
754
+
755
+ class Point(NamedTuple):
756
+ """Immutable 2D point."""
757
+ x: float
758
+ y: float
759
+
760
+ def distance(self, other: 'Point') -> float:
761
+ return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5
762
+
763
+ # Usage
764
+ p1 = Point(0, 0)
765
+ p2 = Point(3, 4)
766
+ print(p1.distance(p2)) # 5.0
767
+ ```
768
+
769
+ ## Decorators
770
+
771
+ ### Function Decorators
772
+
773
+ ```python
774
+ import functools
775
+ import time
776
+
777
+ def timer(func: Callable) -> Callable:
778
+ """Decorator to time function execution."""
779
+ @functools.wraps(func)
780
+ def wrapper(*args, **kwargs):
781
+ start = time.perf_counter()
782
+ result = func(*args, **kwargs)
783
+ elapsed = time.perf_counter() - start
784
+ print(f"{func.__name__} took {elapsed:.4f}s")
785
+ return result
786
+ return wrapper
787
+
788
+ @timer
789
+ def slow_function():
790
+ time.sleep(1)
791
+
792
+ # slow_function() prints: slow_function took 1.0012s
793
+ ```
794
+
795
+ ### Parameterized Decorators
796
+
797
+ ```python
798
+ def repeat(times: int):
799
+ """Decorator to repeat a function multiple times."""
800
+ def decorator(func: Callable) -> Callable:
801
+ @functools.wraps(func)
802
+ def wrapper(*args, **kwargs):
803
+ results = []
804
+ for _ in range(times):
805
+ results.append(func(*args, **kwargs))
806
+ return results
807
+ return wrapper
808
+ return decorator
809
+
810
+ @repeat(times=3)
811
+ def greet(name: str) -> str:
812
+ return f"Hello, {name}!"
813
+
814
+ # greet("Alice") returns ["Hello, Alice!", "Hello, Alice!", "Hello, Alice!"]
815
+ ```
816
+
817
+ ### Class-Based Decorators
818
+
819
+ ```python
820
+ class CountCalls:
821
+ """Decorator that counts how many times a function is called."""
822
+ def __init__(self, func: Callable):
823
+ functools.update_wrapper(self, func)
824
+ self.func = func
825
+ self.count = 0
826
+
827
+ def __call__(self, *args, **kwargs):
828
+ self.count += 1
829
+ print(f"{self.func.__name__} has been called {self.count} times")
830
+ return self.func(*args, **kwargs)
831
+
832
+ @CountCalls
833
+ def process():
834
+ pass
835
+
836
+ # Each call to process() prints the call count
837
+ ```
838
+
839
+ ## Concurrency Patterns
840
+
841
+ ### Threading for I/O-Bound Tasks
842
+
843
+ ```python
844
+ import concurrent.futures
845
+ import threading
846
+
847
+ def fetch_url(url: str) -> str:
848
+ """Fetch a URL (I/O-bound operation)."""
849
+ import urllib.request
850
+ with urllib.request.urlopen(url) as response:
851
+ return response.read().decode()
852
+
853
+ def fetch_all_urls(urls: list[str]) -> dict[str, str]:
854
+ """Fetch multiple URLs concurrently using threads."""
855
+ with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
856
+ future_to_url = {executor.submit(fetch_url, url): url for url in urls}
857
+ results = {}
858
+ for future in concurrent.futures.as_completed(future_to_url):
859
+ url = future_to_url[future]
860
+ try:
861
+ results[url] = future.result()
862
+ except Exception as e:
863
+ results[url] = f"Error: {e}"
864
+ return results
865
+ ```
866
+
867
+ ### Multiprocessing for CPU-Bound Tasks
868
+
869
+ ```python
870
+ def process_data(data: list[int]) -> int:
871
+ """CPU-intensive computation."""
872
+ return sum(x ** 2 for x in data)
873
+
874
+ def process_all(datasets: list[list[int]]) -> list[int]:
875
+ """Process multiple datasets using multiple processes."""
876
+ with concurrent.futures.ProcessPoolExecutor() as executor:
877
+ results = list(executor.map(process_data, datasets))
878
+ return results
879
+ ```
880
+
881
+ ### Async/Await for Concurrent I/O
882
+
883
+ ```python
884
+ import asyncio
885
+
886
+ async def fetch_async(url: str) -> str:
887
+ """Fetch a URL asynchronously."""
888
+ import aiohttp
889
+ async with aiohttp.ClientSession() as session:
890
+ async with session.get(url) as response:
891
+ return await response.text()
892
+
893
+ async def fetch_all(urls: list[str]) -> dict[str, str]:
894
+ """Fetch multiple URLs concurrently."""
895
+ tasks = [fetch_async(url) for url in urls]
896
+ results = await asyncio.gather(*tasks, return_exceptions=True)
897
+ return dict(zip(urls, results))
898
+ ```
899
+
900
+ ## Package Organization
901
+
902
+ ### Standard Project Layout
903
+
904
+ ```
905
+ myproject/
906
+ ├── src/
907
+ │ └── mypackage/
908
+ │ ├── __init__.py
909
+ │ ├── main.py
910
+ │ ├── api/
911
+ │ │ ├── __init__.py
912
+ │ │ └── routes.py
913
+ │ ├── models/
914
+ │ │ ├── __init__.py
915
+ │ │ └── user.py
916
+ │ └── utils/
917
+ │ ├── __init__.py
918
+ │ └── helpers.py
919
+ ├── tests/
920
+ │ ├── __init__.py
921
+ │ ├── conftest.py
922
+ │ ├── test_api.py
923
+ │ └── test_models.py
924
+ ├── pyproject.toml
925
+ ├── README.md
926
+ └── .gitignore
927
+ ```
928
+
929
+ ### Import Conventions
930
+
931
+ ```python
932
+ # Good: Import order - stdlib, third-party, local
933
+ import os
934
+ import sys
935
+ from pathlib import Path
936
+
937
+ import requests
938
+ from fastapi import FastAPI
939
+
940
+ from mypackage.models import User
941
+ from mypackage.utils import format_name
942
+
943
+ # Good: Use isort for automatic import sorting
944
+ # pip install isort
945
+ ```
946
+
947
+ ### __init__.py for Package Exports
948
+
949
+ ```python
950
+ # mypackage/__init__.py
951
+ """mypackage - A sample Python package."""
952
+
953
+ __version__ = "1.0.0"
954
+
955
+ # Export main classes/functions at package level
956
+ from mypackage.models import User, Post
957
+ from mypackage.utils import format_name
958
+
959
+ __all__ = ["User", "Post", "format_name"]
960
+ ```
961
+
962
+ ## Memory and Performance
963
+
964
+ ### Using __slots__ for Memory Efficiency
965
+
966
+ ```python
967
+ # Bad: Regular class uses __dict__ (more memory)
968
+ class Point:
969
+ def __init__(self, x: float, y: float):
970
+ self.x = x
971
+ self.y = y
972
+
973
+ # Good: __slots__ reduces memory usage
974
+ class Point:
975
+ __slots__ = ['x', 'y']
976
+
977
+ def __init__(self, x: float, y: float):
978
+ self.x = x
979
+ self.y = y
980
+ ```
981
+
982
+ ### Generator for Large Data
983
+
984
+ ```python
985
+ # Bad: Returns full list in memory
986
+ def read_lines(path: str) -> list[str]:
987
+ with open(path) as f:
988
+ return [line.strip() for line in f]
989
+
990
+ # Good: Yields lines one at a time
991
+ def read_lines(path: str) -> Iterator[str]:
992
+ with open(path) as f:
993
+ for line in f:
994
+ yield line.strip()
995
+ ```
996
+
997
+ ### Avoid String Concatenation in Loops
998
+
999
+ ```python
1000
+ # Bad: O(n²) due to string immutability
1001
+ result = ""
1002
+ for item in items:
1003
+ result += str(item)
1004
+
1005
+ # Good: O(n) using join
1006
+ result = "".join(str(item) for item in items)
1007
+
1008
+ # Good: Using StringIO for building
1009
+ from io import StringIO
1010
+
1011
+ buffer = StringIO()
1012
+ for item in items:
1013
+ buffer.write(str(item))
1014
+ result = buffer.getvalue()
1015
+ ```
1016
+
1017
+ ## Python Tooling Integration
1018
+
1019
+ ### Essential Commands
1020
+
1021
+ ```bash
1022
+ # Code formatting
1023
+ black .
1024
+ isort .
1025
+
1026
+ # Linting
1027
+ ruff check .
1028
+ pylint mypackage/
1029
+
1030
+ # Type checking
1031
+ mypy .
1032
+
1033
+ # Testing
1034
+ pytest --cov=mypackage --cov-report=html
1035
+
1036
+ # Security scanning
1037
+ bandit -r .
1038
+
1039
+ # Dependency management
1040
+ pip-audit
1041
+ safety check
1042
+ ```
1043
+
1044
+ ### pyproject.toml Configuration
1045
+
1046
+ ```toml
1047
+ [project]
1048
+ name = "mypackage"
1049
+ version = "1.0.0"
1050
+ requires-python = ">=3.9"
1051
+ dependencies = [
1052
+ "requests>=2.31.0",
1053
+ "pydantic>=2.0.0",
1054
+ ]
1055
+
1056
+ [project.optional-dependencies]
1057
+ dev = [
1058
+ "pytest>=7.4.0",
1059
+ "pytest-cov>=4.1.0",
1060
+ "black>=23.0.0",
1061
+ "ruff>=0.1.0",
1062
+ "mypy>=1.5.0",
1063
+ ]
1064
+
1065
+ [tool.black]
1066
+ line-length = 88
1067
+ target-version = ['py39']
1068
+
1069
+ [tool.ruff]
1070
+ line-length = 88
1071
+ select = ["E", "F", "I", "N", "W"]
1072
+
1073
+ [tool.mypy]
1074
+ python_version = "3.9"
1075
+ warn_return_any = true
1076
+ warn_unused_configs = true
1077
+ disallow_untyped_defs = true
1078
+
1079
+ [tool.pytest.ini_options]
1080
+ testpaths = ["tests"]
1081
+ addopts = "--cov=mypackage --cov-report=term-missing"
1082
+ ```
1083
+
1084
+ ## Quick Reference: Python Idioms
1085
+
1086
+ | Idiom | Description |
1087
+ |-------|-------------|
1088
+ | EAFP | Easier to Ask Forgiveness than Permission |
1089
+ | Context managers | Use `with` for resource management |
1090
+ | List comprehensions | For simple transformations |
1091
+ | Generators | For lazy evaluation and large datasets |
1092
+ | Type hints | Annotate function signatures |
1093
+ | Dataclasses | For data containers with auto-generated methods |
1094
+ | `__slots__` | For memory optimization |
1095
+ | f-strings | For string formatting (Python 3.6+) |
1096
+ | `pathlib.Path` | For path operations (Python 3.4+) |
1097
+ | `enumerate` | For index-element pairs in loops |
1098
+
1099
+ ## Anti-Patterns to Avoid
1100
+
1101
+ ```python
1102
+ # Bad: Mutable default arguments
1103
+ def append_to(item, items=[]):
1104
+ items.append(item)
1105
+ return items
1106
+
1107
+ # Good: Use None and create new list
1108
+ def append_to(item, items=None):
1109
+ if items is None:
1110
+ items = []
1111
+ items.append(item)
1112
+ return items
1113
+
1114
+ # Bad: Checking type with type()
1115
+ if type(obj) == list:
1116
+ process(obj)
1117
+
1118
+ # Good: Use isinstance
1119
+ if isinstance(obj, list):
1120
+ process(obj)
1121
+
1122
+ # Bad: Comparing to None with ==
1123
+ if value == None:
1124
+ process()
1125
+
1126
+ # Good: Use is
1127
+ if value is None:
1128
+ process()
1129
+
1130
+ # Bad: from module import *
1131
+ from os.path import *
1132
+
1133
+ # Good: Explicit imports
1134
+ from os.path import join, exists
1135
+
1136
+ # Bad: Bare except
1137
+ try:
1138
+ risky_operation()
1139
+ except:
1140
+ pass
1141
+
1142
+ # Good: Specific exception
1143
+ try:
1144
+ risky_operation()
1145
+ except SpecificError as e:
1146
+ logger.error(f"Operation failed: {e}")
1147
+ ```
1148
+
1149
+ __Remember__: Python code should be readable, explicit, and follow the principle of least surprise. When in doubt, prioritize clarity over cleverness.
1150
+
1151
+ ### python-testing
1152
+
1153
+ pytest fundamentals, fixtures (scopes, params, autouse, conftest), parametrization, mocking/patching, async test patterns, and TDD methodology with coverage targets. Use when writing or reviewing Python tests, or setting up a pytest suite.
1154
+
1155
+ # Python Testing Patterns
1156
+
1157
+ Comprehensive testing strategies for Python applications using pytest, TDD methodology, and best practices.
1158
+
1159
+ ## When to Activate
1160
+
1161
+ - Writing new Python code (follow TDD: red, green, refactor)
1162
+ - Designing test suites for Python projects
1163
+ - Reviewing Python test coverage
1164
+ - Setting up testing infrastructure
1165
+
1166
+ ## Core Testing Philosophy
1167
+
1168
+ ### Test-Driven Development (TDD)
1169
+
1170
+ Always follow the TDD cycle:
1171
+
1172
+ 1. **RED**: Write a failing test for the desired behavior
1173
+ 2. **GREEN**: Write minimal code to make the test pass
1174
+ 3. **REFACTOR**: Improve code while keeping tests green
1175
+
1176
+ ```python
1177
+ # Step 1: Write failing test (RED)
1178
+ def test_add_numbers():
1179
+ result = add(2, 3)
1180
+ assert result == 5
1181
+
1182
+ # Step 2: Write minimal implementation (GREEN)
1183
+ def add(a, b):
1184
+ return a + b
1185
+
1186
+ # Step 3: Refactor if needed (REFACTOR)
1187
+ ```
1188
+
1189
+ ### Coverage Requirements
1190
+
1191
+ - **Target**: 80%+ code coverage
1192
+ - **Critical paths**: 100% coverage required
1193
+ - Use `pytest --cov` to measure coverage
1194
+
1195
+ ```bash
1196
+ pytest --cov=mypackage --cov-report=term-missing --cov-report=html
1197
+ ```
1198
+
1199
+ ## pytest Fundamentals
1200
+
1201
+ ### Basic Test Structure
1202
+
1203
+ ```python
1204
+ import pytest
1205
+
1206
+ def test_addition():
1207
+ """Test basic addition."""
1208
+ assert 2 + 2 == 4
1209
+
1210
+ def test_string_uppercase():
1211
+ """Test string uppercasing."""
1212
+ text = "hello"
1213
+ assert text.upper() == "HELLO"
1214
+
1215
+ def test_list_append():
1216
+ """Test list append."""
1217
+ items = [1, 2, 3]
1218
+ items.append(4)
1219
+ assert 4 in items
1220
+ assert len(items) == 4
1221
+ ```
1222
+
1223
+ ### Assertions
1224
+
1225
+ ```python
1226
+ # Equality
1227
+ assert result == expected
1228
+
1229
+ # Inequality
1230
+ assert result != unexpected
1231
+
1232
+ # Truthiness
1233
+ assert result # Truthy
1234
+ assert not result # Falsy
1235
+ assert result is True # Exactly True
1236
+ assert result is False # Exactly False
1237
+ assert result is None # Exactly None
1238
+
1239
+ # Membership
1240
+ assert item in collection
1241
+ assert item not in collection
1242
+
1243
+ # Comparisons
1244
+ assert result > 0
1245
+ assert 0 <= result <= 100
1246
+
1247
+ # Type checking
1248
+ assert isinstance(result, str)
1249
+
1250
+ # Exception testing (preferred approach)
1251
+ with pytest.raises(ValueError):
1252
+ raise ValueError("error message")
1253
+
1254
+ # Check exception message
1255
+ with pytest.raises(ValueError, match="invalid input"):
1256
+ raise ValueError("invalid input provided")
1257
+
1258
+ # Check exception attributes
1259
+ with pytest.raises(ValueError) as exc_info:
1260
+ raise ValueError("error message")
1261
+ assert str(exc_info.value) == "error message"
1262
+ ```
1263
+
1264
+ ## Fixtures
1265
+
1266
+ ### Basic Fixture Usage
1267
+
1268
+ ```python
1269
+ import pytest
1270
+
1271
+ @pytest.fixture
1272
+ def sample_data():
1273
+ """Fixture providing sample data."""
1274
+ return {"name": "Alice", "age": 30}
1275
+
1276
+ def test_sample_data(sample_data):
1277
+ """Test using the fixture."""
1278
+ assert sample_data["name"] == "Alice"
1279
+ assert sample_data["age"] == 30
1280
+ ```
1281
+
1282
+ ### Fixture with Setup/Teardown
1283
+
1284
+ ```python
1285
+ @pytest.fixture
1286
+ def database():
1287
+ """Fixture with setup and teardown."""
1288
+ # Setup
1289
+ db = Database(":memory:")
1290
+ db.create_tables()
1291
+ db.insert_test_data()
1292
+
1293
+ yield db # Provide to test
1294
+
1295
+ # Teardown
1296
+ db.close()
1297
+
1298
+ def test_database_query(database):
1299
+ """Test database operations."""
1300
+ result = database.query("SELECT * FROM users")
1301
+ assert len(result) > 0
1302
+ ```
1303
+
1304
+ ### Fixture Scopes
1305
+
1306
+ ```python
1307
+ # Function scope (default) - runs for each test
1308
+ @pytest.fixture
1309
+ def temp_file():
1310
+ with open("temp.txt", "w") as f:
1311
+ yield f
1312
+ os.remove("temp.txt")
1313
+
1314
+ # Module scope - runs once per module
1315
+ @pytest.fixture(scope="module")
1316
+ def module_db():
1317
+ db = Database(":memory:")
1318
+ db.create_tables()
1319
+ yield db
1320
+ db.close()
1321
+
1322
+ # Session scope - runs once per test session
1323
+ @pytest.fixture(scope="session")
1324
+ def shared_resource():
1325
+ resource = ExpensiveResource()
1326
+ yield resource
1327
+ resource.cleanup()
1328
+ ```
1329
+
1330
+ ### Fixture with Parameters
1331
+
1332
+ ```python
1333
+ @pytest.fixture(params=[1, 2, 3])
1334
+ def number(request):
1335
+ """Parameterized fixture."""
1336
+ return request.param
1337
+
1338
+ def test_numbers(number):
1339
+ """Test runs 3 times, once for each parameter."""
1340
+ assert number > 0
1341
+ ```
1342
+
1343
+ ### Using Multiple Fixtures
1344
+
1345
+ ```python
1346
+ @pytest.fixture
1347
+ def user():
1348
+ return User(id=1, name="Alice")
1349
+
1350
+ @pytest.fixture
1351
+ def admin():
1352
+ return User(id=2, name="Admin", role="admin")
1353
+
1354
+ def test_user_admin_interaction(user, admin):
1355
+ """Test using multiple fixtures."""
1356
+ assert admin.can_manage(user)
1357
+ ```
1358
+
1359
+ ### Autouse Fixtures
1360
+
1361
+ ```python
1362
+ @pytest.fixture(autouse=True)
1363
+ def reset_config():
1364
+ """Automatically runs before every test."""
1365
+ Config.reset()
1366
+ yield
1367
+ Config.cleanup()
1368
+
1369
+ def test_without_fixture_call():
1370
+ # reset_config runs automatically
1371
+ assert Config.get_setting("debug") is False
1372
+ ```
1373
+
1374
+ ### Conftest.py for Shared Fixtures
1375
+
1376
+ ```python
1377
+ # tests/conftest.py
1378
+ import pytest
1379
+
1380
+ @pytest.fixture
1381
+ def client():
1382
+ """Shared fixture for all tests."""
1383
+ app = create_app(testing=True)
1384
+ with app.test_client() as client:
1385
+ yield client
1386
+
1387
+ @pytest.fixture
1388
+ def auth_headers(client):
1389
+ """Generate auth headers for API testing."""
1390
+ response = client.post("/api/login", json={
1391
+ "username": "test",
1392
+ "password": "test"
1393
+ })
1394
+ token = response.json["token"]
1395
+ return {"Authorization": f"Bearer {token}"}
1396
+ ```
1397
+
1398
+ ## Parametrization
1399
+
1400
+ ### Basic Parametrization
1401
+
1402
+ ```python
1403
+ @pytest.mark.parametrize("input,expected", [
1404
+ ("hello", "HELLO"),
1405
+ ("world", "WORLD"),
1406
+ ("PyThOn", "PYTHON"),
1407
+ ])
1408
+ def test_uppercase(input, expected):
1409
+ """Test runs 3 times with different inputs."""
1410
+ assert input.upper() == expected
1411
+ ```
1412
+
1413
+ ### Multiple Parameters
1414
+
1415
+ ```python
1416
+ @pytest.mark.parametrize("a,b,expected", [
1417
+ (2, 3, 5),
1418
+ (0, 0, 0),
1419
+ (-1, 1, 0),
1420
+ (100, 200, 300),
1421
+ ])
1422
+ def test_add(a, b, expected):
1423
+ """Test addition with multiple inputs."""
1424
+ assert add(a, b) == expected
1425
+ ```
1426
+
1427
+ ### Parametrize with IDs
1428
+
1429
+ ```python
1430
+ @pytest.mark.parametrize("input,expected", [
1431
+ ("valid@email.com", True),
1432
+ ("invalid", False),
1433
+ ("@no-domain.com", False),
1434
+ ], ids=["valid-email", "missing-at", "missing-domain"])
1435
+ def test_email_validation(input, expected):
1436
+ """Test email validation with readable test IDs."""
1437
+ assert is_valid_email(input) is expected
1438
+ ```
1439
+
1440
+ ### Parametrized Fixtures
1441
+
1442
+ ```python
1443
+ @pytest.fixture(params=["sqlite", "postgresql", "mysql"])
1444
+ def db(request):
1445
+ """Test against multiple database backends."""
1446
+ if request.param == "sqlite":
1447
+ return Database(":memory:")
1448
+ elif request.param == "postgresql":
1449
+ return Database("postgresql://localhost/test")
1450
+ elif request.param == "mysql":
1451
+ return Database("mysql://localhost/test")
1452
+
1453
+ def test_database_operations(db):
1454
+ """Test runs 3 times, once for each database."""
1455
+ result = db.query("SELECT 1")
1456
+ assert result is not None
1457
+ ```
1458
+
1459
+ ## Markers and Test Selection
1460
+
1461
+ ### Custom Markers
1462
+
1463
+ ```python
1464
+ # Mark slow tests
1465
+ @pytest.mark.slow
1466
+ def test_slow_operation():
1467
+ time.sleep(5)
1468
+
1469
+ # Mark integration tests
1470
+ @pytest.mark.integration
1471
+ def test_api_integration():
1472
+ response = requests.get("https://api.example.com")
1473
+ assert response.status_code == 200
1474
+
1475
+ # Mark unit tests
1476
+ @pytest.mark.unit
1477
+ def test_unit_logic():
1478
+ assert calculate(2, 3) == 5
1479
+ ```
1480
+
1481
+ ### Run Specific Tests
1482
+
1483
+ ```bash
1484
+ # Run only fast tests
1485
+ pytest -m "not slow"
1486
+
1487
+ # Run only integration tests
1488
+ pytest -m integration
1489
+
1490
+ # Run integration or slow tests
1491
+ pytest -m "integration or slow"
1492
+
1493
+ # Run tests marked as unit but not slow
1494
+ pytest -m "unit and not slow"
1495
+ ```
1496
+
1497
+ ### Configure Markers in pytest.ini
1498
+
1499
+ ```ini
1500
+ [pytest]
1501
+ markers =
1502
+ slow: marks tests as slow
1503
+ integration: marks tests as integration tests
1504
+ unit: marks tests as unit tests
1505
+ django: marks tests as requiring Django
1506
+ ```
1507
+
1508
+ ## Mocking and Patching
1509
+
1510
+ ### Mocking Functions
1511
+
1512
+ ```python
1513
+ from unittest.mock import patch, Mock
1514
+
1515
+ @patch("mypackage.external_api_call")
1516
+ def test_with_mock(api_call_mock):
1517
+ """Test with mocked external API."""
1518
+ api_call_mock.return_value = {"status": "success"}
1519
+
1520
+ result = my_function()
1521
+
1522
+ api_call_mock.assert_called_once()
1523
+ assert result["status"] == "success"
1524
+ ```
1525
+
1526
+ ### Mocking Return Values
1527
+
1528
+ ```python
1529
+ @patch("mypackage.Database.connect")
1530
+ def test_database_connection(connect_mock):
1531
+ """Test with mocked database connection."""
1532
+ connect_mock.return_value = MockConnection()
1533
+
1534
+ db = Database()
1535
+ db.connect()
1536
+
1537
+ connect_mock.assert_called_once_with("localhost")
1538
+ ```
1539
+
1540
+ ### Mocking Exceptions
1541
+
1542
+ ```python
1543
+ @patch("mypackage.api_call")
1544
+ def test_api_error_handling(api_call_mock):
1545
+ """Test error handling with mocked exception."""
1546
+ api_call_mock.side_effect = ConnectionError("Network error")
1547
+
1548
+ with pytest.raises(ConnectionError):
1549
+ api_call()
1550
+
1551
+ api_call_mock.assert_called_once()
1552
+ ```
1553
+
1554
+ ### Mocking Context Managers
1555
+
1556
+ ```python
1557
+ @patch("builtins.open", new_callable=mock_open)
1558
+ def test_file_reading(mock_file):
1559
+ """Test file reading with mocked open."""
1560
+ mock_file.return_value.read.return_value = "file content"
1561
+
1562
+ result = read_file("test.txt")
1563
+
1564
+ mock_file.assert_called_once_with("test.txt", "r")
1565
+ assert result == "file content"
1566
+ ```
1567
+
1568
+ ### Using Autospec
1569
+
1570
+ ```python
1571
+ @patch("mypackage.DBConnection", autospec=True)
1572
+ def test_autospec(db_mock):
1573
+ """Test with autospec to catch API misuse."""
1574
+ db = db_mock.return_value
1575
+ db.query("SELECT * FROM users")
1576
+
1577
+ # This would fail if DBConnection doesn't have query method
1578
+ db_mock.assert_called_once()
1579
+ ```
1580
+
1581
+ ### Mock Class Instances
1582
+
1583
+ ```python
1584
+ class TestUserService:
1585
+ @patch("mypackage.UserRepository")
1586
+ def test_create_user(self, repo_mock):
1587
+ """Test user creation with mocked repository."""
1588
+ repo_mock.return_value.save.return_value = User(id=1, name="Alice")
1589
+
1590
+ service = UserService(repo_mock.return_value)
1591
+ user = service.create_user(name="Alice")
1592
+
1593
+ assert user.name == "Alice"
1594
+ repo_mock.return_value.save.assert_called_once()
1595
+ ```
1596
+
1597
+ ### Mock Property
1598
+
1599
+ ```python
1600
+ @pytest.fixture
1601
+ def mock_config():
1602
+ """Create a mock with a property."""
1603
+ config = Mock()
1604
+ type(config).debug = PropertyMock(return_value=True)
1605
+ type(config).api_key = PropertyMock(return_value="test-key")
1606
+ return config
1607
+
1608
+ def test_with_mock_config(mock_config):
1609
+ """Test with mocked config properties."""
1610
+ assert mock_config.debug is True
1611
+ assert mock_config.api_key == "test-key"
1612
+ ```
1613
+
1614
+ ## Testing Async Code
1615
+
1616
+ ### Async Tests with pytest-asyncio
1617
+
1618
+ ```python
1619
+ import pytest
1620
+
1621
+ @pytest.mark.asyncio
1622
+ async def test_async_function():
1623
+ """Test async function."""
1624
+ result = await async_add(2, 3)
1625
+ assert result == 5
1626
+
1627
+ @pytest.mark.asyncio
1628
+ async def test_async_with_fixture(async_client):
1629
+ """Test async with async fixture."""
1630
+ response = await async_client.get("/api/users")
1631
+ assert response.status_code == 200
1632
+ ```
1633
+
1634
+ ### Async Fixture
1635
+
1636
+ ```python
1637
+ @pytest.fixture
1638
+ async def async_client():
1639
+ """Async fixture providing async test client."""
1640
+ app = create_app()
1641
+ async with app.test_client() as client:
1642
+ yield client
1643
+
1644
+ @pytest.mark.asyncio
1645
+ async def test_api_endpoint(async_client):
1646
+ """Test using async fixture."""
1647
+ response = await async_client.get("/api/data")
1648
+ assert response.status_code == 200
1649
+ ```
1650
+
1651
+ ### Mocking Async Functions
1652
+
1653
+ ```python
1654
+ @pytest.mark.asyncio
1655
+ @patch("mypackage.async_api_call")
1656
+ async def test_async_mock(api_call_mock):
1657
+ """Test async function with mock."""
1658
+ api_call_mock.return_value = {"status": "ok"}
1659
+
1660
+ result = await my_async_function()
1661
+
1662
+ api_call_mock.assert_awaited_once()
1663
+ assert result["status"] == "ok"
1664
+ ```
1665
+
1666
+ ## Testing Exceptions
1667
+
1668
+ ### Testing Expected Exceptions
1669
+
1670
+ ```python
1671
+ def test_divide_by_zero():
1672
+ """Test that dividing by zero raises ZeroDivisionError."""
1673
+ with pytest.raises(ZeroDivisionError):
1674
+ divide(10, 0)
1675
+
1676
+ def test_custom_exception():
1677
+ """Test custom exception with message."""
1678
+ with pytest.raises(ValueError, match="invalid input"):
1679
+ validate_input("invalid")
1680
+ ```
1681
+
1682
+ ### Testing Exception Attributes
1683
+
1684
+ ```python
1685
+ def test_exception_with_details():
1686
+ """Test exception with custom attributes."""
1687
+ with pytest.raises(CustomError) as exc_info:
1688
+ raise CustomError("error", code=400)
1689
+
1690
+ assert exc_info.value.code == 400
1691
+ assert "error" in str(exc_info.value)
1692
+ ```
1693
+
1694
+ ## Testing Side Effects
1695
+
1696
+ ### Testing File Operations
1697
+
1698
+ ```python
1699
+ import tempfile
1700
+ import os
1701
+
1702
+ def test_file_processing():
1703
+ """Test file processing with temp file."""
1704
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f:
1705
+ f.write("test content")
1706
+ temp_path = f.name
1707
+
1708
+ try:
1709
+ result = process_file(temp_path)
1710
+ assert result == "processed: test content"
1711
+ finally:
1712
+ os.unlink(temp_path)
1713
+ ```
1714
+
1715
+ ### Testing with pytest's tmp_path Fixture
1716
+
1717
+ ```python
1718
+ def test_with_tmp_path(tmp_path):
1719
+ """Test using pytest's built-in temp path fixture."""
1720
+ test_file = tmp_path / "test.txt"
1721
+ test_file.write_text("hello world")
1722
+
1723
+ result = process_file(str(test_file))
1724
+ assert result == "hello world"
1725
+ # tmp_path automatically cleaned up
1726
+ ```
1727
+
1728
+ ### Testing with tmpdir Fixture
1729
+
1730
+ ```python
1731
+ def test_with_tmpdir(tmpdir):
1732
+ """Test using pytest's tmpdir fixture."""
1733
+ test_file = tmpdir.join("test.txt")
1734
+ test_file.write("data")
1735
+
1736
+ result = process_file(str(test_file))
1737
+ assert result == "data"
1738
+ ```
1739
+
1740
+ ## Test Organization
1741
+
1742
+ ### Directory Structure
1743
+
1744
+ ```
1745
+ tests/
1746
+ ├── conftest.py # Shared fixtures
1747
+ ├── __init__.py
1748
+ ├── unit/ # Unit tests
1749
+ │ ├── __init__.py
1750
+ │ ├── test_models.py
1751
+ │ ├── test_utils.py
1752
+ │ └── test_services.py
1753
+ ├── integration/ # Integration tests
1754
+ │ ├── __init__.py
1755
+ │ ├── test_api.py
1756
+ │ └── test_database.py
1757
+ └── e2e/ # End-to-end tests
1758
+ ├── __init__.py
1759
+ └── test_user_flow.py
1760
+ ```
1761
+
1762
+ ### Test Classes
1763
+
1764
+ ```python
1765
+ class TestUserService:
1766
+ """Group related tests in a class."""
1767
+
1768
+ @pytest.fixture(autouse=True)
1769
+ def setup(self):
1770
+ """Setup runs before each test in this class."""
1771
+ self.service = UserService()
1772
+
1773
+ def test_create_user(self):
1774
+ """Test user creation."""
1775
+ user = self.service.create_user("Alice")
1776
+ assert user.name == "Alice"
1777
+
1778
+ def test_delete_user(self):
1779
+ """Test user deletion."""
1780
+ user = User(id=1, name="Bob")
1781
+ self.service.delete_user(user)
1782
+ assert not self.service.user_exists(1)
1783
+ ```
1784
+
1785
+ ## Best Practices
1786
+
1787
+ ### DO
1788
+
1789
+ - **Follow TDD**: Write tests before code (red-green-refactor)
1790
+ - **Test one thing**: Each test should verify a single behavior
1791
+ - **Use descriptive names**: `test_user_login_with_invalid_credentials_fails`
1792
+ - **Use fixtures**: Eliminate duplication with fixtures
1793
+ - **Mock external dependencies**: Don't depend on external services
1794
+ - **Test edge cases**: Empty inputs, None values, boundary conditions
1795
+ - **Aim for 80%+ coverage**: Focus on critical paths
1796
+ - **Keep tests fast**: Use marks to separate slow tests
1797
+
1798
+ ### DON'T
1799
+
1800
+ - **Don't test implementation**: Test behavior, not internals
1801
+ - **Don't use complex conditionals in tests**: Keep tests simple
1802
+ - **Don't ignore test failures**: All tests must pass
1803
+ - **Don't test third-party code**: Trust libraries to work
1804
+ - **Don't share state between tests**: Tests should be independent
1805
+ - **Don't catch exceptions in tests**: Use `pytest.raises`
1806
+ - **Don't use print statements**: Use assertions and pytest output
1807
+ - **Don't write tests that are too brittle**: Avoid over-specific mocks
1808
+
1809
+ ## Common Patterns
1810
+
1811
+ ### Testing API Endpoints (FastAPI/Flask)
1812
+
1813
+ ```python
1814
+ @pytest.fixture
1815
+ def client():
1816
+ app = create_app(testing=True)
1817
+ return app.test_client()
1818
+
1819
+ def test_get_user(client):
1820
+ response = client.get("/api/users/1")
1821
+ assert response.status_code == 200
1822
+ assert response.json["id"] == 1
1823
+
1824
+ def test_create_user(client):
1825
+ response = client.post("/api/users", json={
1826
+ "name": "Alice",
1827
+ "email": "alice@example.com"
1828
+ })
1829
+ assert response.status_code == 201
1830
+ assert response.json["name"] == "Alice"
1831
+ ```
1832
+
1833
+ ### Testing Database Operations
1834
+
1835
+ ```python
1836
+ @pytest.fixture
1837
+ def db_session():
1838
+ """Create a test database session."""
1839
+ session = Session(bind=engine)
1840
+ session.begin_nested()
1841
+ yield session
1842
+ session.rollback()
1843
+ session.close()
1844
+
1845
+ def test_create_user(db_session):
1846
+ user = User(name="Alice", email="alice@example.com")
1847
+ db_session.add(user)
1848
+ db_session.commit()
1849
+
1850
+ retrieved = db_session.query(User).filter_by(name="Alice").first()
1851
+ assert retrieved.email == "alice@example.com"
1852
+ ```
1853
+
1854
+ ### Testing Class Methods
1855
+
1856
+ ```python
1857
+ class TestCalculator:
1858
+ @pytest.fixture
1859
+ def calculator(self):
1860
+ return Calculator()
1861
+
1862
+ def test_add(self, calculator):
1863
+ assert calculator.add(2, 3) == 5
1864
+
1865
+ def test_divide_by_zero(self, calculator):
1866
+ with pytest.raises(ZeroDivisionError):
1867
+ calculator.divide(10, 0)
1868
+ ```
1869
+
1870
+ ## pytest Configuration
1871
+
1872
+ ### pytest.ini
1873
+
1874
+ ```ini
1875
+ [pytest]
1876
+ testpaths = tests
1877
+ python_files = test_*.py
1878
+ python_classes = Test*
1879
+ python_functions = test_*
1880
+ addopts =
1881
+ --strict-markers
1882
+ --disable-warnings
1883
+ --cov=mypackage
1884
+ --cov-report=term-missing
1885
+ --cov-report=html
1886
+ markers =
1887
+ slow: marks tests as slow
1888
+ integration: marks tests as integration tests
1889
+ unit: marks tests as unit tests
1890
+ ```
1891
+
1892
+ ### pyproject.toml
1893
+
1894
+ ```toml
1895
+ [tool.pytest.ini_options]
1896
+ testpaths = ["tests"]
1897
+ python_files = ["test_*.py"]
1898
+ python_classes = ["Test*"]
1899
+ python_functions = ["test_*"]
1900
+ addopts = [
1901
+ "--strict-markers",
1902
+ "--cov=mypackage",
1903
+ "--cov-report=term-missing",
1904
+ "--cov-report=html",
1905
+ ]
1906
+ markers = [
1907
+ "slow: marks tests as slow",
1908
+ "integration: marks tests as integration tests",
1909
+ "unit: marks tests as unit tests",
1910
+ ]
1911
+ ```
1912
+
1913
+ ## Running Tests
1914
+
1915
+ ```bash
1916
+ # Run all tests
1917
+ pytest
1918
+
1919
+ # Run specific file
1920
+ pytest tests/test_utils.py
1921
+
1922
+ # Run specific test
1923
+ pytest tests/test_utils.py::test_function
1924
+
1925
+ # Run with verbose output
1926
+ pytest -v
1927
+
1928
+ # Run with coverage
1929
+ pytest --cov=mypackage --cov-report=html
1930
+
1931
+ # Run only fast tests
1932
+ pytest -m "not slow"
1933
+
1934
+ # Run until first failure
1935
+ pytest -x
1936
+
1937
+ # Run and stop on N failures
1938
+ pytest --maxfail=3
1939
+
1940
+ # Run last failed tests
1941
+ pytest --lf
1942
+
1943
+ # Run tests with pattern
1944
+ pytest -k "test_user"
1945
+
1946
+ # Run with debugger on failure
1947
+ pytest --pdb
1948
+ ```
1949
+
1950
+ ## Quick Reference
1951
+
1952
+ | Pattern | Usage |
1953
+ |---------|-------|
1954
+ | `pytest.raises()` | Test expected exceptions |
1955
+ | `@pytest.fixture()` | Create reusable test fixtures |
1956
+ | `@pytest.mark.parametrize()` | Run tests with multiple inputs |
1957
+ | `@pytest.mark.slow` | Mark slow tests |
1958
+ | `pytest -m "not slow"` | Skip slow tests |
1959
+ | `@patch()` | Mock functions and classes |
1960
+ | `tmp_path` fixture | Automatic temp directory |
1961
+ | `pytest --cov` | Generate coverage report |
1962
+ | `assert` | Simple and readable assertions |
1963
+
1964
+ **Remember**: Tests are code too. Keep them clean, readable, and maintainable. Good tests catch bugs; great tests prevent them.
1965
+
1966
+ ### fastapi-patterns
1967
+
1968
+ FastAPI project layout, app factory/lifespan, pydantic-settings config, Pydantic v2 request/response schemas, async dependency injection, router design, a transactional service layer, and httpx+pytest testing. Use when building or reviewing a FastAPI application.
1969
+
1970
+ # FastAPI Patterns
1971
+
1972
+ Modern, production-grade FastAPI development: project layout, Pydantic v2 schemas, dependency injection, async patterns, auth, transactional service methods, and testing.
1973
+
1974
+ ## Project Structure
1975
+
1976
+ ```text
1977
+ my_app/
1978
+ |-- app/
1979
+ | |-- main.py # App factory, lifespan, middleware
1980
+ | |-- config.py # Settings via pydantic-settings
1981
+ | |-- dependencies.py # Shared FastAPI dependencies
1982
+ | |-- database.py # SQLAlchemy engine + session
1983
+ | |-- routers/
1984
+ | | `-- users.py
1985
+ | |-- models/ # SQLAlchemy ORM models
1986
+ | | `-- user.py
1987
+ | |-- schemas/ # Pydantic request/response schemas
1988
+ | | `-- user.py
1989
+ | `-- services/ # Business logic layer
1990
+ | `-- user_service.py
1991
+ |-- tests/
1992
+ | |-- conftest.py
1993
+ | `-- test_users.py
1994
+ |-- pyproject.toml
1995
+ `-- .env
1996
+ ```
1997
+
1998
+ ---
1999
+
2000
+ ## App Factory and Lifespan
2001
+
2002
+ ```python
2003
+ # app/main.py
2004
+ from contextlib import asynccontextmanager
2005
+ from fastapi import FastAPI
2006
+ from fastapi.middleware.cors import CORSMiddleware
2007
+
2008
+ from app.config import settings
2009
+ from app.database import engine, Base
2010
+ from app.routers import users
2011
+
2012
+
2013
+ @asynccontextmanager
2014
+ async def lifespan(app: FastAPI):
2015
+ # Automatically create tables on startup for ease of use in dev/demo environments.
2016
+ # For strict production applications, manage schemas via Alembic migrations instead.
2017
+ async with engine.begin() as conn:
2018
+ await conn.run_sync(Base.metadata.create_all)
2019
+ yield
2020
+ # Shutdown: close pooled resources.
2021
+ await engine.dispose()
2022
+
2023
+
2024
+ def create_app() -> FastAPI:
2025
+ app = FastAPI(
2026
+ title=settings.app_name,
2027
+ version=settings.app_version,
2028
+ lifespan=lifespan,
2029
+ )
2030
+
2031
+ app.add_middleware(
2032
+ CORSMiddleware,
2033
+ allow_origins=settings.allowed_origins,
2034
+ allow_credentials=settings.allow_credentials,
2035
+ allow_methods=settings.allowed_methods,
2036
+ allow_headers=settings.allowed_headers,
2037
+ )
2038
+
2039
+ app.include_router(users.router, prefix="/users", tags=["users"])
2040
+
2041
+ return app
2042
+
2043
+
2044
+ app = create_app()
2045
+ ```
2046
+
2047
+ ---
2048
+
2049
+ ## Configuration with pydantic-settings
2050
+
2051
+ ```python
2052
+ # app/config.py
2053
+ from pydantic_settings import BaseSettings, SettingsConfigDict
2054
+
2055
+
2056
+ class Settings(BaseSettings):
2057
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
2058
+
2059
+ app_name: str = "My App"
2060
+ app_version: str = "0.1.0"
2061
+ debug: bool = False
2062
+
2063
+ database_url: str
2064
+ secret_key: str
2065
+ algorithm: str = "HS256"
2066
+ access_token_expire_minutes: int = 30
2067
+
2068
+ # Pydantic-settings v2 safely evaluates mutable list literals directly
2069
+ allowed_origins: list[str] = ["http://localhost:3000"]
2070
+ allowed_methods: list[str] = ["GET", "POST", "PATCH", "DELETE", "OPTIONS"]
2071
+ allowed_headers: list[str] = ["Authorization", "Content-Type"]
2072
+ allow_credentials: bool = True
2073
+
2074
+
2075
+ settings = Settings()
2076
+ ```
2077
+
2078
+ ---
2079
+
2080
+ ## Pydantic Schemas (v2)
2081
+
2082
+ ```python
2083
+ # app/schemas/user.py
2084
+ from datetime import datetime
2085
+ from pydantic import BaseModel, EmailStr, Field, model_validator
2086
+
2087
+
2088
+ class UserBase(BaseModel):
2089
+ email: EmailStr
2090
+ username: str = Field(min_length=3, max_length=50)
2091
+
2092
+
2093
+ class UserCreate(UserBase):
2094
+ password: str = Field(min_length=8)
2095
+ password_confirm: str
2096
+
2097
+ @model_validator(mode="after")
2098
+ def passwords_match(self) -> "UserCreate":
2099
+ if self.password != self.password_confirm:
2100
+ raise ValueError("Passwords do not match")
2101
+ return self
2102
+
2103
+
2104
+ class UserUpdate(BaseModel):
2105
+ username: str | None = Field(default=None, min_length=3, max_length=50)
2106
+ email: EmailStr | None = None
2107
+
2108
+
2109
+ class UserResponse(UserBase):
2110
+ id: int
2111
+ is_active: bool
2112
+ created_at: datetime
2113
+
2114
+ model_config = {"from_attributes": True}
2115
+
2116
+
2117
+ class UserListResponse(BaseModel):
2118
+ total: int
2119
+ items: list[UserResponse]
2120
+ ```
2121
+
2122
+ ---
2123
+
2124
+ ## Dependency Injection
2125
+
2126
+ ```python
2127
+ # app/dependencies.py
2128
+ from typing import Annotated, AsyncGenerator
2129
+ from fastapi import Depends, HTTPException, status
2130
+ from fastapi.security import OAuth2PasswordBearer
2131
+ from jose import JWTError, jwt
2132
+ from sqlalchemy.ext.asyncio import AsyncSession
2133
+
2134
+ from app.config import settings
2135
+ from app.database import AsyncSessionLocal
2136
+ from app.models.user import User
2137
+
2138
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/users/token")
2139
+
2140
+
2141
+ async def get_db() -> AsyncGenerator[AsyncSession, None]:
2142
+ async with AsyncSessionLocal() as session:
2143
+ try:
2144
+ yield session
2145
+ except Exception:
2146
+ await session.rollback()
2147
+ raise
2148
+
2149
+
2150
+ async def get_current_user(
2151
+ token: Annotated[str, Depends(oauth2_scheme)],
2152
+ db: Annotated[AsyncSession, Depends(get_db)],
2153
+ ) -> User:
2154
+ credentials_exception = HTTPException(
2155
+ status_code=status.HTTP_401_UNAUTHORIZED,
2156
+ detail="Could not validate credentials",
2157
+ headers={"WWW-Authenticate": "Bearer"},
2158
+ )
2159
+ try:
2160
+ payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
2161
+ subject = payload.get("sub")
2162
+ if subject is None:
2163
+ raise credentials_exception
2164
+ user_id = int(subject)
2165
+ except (JWTError, TypeError, ValueError):
2166
+ raise credentials_exception
2167
+
2168
+ user = await db.get(User, user_id)
2169
+ if user is None:
2170
+ raise credentials_exception
2171
+ return user
2172
+
2173
+
2174
+ async def get_current_active_user(
2175
+ current_user: Annotated[User, Depends(get_current_user)],
2176
+ ) -> User:
2177
+ if not current_user.is_active:
2178
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user")
2179
+ return current_user
2180
+
2181
+
2182
+ DbDep = Annotated[AsyncSession, Depends(get_db)]
2183
+ CurrentUserDep = Annotated[User, Depends(get_current_user)]
2184
+ ActiveUserDep = Annotated[User, Depends(get_current_active_user)]
2185
+ ```
2186
+
2187
+ ---
2188
+
2189
+ ## Router and Endpoint Design
2190
+
2191
+ ```python
2192
+ # app/routers/users.py
2193
+ from typing import Annotated
2194
+ from fastapi import APIRouter, HTTPException, Query, status
2195
+ from fastapi.security import OAuth2PasswordRequestForm
2196
+
2197
+ from app.dependencies import ActiveUserDep, DbDep
2198
+ from app.schemas.user import UserCreate, UserResponse, UserUpdate, UserListResponse
2199
+ from app.services.user_service import DuplicateUserError, UserService
2200
+
2201
+ router = APIRouter()
2202
+
2203
+
2204
+ @router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
2205
+ async def create_user(payload: UserCreate, db: DbDep) -> UserResponse:
2206
+ service = UserService(db)
2207
+ try:
2208
+ return await service.create(payload)
2209
+ except DuplicateUserError:
2210
+ raise HTTPException(status_code=400, detail="Email already registered")
2211
+
2212
+
2213
+ @router.get("/me", response_model=UserResponse)
2214
+ async def get_me(current_user: ActiveUserDep) -> UserResponse:
2215
+ return current_user
2216
+
2217
+
2218
+ @router.get("/", response_model=UserListResponse)
2219
+ async def list_users(
2220
+ db: DbDep,
2221
+ current_user: ActiveUserDep,
2222
+ skip: Annotated[int, Query(ge=0)] = 0,
2223
+ limit: Annotated[int, Query(ge=1, le=100)] = 20,
2224
+ ) -> UserListResponse:
2225
+ service = UserService(db)
2226
+ users, total = await service.list(skip=skip, limit=limit)
2227
+ return UserListResponse(total=total, items=users)
2228
+
2229
+
2230
+ @router.patch("/{user_id}", response_model=UserResponse)
2231
+ async def update_user(
2232
+ user_id: int,
2233
+ payload: UserUpdate,
2234
+ db: DbDep,
2235
+ current_user: ActiveUserDep,
2236
+ ) -> UserResponse:
2237
+ if current_user.id != user_id:
2238
+ raise HTTPException(status_code=403, detail="Not authorized")
2239
+ service = UserService(db)
2240
+ try:
2241
+ user = await service.update(user_id, payload)
2242
+ except DuplicateUserError:
2243
+ raise HTTPException(status_code=400, detail="Email already registered")
2244
+ if user is None:
2245
+ raise HTTPException(status_code=404, detail="User not found")
2246
+ return user
2247
+
2248
+
2249
+ @router.post("/token")
2250
+ async def login(
2251
+ form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
2252
+ db: DbDep,
2253
+ ) -> dict[str, str]:
2254
+ service = UserService(db)
2255
+ token = await service.authenticate(form_data.username, form_data.password)
2256
+ if token is None:
2257
+ raise HTTPException(
2258
+ status_code=status.HTTP_401_UNAUTHORIZED,
2259
+ detail="Incorrect username or password",
2260
+ headers={"WWW-Authenticate": "Bearer"},
2261
+ )
2262
+ return {"access_token": token, "token_type": "bearer"}
2263
+ ```
2264
+
2265
+ ---
2266
+
2267
+ ## Service Layer
2268
+
2269
+ ```python
2270
+ # app/services/user_service.py
2271
+ from datetime import datetime, timedelta, timezone
2272
+
2273
+ from jose import jwt
2274
+ from passlib.context import CryptContext
2275
+ from sqlalchemy import func, select
2276
+ from sqlalchemy.exc import IntegrityError
2277
+ from sqlalchemy.ext.asyncio import AsyncSession
2278
+
2279
+ from app.config import settings
2280
+ from app.models.user import User
2281
+ from app.schemas.user import UserCreate, UserUpdate
2282
+
2283
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
2284
+
2285
+
2286
+ class DuplicateUserError(Exception):
2287
+ """Raised when a unique user field conflicts with an existing row."""
2288
+
2289
+
2290
+ class UserService:
2291
+ def __init__(self, db: AsyncSession) -> None:
2292
+ self.db = db
2293
+
2294
+ async def get_by_email(self, email: str) -> User | None:
2295
+ result = await self.db.execute(select(User).where(User.email == email))
2296
+ return result.scalar_one_or_none()
2297
+
2298
+ async def create(self, payload: UserCreate) -> User:
2299
+ user = User(
2300
+ email=payload.email,
2301
+ username=payload.username,
2302
+ hashed_password=pwd_context.hash(payload.password),
2303
+ )
2304
+ self.db.add(user)
2305
+ try:
2306
+ # Rely on atomic DB constraints rather than race-prone application-level prechecks
2307
+ await self.db.commit()
2308
+ except IntegrityError as exc:
2309
+ await self.db.rollback()
2310
+ raise DuplicateUserError from exc
2311
+ await self.db.refresh(user)
2312
+ return user
2313
+
2314
+ async def list(self, skip: int = 0, limit: int = 20) -> tuple[list[User], int]:
2315
+ total_result = await self.db.execute(select(func.count(User.id)))
2316
+ total = total_result.scalar_one()
2317
+ # Enforce explicit deterministic ordering to ensure reliable pagination
2318
+ result = await self.db.execute(
2319
+ select(User).order_by(User.id).offset(skip).limit(limit)
2320
+ )
2321
+ return list(result.scalars()), total
2322
+
2323
+ async def update(self, user_id: int, payload: UserUpdate) -> User | None:
2324
+ user = await self.db.get(User, user_id)
2325
+ if user is None:
2326
+ return None
2327
+ for field, value in payload.model_dump(exclude_unset=True).items():
2328
+ setattr(user, field, value)
2329
+ try:
2330
+ await self.db.commit()
2331
+ except IntegrityError as exc:
2332
+ await self.db.rollback()
2333
+ raise DuplicateUserError from exc
2334
+ await self.db.refresh(user)
2335
+ return user
2336
+
2337
+ async def authenticate(self, email: str, password: str) -> str | None:
2338
+ user = await self.get_by_email(email)
2339
+ if user is None or not pwd_context.verify(password, user.hashed_password):
2340
+ return None
2341
+ expire = datetime.now(timezone.utc) + timedelta(
2342
+ minutes=settings.access_token_expire_minutes
2343
+ )
2344
+ return jwt.encode(
2345
+ {"sub": str(user.id), "exp": expire},
2346
+ settings.secret_key,
2347
+ algorithm=settings.algorithm,
2348
+ )
2349
+ ```
2350
+
2351
+ > **Note on Database Design:** Application-level unique handling requires an underlying unique database index (e.g., `unique=True` on your SQLAlchemy mapping attributes). Without underlying constraints, application layer error-catching cannot safely prevent concurrent race conditions.
2352
+
2353
+ ---
2354
+
2355
+ ## Testing with httpx and pytest
2356
+
2357
+ ```python
2358
+ # tests/conftest.py
2359
+ import pytest_asyncio
2360
+ from httpx import ASGITransport, AsyncClient
2361
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
2362
+
2363
+ from app.database import Base
2364
+ from app.dependencies import get_db
2365
+ from app.main import create_app
2366
+
2367
+ TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
2368
+
2369
+ engine = create_async_engine(TEST_DATABASE_URL)
2370
+ TestingSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
2371
+
2372
+
2373
+ @pytest_asyncio.fixture(autouse=True)
2374
+ async def setup_db():
2375
+ async with engine.begin() as conn:
2376
+ await conn.run_sync(Base.metadata.create_all)
2377
+ yield
2378
+ async with engine.begin() as conn:
2379
+ await conn.run_sync(Base.metadata.drop_all)
2380
+
2381
+
2382
+ @pytest_asyncio.fixture
2383
+ async def db_session():
2384
+ async with TestingSessionLocal() as session:
2385
+ yield session
2386
+ await session.rollback()
2387
+
2388
+
2389
+ @pytest_asyncio.fixture
2390
+ async def client(db_session: AsyncSession):
2391
+ app = create_app()
2392
+
2393
+ async def override_get_db():
2394
+ yield db_session
2395
+
2396
+ app.dependency_overrides[get_db] = override_get_db
2397
+
2398
+ async with AsyncClient(
2399
+ transport=ASGITransport(app=app), base_url="http://test"
2400
+ ) as ac:
2401
+ yield ac
2402
+
2403
+
2404
+ @pytest_asyncio.fixture
2405
+ async def registered_user(client: AsyncClient) -> dict:
2406
+ resp = await client.post("/users/", json={
2407
+ "email": "test@example.com",
2408
+ "username": "testuser",
2409
+ "password": "securepass1",
2410
+ "password_confirm": "securepass1",
2411
+ })
2412
+ assert resp.status_code == 201
2413
+ return resp.json()
2414
+
2415
+
2416
+ @pytest_asyncio.fixture
2417
+ async def auth_token(client: AsyncClient, registered_user: dict) -> str:
2418
+ resp = await client.post("/users/token", data={
2419
+ "username": "test@example.com",
2420
+ "password": "securepass1",
2421
+ })
2422
+ assert resp.status_code == 200
2423
+ return resp.json()["access_token"]
2424
+
2425
+
2426
+ @pytest_asyncio.fixture
2427
+ async def auth_client(client: AsyncClient, auth_token: str) -> AsyncClient:
2428
+ client.headers.update({"Authorization": f"Bearer {auth_token}"})
2429
+ return client
2430
+ ```
2431
+
2432
+ ---
2433
+
2434
+ ## Anti-Patterns
2435
+
2436
+ ```python
2437
+ # Bad: business logic inside route handlers.
2438
+ @router.post("/users/")
2439
+ async def create_user(payload: UserCreate, db: DbDep):
2440
+ hashed = bcrypt.hash(payload.password)
2441
+ user = User(email=payload.email, hashed_password=hashed)
2442
+ db.add(user)
2443
+ await db.commit()
2444
+ return user
2445
+
2446
+ # Good: thin route, transactional service handling.
2447
+ @router.post("/users/", response_model=UserResponse, status_code=201)
2448
+ async def create_user(payload: UserCreate, db: DbDep):
2449
+ try:
2450
+ return await UserService(db).create(payload)
2451
+ except DuplicateUserError:
2452
+ raise HTTPException(status_code=400, detail="Email already registered")
2453
+
2454
+
2455
+ # Bad: sync DB calls in async routes block the event loop.
2456
+ @router.get("/items/")
2457
+ async def list_items(db: Session = Depends(get_db)):
2458
+ return db.query(Item).all()
2459
+
2460
+ # Good: use async SQLAlchemy executions.
2461
+ @router.get("/items/")
2462
+ async def list_items(db: AsyncSession = Depends(get_db)):
2463
+ result = await db.execute(select(Item))
2464
+ return result.scalars().all()
2465
+ ```
2466
+
2467
+ ---
2468
+
2469
+ ## Best Practices
2470
+
2471
+ - Always declare a typed `response_model` to prevent accidental PII/data leaks and output clean OpenAPI schemas.
2472
+ - Consolidate standard middleware dependency injections via type-aliasing: `DbDep = Annotated[AsyncSession, Depends(get_db)]`.
2473
+ - Wrap database mutation boundaries gracefully within transactions inside your service layer, catching structural database errors directly.
2474
+ - Parse JWT parameters defensively, expecting potential string/integer cast mismatches from modern payload variations.
2475
+ - Enforce deterministic sorting (e.g., `.order_by(Model.id)`) on all offset/limit paginated endpoints to avoid data skips.
2476
+ - Isolate authorization checks from core authentication dependencies to provide precise REST status signals (`401` vs `403`).
2477
+
2478
+ ### django-security
2479
+
2480
+ Django production security settings, authentication and custom user models, authorization/RBAC and DRF permissions, SQL injection and XSS prevention, CSRF, file-upload validation, rate limiting, security headers, and secret management. Use when configuring or reviewing Django security posture.
2481
+
2482
+ # Django Security Best Practices
2483
+
2484
+ Comprehensive security guidelines for Django applications to protect against common vulnerabilities.
2485
+
2486
+ ## When to Activate
2487
+
2488
+ - Setting up Django authentication and authorization
2489
+ - Implementing user permissions and roles
2490
+ - Configuring production security settings
2491
+ - Reviewing Django application for security issues
2492
+ - Deploying Django applications to production
2493
+
2494
+ ## Core Security Settings
2495
+
2496
+ ### Production Settings Configuration
2497
+
2498
+ ```python
2499
+ # settings/production.py
2500
+ import os
2501
+
2502
+ DEBUG = False # CRITICAL: Never use True in production
2503
+
2504
+ ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split(',')
2505
+
2506
+ # Security headers
2507
+ SECURE_SSL_REDIRECT = True
2508
+ SESSION_COOKIE_SECURE = True
2509
+ CSRF_COOKIE_SECURE = True
2510
+ SECURE_HSTS_SECONDS = 31536000 # 1 year
2511
+ SECURE_HSTS_INCLUDE_SUBDOMAINS = True
2512
+ SECURE_HSTS_PRELOAD = True
2513
+ SECURE_CONTENT_TYPE_NOSNIFF = True
2514
+ SECURE_BROWSER_XSS_FILTER = True
2515
+ X_FRAME_OPTIONS = 'DENY'
2516
+
2517
+ # HTTPS and Cookies
2518
+ SESSION_COOKIE_HTTPONLY = True
2519
+ CSRF_COOKIE_HTTPONLY = True
2520
+ SESSION_COOKIE_SAMESITE = 'Lax'
2521
+ CSRF_COOKIE_SAMESITE = 'Lax'
2522
+
2523
+ # Secret key (must be set via environment variable)
2524
+ SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
2525
+ if not SECRET_KEY:
2526
+ raise ImproperlyConfigured('DJANGO_SECRET_KEY environment variable is required')
2527
+
2528
+ # Password validation
2529
+ AUTH_PASSWORD_VALIDATORS = [
2530
+ {
2531
+ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
2532
+ },
2533
+ {
2534
+ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
2535
+ 'OPTIONS': {
2536
+ 'min_length': 12,
2537
+ }
2538
+ },
2539
+ {
2540
+ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
2541
+ },
2542
+ {
2543
+ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
2544
+ },
2545
+ ]
2546
+ ```
2547
+
2548
+ ## Authentication
2549
+
2550
+ ### Custom User Model
2551
+
2552
+ ```python
2553
+ # apps/users/models.py
2554
+ from django.contrib.auth.models import AbstractUser
2555
+ from django.db import models
2556
+
2557
+ class User(AbstractUser):
2558
+ """Custom user model for better security."""
2559
+
2560
+ email = models.EmailField(unique=True)
2561
+ phone = models.CharField(max_length=20, blank=True)
2562
+
2563
+ USERNAME_FIELD = 'email' # Use email as username
2564
+ REQUIRED_FIELDS = ['username']
2565
+
2566
+ class Meta:
2567
+ db_table = 'users'
2568
+ verbose_name = 'User'
2569
+ verbose_name_plural = 'Users'
2570
+
2571
+ def __str__(self):
2572
+ return self.email
2573
+
2574
+ # settings/base.py
2575
+ AUTH_USER_MODEL = 'users.User'
2576
+ ```
2577
+
2578
+ ### Password Hashing
2579
+
2580
+ ```python
2581
+ # Django uses PBKDF2 by default. For stronger security:
2582
+ PASSWORD_HASHERS = [
2583
+ 'django.contrib.auth.hashers.Argon2PasswordHasher',
2584
+ 'django.contrib.auth.hashers.PBKDF2PasswordHasher',
2585
+ 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
2586
+ 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
2587
+ ]
2588
+ ```
2589
+
2590
+ ### Session Management
2591
+
2592
+ ```python
2593
+ # Session configuration
2594
+ SESSION_ENGINE = 'django.contrib.sessions.backends.cache' # Or 'db'
2595
+ SESSION_CACHE_ALIAS = 'default'
2596
+ SESSION_COOKIE_AGE = 3600 * 24 * 7 # 1 week
2597
+ SESSION_SAVE_EVERY_REQUEST = False
2598
+ SESSION_EXPIRE_AT_BROWSER_CLOSE = False # Better UX, but less secure
2599
+ ```
2600
+
2601
+ ## Authorization
2602
+
2603
+ ### Permissions
2604
+
2605
+ ```python
2606
+ # models.py
2607
+ from django.db import models
2608
+ from django.contrib.auth.models import Permission
2609
+
2610
+ class Post(models.Model):
2611
+ title = models.CharField(max_length=200)
2612
+ content = models.TextField()
2613
+ author = models.ForeignKey(User, on_delete=models.CASCADE)
2614
+
2615
+ class Meta:
2616
+ permissions = [
2617
+ ('can_publish', 'Can publish posts'),
2618
+ ('can_edit_others', 'Can edit posts of others'),
2619
+ ]
2620
+
2621
+ def user_can_edit(self, user):
2622
+ """Check if user can edit this post."""
2623
+ return self.author == user or user.has_perm('app.can_edit_others')
2624
+
2625
+ # views.py
2626
+ from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
2627
+ from django.views.generic import UpdateView
2628
+
2629
+ class PostUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
2630
+ model = Post
2631
+ permission_required = 'app.can_edit_others'
2632
+ raise_exception = True # Return 403 instead of redirect
2633
+
2634
+ def get_queryset(self):
2635
+ """Only allow users to edit their own posts."""
2636
+ return Post.objects.filter(author=self.request.user)
2637
+ ```
2638
+
2639
+ ### Custom Permissions
2640
+
2641
+ ```python
2642
+ # permissions.py
2643
+ from rest_framework import permissions
2644
+
2645
+ class IsOwnerOrReadOnly(permissions.BasePermission):
2646
+ """Allow only owners to edit objects."""
2647
+
2648
+ def has_object_permission(self, request, view, obj):
2649
+ # Read permissions allowed for any request
2650
+ if request.method in permissions.SAFE_METHODS:
2651
+ return True
2652
+
2653
+ # Write permissions only for owner
2654
+ return obj.author == request.user
2655
+
2656
+ class IsAdminOrReadOnly(permissions.BasePermission):
2657
+ """Allow admins to do anything, others read-only."""
2658
+
2659
+ def has_permission(self, request, view):
2660
+ if request.method in permissions.SAFE_METHODS:
2661
+ return True
2662
+ return request.user and request.user.is_staff
2663
+
2664
+ class IsVerifiedUser(permissions.BasePermission):
2665
+ """Allow only verified users."""
2666
+
2667
+ def has_permission(self, request, view):
2668
+ return request.user and request.user.is_authenticated and request.user.is_verified
2669
+ ```
2670
+
2671
+ ### Role-Based Access Control (RBAC)
2672
+
2673
+ ```python
2674
+ # models.py
2675
+ from django.contrib.auth.models import AbstractUser, Group
2676
+
2677
+ class User(AbstractUser):
2678
+ ROLE_CHOICES = [
2679
+ ('admin', 'Administrator'),
2680
+ ('moderator', 'Moderator'),
2681
+ ('user', 'Regular User'),
2682
+ ]
2683
+ role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='user')
2684
+
2685
+ def is_admin(self):
2686
+ return self.role == 'admin' or self.is_superuser
2687
+
2688
+ def is_moderator(self):
2689
+ return self.role in ['admin', 'moderator']
2690
+
2691
+ # Mixins
2692
+ class AdminRequiredMixin:
2693
+ """Mixin to require admin role."""
2694
+
2695
+ def dispatch(self, request, *args, **kwargs):
2696
+ if not request.user.is_authenticated or not request.user.is_admin():
2697
+ from django.core.exceptions import PermissionDenied
2698
+ raise PermissionDenied
2699
+ return super().dispatch(request, *args, **kwargs)
2700
+ ```
2701
+
2702
+ ## SQL Injection Prevention
2703
+
2704
+ ### Django ORM Protection
2705
+
2706
+ ```python
2707
+ # GOOD: Django ORM automatically escapes parameters
2708
+ def get_user(username):
2709
+ return User.objects.get(username=username) # Safe
2710
+
2711
+ # GOOD: Using parameters with raw()
2712
+ def search_users(query):
2713
+ return User.objects.raw('SELECT * FROM users WHERE username = %s', [query])
2714
+
2715
+ # BAD: Never directly interpolate user input
2716
+ def get_user_bad(username):
2717
+ return User.objects.raw(f'SELECT * FROM users WHERE username = {username}') # VULNERABLE!
2718
+
2719
+ # GOOD: Using filter with proper escaping
2720
+ def get_users_by_email(email):
2721
+ return User.objects.filter(email__iexact=email) # Safe
2722
+
2723
+ # GOOD: Using Q objects for complex queries
2724
+ from django.db.models import Q
2725
+ def search_users_complex(query):
2726
+ return User.objects.filter(
2727
+ Q(username__icontains=query) |
2728
+ Q(email__icontains=query)
2729
+ ) # Safe
2730
+ ```
2731
+
2732
+ ### Extra Security with raw()
2733
+
2734
+ ```python
2735
+ # If you must use raw SQL, always use parameters
2736
+ User.objects.raw(
2737
+ 'SELECT * FROM users WHERE email = %s AND status = %s',
2738
+ [user_input_email, status]
2739
+ )
2740
+ ```
2741
+
2742
+ ## XSS Prevention
2743
+
2744
+ ### Template Escaping
2745
+
2746
+ ```django
2747
+ {# Django auto-escapes variables by default - SAFE #}
2748
+ {{ user_input }} {# Escaped HTML #}
2749
+
2750
+ {# Explicitly mark safe only for trusted content #}
2751
+ {{ trusted_html|safe }} {# Not escaped #}
2752
+
2753
+ {# Use template filters for safe HTML #}
2754
+ {{ user_input|escape }} {# Same as default #}
2755
+ {{ user_input|striptags }} {# Remove all HTML tags #}
2756
+
2757
+ {# JavaScript escaping #}
2758
+ <script>
2759
+ var username = {{ username|escapejs }};
2760
+ </script>
2761
+ ```
2762
+
2763
+ ### Safe String Handling
2764
+
2765
+ ```python
2766
+ from django.utils.safestring import mark_safe
2767
+ from django.utils.html import escape
2768
+
2769
+ # BAD: Never mark user input as safe without escaping
2770
+ def render_bad(user_input):
2771
+ return mark_safe(user_input) # VULNERABLE!
2772
+
2773
+ # GOOD: Escape first, then mark safe
2774
+ def render_good(user_input):
2775
+ return mark_safe(escape(user_input))
2776
+
2777
+ # GOOD: Use format_html for HTML with variables
2778
+ from django.utils.html import format_html
2779
+
2780
+ def greet_user(username):
2781
+ return format_html('<span class="user">{}</span>', escape(username))
2782
+ ```
2783
+
2784
+ ### HTTP Headers
2785
+
2786
+ ```python
2787
+ # settings.py
2788
+ SECURE_CONTENT_TYPE_NOSNIFF = True # Prevent MIME sniffing
2789
+ SECURE_BROWSER_XSS_FILTER = True # Enable XSS filter
2790
+ X_FRAME_OPTIONS = 'DENY' # Prevent clickjacking
2791
+
2792
+ # Custom middleware
2793
+ from django.conf import settings
2794
+
2795
+ class SecurityHeaderMiddleware:
2796
+ def __init__(self, get_response):
2797
+ self.get_response = get_response
2798
+
2799
+ def __call__(self, request):
2800
+ response = self.get_response(request)
2801
+ response['X-Content-Type-Options'] = 'nosniff'
2802
+ response['X-Frame-Options'] = 'DENY'
2803
+ response['X-XSS-Protection'] = '1; mode=block'
2804
+ response['Content-Security-Policy'] = "default-src 'self'"
2805
+ return response
2806
+ ```
2807
+
2808
+ ## CSRF Protection
2809
+
2810
+ ### Default CSRF Protection
2811
+
2812
+ ```python
2813
+ # settings.py - CSRF is enabled by default
2814
+ CSRF_COOKIE_SECURE = True # Only send over HTTPS
2815
+ CSRF_COOKIE_HTTPONLY = True # Prevent JavaScript access
2816
+ CSRF_COOKIE_SAMESITE = 'Lax' # Prevent CSRF in some cases
2817
+ CSRF_TRUSTED_ORIGINS = ['https://example.com'] # Trusted domains
2818
+
2819
+ # Template usage
2820
+ <form method="post">
2821
+ {% csrf_token %}
2822
+ {{ form.as_p }}
2823
+ <button type="submit">Submit</button>
2824
+ </form>
2825
+
2826
+ # AJAX requests
2827
+ function getCookie(name) {
2828
+ let cookieValue = null;
2829
+ if (document.cookie && document.cookie !== '') {
2830
+ const cookies = document.cookie.split(';');
2831
+ for (let i = 0; i < cookies.length; i++) {
2832
+ const cookie = cookies[i].trim();
2833
+ if (cookie.substring(0, name.length + 1) === (name + '=')) {
2834
+ cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
2835
+ break;
2836
+ }
2837
+ }
2838
+ }
2839
+ return cookieValue;
2840
+ }
2841
+
2842
+ fetch('/api/endpoint/', {
2843
+ method: 'POST',
2844
+ headers: {
2845
+ 'X-CSRFToken': getCookie('csrftoken'),
2846
+ 'Content-Type': 'application/json',
2847
+ },
2848
+ body: JSON.stringify(data)
2849
+ });
2850
+ ```
2851
+
2852
+ ### Exempting Views (Use Carefully)
2853
+
2854
+ ```python
2855
+ from django.views.decorators.csrf import csrf_exempt
2856
+
2857
+ @csrf_exempt # Only use when absolutely necessary!
2858
+ def webhook_view(request):
2859
+ # Webhook from external service
2860
+ pass
2861
+ ```
2862
+
2863
+ ## File Upload Security
2864
+
2865
+ ### File Validation
2866
+
2867
+ ```python
2868
+ import os
2869
+ import magic # pip install python-magic
2870
+ from django.core.exceptions import ValidationError
2871
+
2872
+ ALLOWED_MIMES = {
2873
+ 'image/jpeg', 'image/png', 'image/gif', 'application/pdf',
2874
+ }
2875
+
2876
+ MIME_TO_EXTENSIONS = {
2877
+ 'image/jpeg': {'.jpg', '.jpeg'},
2878
+ 'image/png': {'.png'},
2879
+ 'image/gif': {'.gif'},
2880
+ 'application/pdf': {'.pdf'},
2881
+ }
2882
+
2883
+ def validate_file_type(value):
2884
+ """Validate file type using magic bytes and cross-check extension."""
2885
+ mime = magic.from_buffer(value.read(2048), mime=True)
2886
+ value.seek(0)
2887
+
2888
+ if mime not in ALLOWED_MIMES:
2889
+ raise ValidationError('Unsupported file type.')
2890
+
2891
+ ext = os.path.splitext(value.name)[1].lower()
2892
+ if ext not in MIME_TO_EXTENSIONS.get(mime, set()):
2893
+ raise ValidationError('File extension does not match file content.')
2894
+
2895
+ def validate_file_size(value):
2896
+ """Validate file size (max 5MB)."""
2897
+ if value.size > 5 * 1024 * 1024:
2898
+ raise ValidationError('File too large. Max size is 5MB.')
2899
+
2900
+ # models.py
2901
+ class Document(models.Model):
2902
+ file = models.FileField(
2903
+ upload_to='documents/',
2904
+ validators=[validate_file_type, validate_file_size]
2905
+ )
2906
+
2907
+ ```
2908
+
2909
+ For environments where installing libmagic is difficult (e.g., minimal containers),
2910
+ use the pure-Python `filetype` package as an alternative:
2911
+
2912
+ ```python
2913
+ import os
2914
+ from django.core.exceptions import ValidationError
2915
+
2916
+ import filetype # pip install filetype
2917
+
2918
+ ALLOWED_MIMES = {
2919
+ 'image/jpeg', 'image/png', 'image/gif', 'application/pdf',
2920
+ }
2921
+
2922
+ MIME_TO_EXTENSIONS = {
2923
+ 'image/jpeg': {'.jpg', '.jpeg'},
2924
+ 'image/png': {'.png'},
2925
+ 'image/gif': {'.gif'},
2926
+ 'application/pdf': {'.pdf'},
2927
+ }
2928
+
2929
+ def validate_file_type(value):
2930
+ """Validate file type using magic bytes."""
2931
+ kind = filetype.guess(value.read(2048))
2932
+ value.seek(0)
2933
+
2934
+ if kind is None or kind.mime not in ALLOWED_MIMES:
2935
+ raise ValidationError('Unsupported file type.')
2936
+
2937
+ ext = os.path.splitext(value.name)[1].lower()
2938
+ if ext not in MIME_TO_EXTENSIONS.get(kind.mime, set()):
2939
+ raise ValidationError('File extension does not match file content.')
2940
+ ```
2941
+
2942
+ ### Secure File Storage
2943
+
2944
+ ```python
2945
+ # settings.py
2946
+ MEDIA_ROOT = '/var/www/media/'
2947
+ MEDIA_URL = '/media/'
2948
+
2949
+ # Use a separate domain for media in production
2950
+ MEDIA_DOMAIN = 'https://media.example.com'
2951
+
2952
+ # Don't serve user uploads directly
2953
+ # Use whitenoise or a CDN for static files
2954
+ # Use a separate server or S3 for media files
2955
+ ```
2956
+
2957
+ ## API Security
2958
+
2959
+ ### Rate Limiting
2960
+
2961
+ ```python
2962
+ # settings.py
2963
+ REST_FRAMEWORK = {
2964
+ 'DEFAULT_THROTTLE_CLASSES': [
2965
+ 'rest_framework.throttling.AnonRateThrottle',
2966
+ 'rest_framework.throttling.UserRateThrottle'
2967
+ ],
2968
+ 'DEFAULT_THROTTLE_RATES': {
2969
+ 'anon': '100/day',
2970
+ 'user': '1000/day',
2971
+ 'upload': '10/hour',
2972
+ }
2973
+ }
2974
+
2975
+ # Custom throttle
2976
+ from rest_framework.throttling import UserRateThrottle
2977
+
2978
+ class BurstRateThrottle(UserRateThrottle):
2979
+ scope = 'burst'
2980
+ rate = '60/min'
2981
+
2982
+ class SustainedRateThrottle(UserRateThrottle):
2983
+ scope = 'sustained'
2984
+ rate = '1000/day'
2985
+ ```
2986
+
2987
+ ### Authentication for APIs
2988
+
2989
+ ```python
2990
+ # settings.py
2991
+ REST_FRAMEWORK = {
2992
+ 'DEFAULT_AUTHENTICATION_CLASSES': [
2993
+ 'rest_framework.authentication.TokenAuthentication',
2994
+ 'rest_framework.authentication.SessionAuthentication',
2995
+ 'rest_framework_simplejwt.authentication.JWTAuthentication',
2996
+ ],
2997
+ 'DEFAULT_PERMISSION_CLASSES': [
2998
+ 'rest_framework.permissions.IsAuthenticated',
2999
+ ],
3000
+ }
3001
+
3002
+ # views.py
3003
+ from rest_framework.decorators import api_view, permission_classes
3004
+ from rest_framework.permissions import IsAuthenticated
3005
+
3006
+ @api_view(['GET', 'POST'])
3007
+ @permission_classes([IsAuthenticated])
3008
+ def protected_view(request):
3009
+ return Response({'message': 'You are authenticated'})
3010
+ ```
3011
+
3012
+ ## Security Headers
3013
+
3014
+ ### Content Security Policy
3015
+
3016
+ ```python
3017
+ # settings.py
3018
+ CSP_DEFAULT_SRC = "'self'"
3019
+ CSP_SCRIPT_SRC = "'self' https://cdn.example.com"
3020
+ CSP_STYLE_SRC = "'self' 'unsafe-inline'"
3021
+ CSP_IMG_SRC = "'self' data: https:"
3022
+ CSP_CONNECT_SRC = "'self' https://api.example.com"
3023
+
3024
+ # Middleware
3025
+ class CSPMiddleware:
3026
+ def __init__(self, get_response):
3027
+ self.get_response = get_response
3028
+
3029
+ def __call__(self, request):
3030
+ response = self.get_response(request)
3031
+ response['Content-Security-Policy'] = (
3032
+ f"default-src {CSP_DEFAULT_SRC}; "
3033
+ f"script-src {CSP_SCRIPT_SRC}; "
3034
+ f"style-src {CSP_STYLE_SRC}; "
3035
+ f"img-src {CSP_IMG_SRC}; "
3036
+ f"connect-src {CSP_CONNECT_SRC}"
3037
+ )
3038
+ return response
3039
+ ```
3040
+
3041
+ ## Environment Variables
3042
+
3043
+ ### Managing Secrets
3044
+
3045
+ ```python
3046
+ # Use python-decouple or django-environ
3047
+ import environ
3048
+
3049
+ env = environ.Env(
3050
+ # set casting, default value
3051
+ DEBUG=(bool, False)
3052
+ )
3053
+
3054
+ # reading .env file
3055
+ environ.Env.read_env()
3056
+
3057
+ SECRET_KEY = env('DJANGO_SECRET_KEY')
3058
+ DATABASE_URL = env('DATABASE_URL')
3059
+ ALLOWED_HOSTS = env.list('ALLOWED_HOSTS')
3060
+
3061
+ # .env file (never commit this)
3062
+ DEBUG=False
3063
+ SECRET_KEY=your-secret-key-here
3064
+ DATABASE_URL=postgresql://user:password@localhost:5432/dbname
3065
+ ALLOWED_HOSTS=example.com,www.example.com
3066
+ ```
3067
+
3068
+ ## Logging Security Events
3069
+
3070
+ ```python
3071
+ # settings.py
3072
+ LOGGING = {
3073
+ 'version': 1,
3074
+ 'disable_existing_loggers': False,
3075
+ 'handlers': {
3076
+ 'file': {
3077
+ 'level': 'WARNING',
3078
+ 'class': 'logging.FileHandler',
3079
+ 'filename': '/var/log/django/security.log',
3080
+ },
3081
+ 'console': {
3082
+ 'level': 'INFO',
3083
+ 'class': 'logging.StreamHandler',
3084
+ },
3085
+ },
3086
+ 'loggers': {
3087
+ 'django.security': {
3088
+ 'handlers': ['file', 'console'],
3089
+ 'level': 'WARNING',
3090
+ 'propagate': True,
3091
+ },
3092
+ 'django.request': {
3093
+ 'handlers': ['file'],
3094
+ 'level': 'ERROR',
3095
+ 'propagate': False,
3096
+ },
3097
+ },
3098
+ }
3099
+ ```
3100
+
3101
+ ## Quick Security Checklist
3102
+
3103
+ | Check | Description |
3104
+ |-------|-------------|
3105
+ | `DEBUG = False` | Never run with DEBUG in production |
3106
+ | HTTPS only | Force SSL, secure cookies |
3107
+ | Strong secrets | Use environment variables for SECRET_KEY |
3108
+ | Password validation | Enable all password validators |
3109
+ | CSRF protection | Enabled by default, don't disable |
3110
+ | XSS prevention | Django auto-escapes, don't use `&#124;safe` with user input |
3111
+ | SQL injection | Use ORM, never concatenate strings in queries |
3112
+ | File uploads | Validate file type and size |
3113
+ | Rate limiting | Throttle API endpoints |
3114
+ | Security headers | CSP, X-Frame-Options, HSTS |
3115
+ | Logging | Log security events |
3116
+ | Updates | Keep Django and dependencies updated |
3117
+
3118
+ Remember: Security is a process, not a product. Regularly review and update your security practices.
3119
+
3120
+ ### django-tdd
3121
+
3122
+ Django testing with pytest-django and factory_boy: TDD workflow, test settings, fixtures, model/view/serializer/DRF-API testing, mocking external services and email, and integration/full-flow tests, plus coverage targets. Use when writing or reviewing Django tests.
3123
+
3124
+ # Django Testing with TDD
3125
+
3126
+ Test-driven development for Django applications using pytest, factory_boy, and Django REST Framework.
3127
+
3128
+ ## When to Activate
3129
+
3130
+ - Writing new Django applications
3131
+ - Implementing Django REST Framework APIs
3132
+ - Testing Django models, views, and serializers
3133
+ - Setting up testing infrastructure for Django projects
3134
+
3135
+ ## TDD Workflow for Django
3136
+
3137
+ ### Red-Green-Refactor Cycle
3138
+
3139
+ ```python
3140
+ # Step 1: RED - Write failing test
3141
+ def test_user_creation():
3142
+ user = User.objects.create_user(email='test@example.com', password='testpass123')
3143
+ assert user.email == 'test@example.com'
3144
+ assert user.check_password('testpass123')
3145
+ assert not user.is_staff
3146
+
3147
+ # Step 2: GREEN - Make test pass
3148
+ # Create User model or factory
3149
+
3150
+ # Step 3: REFACTOR - Improve while keeping tests green
3151
+ ```
3152
+
3153
+ ## Setup
3154
+
3155
+ ### pytest Configuration
3156
+
3157
+ ```ini
3158
+ # pytest.ini
3159
+ [pytest]
3160
+ DJANGO_SETTINGS_MODULE = config.settings.test
3161
+ testpaths = tests
3162
+ python_files = test_*.py
3163
+ python_classes = Test*
3164
+ python_functions = test_*
3165
+ addopts =
3166
+ --reuse-db
3167
+ --nomigrations
3168
+ --cov=apps
3169
+ --cov-report=html
3170
+ --cov-report=term-missing
3171
+ --strict-markers
3172
+ markers =
3173
+ slow: marks tests as slow
3174
+ integration: marks tests as integration tests
3175
+ ```
3176
+
3177
+ ### Test Settings
3178
+
3179
+ ```python
3180
+ # config/settings/test.py
3181
+ from .base import *
3182
+
3183
+ DEBUG = True
3184
+ DATABASES = {
3185
+ 'default': {
3186
+ 'ENGINE': 'django.db.backends.sqlite3',
3187
+ 'NAME': ':memory:',
3188
+ }
3189
+ }
3190
+
3191
+ # Disable migrations for speed
3192
+ class DisableMigrations:
3193
+ def __contains__(self, item):
3194
+ return True
3195
+
3196
+ def __getitem__(self, item):
3197
+ return None
3198
+
3199
+ MIGRATION_MODULES = DisableMigrations()
3200
+
3201
+ # Faster password hashing
3202
+ PASSWORD_HASHERS = [
3203
+ 'django.contrib.auth.hashers.MD5PasswordHasher',
3204
+ ]
3205
+
3206
+ # Email backend
3207
+ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
3208
+
3209
+ # Celery always eager
3210
+ CELERY_TASK_ALWAYS_EAGER = True
3211
+ CELERY_TASK_EAGER_PROPAGATES = True
3212
+ ```
3213
+
3214
+ ### conftest.py
3215
+
3216
+ ```python
3217
+ # tests/conftest.py
3218
+ import pytest
3219
+ from django.utils import timezone
3220
+ from django.contrib.auth import get_user_model
3221
+
3222
+ User = get_user_model()
3223
+
3224
+ @pytest.fixture(autouse=True)
3225
+ def timezone_settings(settings):
3226
+ """Ensure consistent timezone."""
3227
+ settings.TIME_ZONE = 'UTC'
3228
+
3229
+ @pytest.fixture
3230
+ def user(db):
3231
+ """Create a test user."""
3232
+ return User.objects.create_user(
3233
+ email='test@example.com',
3234
+ password='testpass123',
3235
+ username='testuser'
3236
+ )
3237
+
3238
+ @pytest.fixture
3239
+ def admin_user(db):
3240
+ """Create an admin user."""
3241
+ return User.objects.create_superuser(
3242
+ email='admin@example.com',
3243
+ password='adminpass123',
3244
+ username='admin'
3245
+ )
3246
+
3247
+ @pytest.fixture
3248
+ def authenticated_client(client, user):
3249
+ """Return authenticated client."""
3250
+ client.force_login(user)
3251
+ return client
3252
+
3253
+ @pytest.fixture
3254
+ def api_client():
3255
+ """Return DRF API client."""
3256
+ from rest_framework.test import APIClient
3257
+ return APIClient()
3258
+
3259
+ @pytest.fixture
3260
+ def authenticated_api_client(api_client, user):
3261
+ """Return authenticated API client."""
3262
+ api_client.force_authenticate(user=user)
3263
+ return api_client
3264
+ ```
3265
+
3266
+ ## Factory Boy
3267
+
3268
+ ### Factory Setup
3269
+
3270
+ ```python
3271
+ # tests/factories.py
3272
+ import factory
3273
+ from factory import fuzzy
3274
+ from datetime import datetime, timedelta
3275
+ from django.contrib.auth import get_user_model
3276
+ from apps.products.models import Product, Category
3277
+
3278
+ User = get_user_model()
3279
+
3280
+ class UserFactory(factory.django.DjangoModelFactory):
3281
+ """Factory for User model."""
3282
+
3283
+ class Meta:
3284
+ model = User
3285
+
3286
+ email = factory.Sequence(lambda n: f"user{n}@example.com")
3287
+ username = factory.Sequence(lambda n: f"user{n}")
3288
+ password = factory.PostGenerationMethodCall('set_password', 'testpass123')
3289
+ first_name = factory.Faker('first_name')
3290
+ last_name = factory.Faker('last_name')
3291
+ is_active = True
3292
+
3293
+ class CategoryFactory(factory.django.DjangoModelFactory):
3294
+ """Factory for Category model."""
3295
+
3296
+ class Meta:
3297
+ model = Category
3298
+
3299
+ name = factory.Faker('word')
3300
+ slug = factory.LazyAttribute(lambda obj: obj.name.lower())
3301
+ description = factory.Faker('text')
3302
+
3303
+ class ProductFactory(factory.django.DjangoModelFactory):
3304
+ """Factory for Product model."""
3305
+
3306
+ class Meta:
3307
+ model = Product
3308
+
3309
+ name = factory.Faker('sentence', nb_words=3)
3310
+ slug = factory.LazyAttribute(lambda obj: obj.name.lower().replace(' ', '-'))
3311
+ description = factory.Faker('text')
3312
+ price = fuzzy.FuzzyDecimal(10.00, 1000.00, 2)
3313
+ stock = fuzzy.FuzzyInteger(0, 100)
3314
+ is_active = True
3315
+ category = factory.SubFactory(CategoryFactory)
3316
+ created_by = factory.SubFactory(UserFactory)
3317
+
3318
+ @factory.post_generation
3319
+ def tags(self, create, extracted, **kwargs):
3320
+ """Add tags to product."""
3321
+ if not create:
3322
+ return
3323
+ if extracted:
3324
+ for tag in extracted:
3325
+ self.tags.add(tag)
3326
+ ```
3327
+
3328
+ ### Using Factories
3329
+
3330
+ ```python
3331
+ # tests/test_models.py
3332
+ import pytest
3333
+ from tests.factories import ProductFactory, UserFactory
3334
+
3335
+ def test_product_creation():
3336
+ """Test product creation using factory."""
3337
+ product = ProductFactory(price=100.00, stock=50)
3338
+ assert product.price == 100.00
3339
+ assert product.stock == 50
3340
+ assert product.is_active is True
3341
+
3342
+ def test_product_with_tags():
3343
+ """Test product with tags."""
3344
+ tags = [TagFactory(name='electronics'), TagFactory(name='new')]
3345
+ product = ProductFactory(tags=tags)
3346
+ assert product.tags.count() == 2
3347
+
3348
+ def test_multiple_products():
3349
+ """Test creating multiple products."""
3350
+ products = ProductFactory.create_batch(10)
3351
+ assert len(products) == 10
3352
+ ```
3353
+
3354
+ ## Model Testing
3355
+
3356
+ ### Model Tests
3357
+
3358
+ ```python
3359
+ # tests/test_models.py
3360
+ import pytest
3361
+ from django.core.exceptions import ValidationError
3362
+ from tests.factories import UserFactory, ProductFactory
3363
+
3364
+ class TestUserModel:
3365
+ """Test User model."""
3366
+
3367
+ def test_create_user(self, db):
3368
+ """Test creating a regular user."""
3369
+ user = UserFactory(email='test@example.com')
3370
+ assert user.email == 'test@example.com'
3371
+ assert user.check_password('testpass123')
3372
+ assert not user.is_staff
3373
+ assert not user.is_superuser
3374
+
3375
+ def test_create_superuser(self, db):
3376
+ """Test creating a superuser."""
3377
+ user = UserFactory(
3378
+ email='admin@example.com',
3379
+ is_staff=True,
3380
+ is_superuser=True
3381
+ )
3382
+ assert user.is_staff
3383
+ assert user.is_superuser
3384
+
3385
+ def test_user_str(self, db):
3386
+ """Test user string representation."""
3387
+ user = UserFactory(email='test@example.com')
3388
+ assert str(user) == 'test@example.com'
3389
+
3390
+ class TestProductModel:
3391
+ """Test Product model."""
3392
+
3393
+ def test_product_creation(self, db):
3394
+ """Test creating a product."""
3395
+ product = ProductFactory()
3396
+ assert product.id is not None
3397
+ assert product.is_active is True
3398
+ assert product.created_at is not None
3399
+
3400
+ def test_product_slug_generation(self, db):
3401
+ """Test automatic slug generation."""
3402
+ product = ProductFactory(name='Test Product')
3403
+ assert product.slug == 'test-product'
3404
+
3405
+ def test_product_price_validation(self, db):
3406
+ """Test price cannot be negative."""
3407
+ product = ProductFactory(price=-10)
3408
+ with pytest.raises(ValidationError):
3409
+ product.full_clean()
3410
+
3411
+ def test_product_manager_active(self, db):
3412
+ """Test active manager method."""
3413
+ ProductFactory.create_batch(5, is_active=True)
3414
+ ProductFactory.create_batch(3, is_active=False)
3415
+
3416
+ active_count = Product.objects.active().count()
3417
+ assert active_count == 5
3418
+
3419
+ def test_product_stock_management(self, db):
3420
+ """Test stock management."""
3421
+ product = ProductFactory(stock=10)
3422
+ product.reduce_stock(5)
3423
+ product.refresh_from_db()
3424
+ assert product.stock == 5
3425
+
3426
+ with pytest.raises(ValueError):
3427
+ product.reduce_stock(10) # Not enough stock
3428
+ ```
3429
+
3430
+ ## View Testing
3431
+
3432
+ ### Django View Testing
3433
+
3434
+ ```python
3435
+ # tests/test_views.py
3436
+ import pytest
3437
+ from django.urls import reverse
3438
+ from tests.factories import ProductFactory, UserFactory
3439
+
3440
+ class TestProductViews:
3441
+ """Test product views."""
3442
+
3443
+ def test_product_list(self, client, db):
3444
+ """Test product list view."""
3445
+ ProductFactory.create_batch(10)
3446
+
3447
+ response = client.get(reverse('products:list'))
3448
+
3449
+ assert response.status_code == 200
3450
+ assert len(response.context['products']) == 10
3451
+
3452
+ def test_product_detail(self, client, db):
3453
+ """Test product detail view."""
3454
+ product = ProductFactory()
3455
+
3456
+ response = client.get(reverse('products:detail', kwargs={'slug': product.slug}))
3457
+
3458
+ assert response.status_code == 200
3459
+ assert response.context['product'] == product
3460
+
3461
+ def test_product_create_requires_login(self, client, db):
3462
+ """Test product creation requires authentication."""
3463
+ response = client.get(reverse('products:create'))
3464
+
3465
+ assert response.status_code == 302
3466
+ assert response.url.startswith('/accounts/login/')
3467
+
3468
+ def test_product_create_authenticated(self, authenticated_client, db):
3469
+ """Test product creation as authenticated user."""
3470
+ response = authenticated_client.get(reverse('products:create'))
3471
+
3472
+ assert response.status_code == 200
3473
+
3474
+ def test_product_create_post(self, authenticated_client, db, category):
3475
+ """Test creating a product via POST."""
3476
+ data = {
3477
+ 'name': 'Test Product',
3478
+ 'description': 'A test product',
3479
+ 'price': '99.99',
3480
+ 'stock': 10,
3481
+ 'category': category.id,
3482
+ }
3483
+
3484
+ response = authenticated_client.post(reverse('products:create'), data)
3485
+
3486
+ assert response.status_code == 302
3487
+ assert Product.objects.filter(name='Test Product').exists()
3488
+ ```
3489
+
3490
+ ## DRF API Testing
3491
+
3492
+ ### Serializer Testing
3493
+
3494
+ ```python
3495
+ # tests/test_serializers.py
3496
+ import pytest
3497
+ from rest_framework.exceptions import ValidationError
3498
+ from apps.products.serializers import ProductSerializer
3499
+ from tests.factories import ProductFactory
3500
+
3501
+ class TestProductSerializer:
3502
+ """Test ProductSerializer."""
3503
+
3504
+ def test_serialize_product(self, db):
3505
+ """Test serializing a product."""
3506
+ product = ProductFactory()
3507
+ serializer = ProductSerializer(product)
3508
+
3509
+ data = serializer.data
3510
+
3511
+ assert data['id'] == product.id
3512
+ assert data['name'] == product.name
3513
+ assert data['price'] == str(product.price)
3514
+
3515
+ def test_deserialize_product(self, db):
3516
+ """Test deserializing product data."""
3517
+ data = {
3518
+ 'name': 'Test Product',
3519
+ 'description': 'Test description',
3520
+ 'price': '99.99',
3521
+ 'stock': 10,
3522
+ 'category': 1,
3523
+ }
3524
+
3525
+ serializer = ProductSerializer(data=data)
3526
+
3527
+ assert serializer.is_valid()
3528
+ product = serializer.save()
3529
+
3530
+ assert product.name == 'Test Product'
3531
+ assert float(product.price) == 99.99
3532
+
3533
+ def test_price_validation(self, db):
3534
+ """Test price validation."""
3535
+ data = {
3536
+ 'name': 'Test Product',
3537
+ 'price': '-10.00',
3538
+ 'stock': 10,
3539
+ }
3540
+
3541
+ serializer = ProductSerializer(data=data)
3542
+
3543
+ assert not serializer.is_valid()
3544
+ assert 'price' in serializer.errors
3545
+
3546
+ def test_stock_validation(self, db):
3547
+ """Test stock cannot be negative."""
3548
+ data = {
3549
+ 'name': 'Test Product',
3550
+ 'price': '99.99',
3551
+ 'stock': -5,
3552
+ }
3553
+
3554
+ serializer = ProductSerializer(data=data)
3555
+
3556
+ assert not serializer.is_valid()
3557
+ assert 'stock' in serializer.errors
3558
+ ```
3559
+
3560
+ ### API ViewSet Testing
3561
+
3562
+ ```python
3563
+ # tests/test_api.py
3564
+ import pytest
3565
+ from rest_framework.test import APIClient
3566
+ from rest_framework import status
3567
+ from django.urls import reverse
3568
+ from tests.factories import ProductFactory, UserFactory
3569
+
3570
+ class TestProductAPI:
3571
+ """Test Product API endpoints."""
3572
+
3573
+ @pytest.fixture
3574
+ def api_client(self):
3575
+ """Return API client."""
3576
+ return APIClient()
3577
+
3578
+ def test_list_products(self, api_client, db):
3579
+ """Test listing products."""
3580
+ ProductFactory.create_batch(10)
3581
+
3582
+ url = reverse('api:product-list')
3583
+ response = api_client.get(url)
3584
+
3585
+ assert response.status_code == status.HTTP_200_OK
3586
+ assert response.data['count'] == 10
3587
+
3588
+ def test_retrieve_product(self, api_client, db):
3589
+ """Test retrieving a product."""
3590
+ product = ProductFactory()
3591
+
3592
+ url = reverse('api:product-detail', kwargs={'pk': product.id})
3593
+ response = api_client.get(url)
3594
+
3595
+ assert response.status_code == status.HTTP_200_OK
3596
+ assert response.data['id'] == product.id
3597
+
3598
+ def test_create_product_unauthorized(self, api_client, db):
3599
+ """Test creating product without authentication."""
3600
+ url = reverse('api:product-list')
3601
+ data = {'name': 'Test Product', 'price': '99.99'}
3602
+
3603
+ response = api_client.post(url, data)
3604
+
3605
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
3606
+
3607
+ def test_create_product_authorized(self, authenticated_api_client, db):
3608
+ """Test creating product as authenticated user."""
3609
+ url = reverse('api:product-list')
3610
+ data = {
3611
+ 'name': 'Test Product',
3612
+ 'description': 'Test',
3613
+ 'price': '99.99',
3614
+ 'stock': 10,
3615
+ }
3616
+
3617
+ response = authenticated_api_client.post(url, data)
3618
+
3619
+ assert response.status_code == status.HTTP_201_CREATED
3620
+ assert response.data['name'] == 'Test Product'
3621
+
3622
+ def test_update_product(self, authenticated_api_client, db):
3623
+ """Test updating a product."""
3624
+ product = ProductFactory(created_by=authenticated_api_client.user)
3625
+
3626
+ url = reverse('api:product-detail', kwargs={'pk': product.id})
3627
+ data = {'name': 'Updated Product'}
3628
+
3629
+ response = authenticated_api_client.patch(url, data)
3630
+
3631
+ assert response.status_code == status.HTTP_200_OK
3632
+ assert response.data['name'] == 'Updated Product'
3633
+
3634
+ def test_delete_product(self, authenticated_api_client, db):
3635
+ """Test deleting a product."""
3636
+ product = ProductFactory(created_by=authenticated_api_client.user)
3637
+
3638
+ url = reverse('api:product-detail', kwargs={'pk': product.id})
3639
+ response = authenticated_api_client.delete(url)
3640
+
3641
+ assert response.status_code == status.HTTP_204_NO_CONTENT
3642
+
3643
+ def test_filter_products_by_price(self, api_client, db):
3644
+ """Test filtering products by price."""
3645
+ ProductFactory(price=50)
3646
+ ProductFactory(price=150)
3647
+
3648
+ url = reverse('api:product-list')
3649
+ response = api_client.get(url, {'price_min': 100})
3650
+
3651
+ assert response.status_code == status.HTTP_200_OK
3652
+ assert response.data['count'] == 1
3653
+
3654
+ def test_search_products(self, api_client, db):
3655
+ """Test searching products."""
3656
+ ProductFactory(name='Apple iPhone')
3657
+ ProductFactory(name='Samsung Galaxy')
3658
+
3659
+ url = reverse('api:product-list')
3660
+ response = api_client.get(url, {'search': 'Apple'})
3661
+
3662
+ assert response.status_code == status.HTTP_200_OK
3663
+ assert response.data['count'] == 1
3664
+ ```
3665
+
3666
+ ## Mocking and Patching
3667
+
3668
+ ### Mocking External Services
3669
+
3670
+ ```python
3671
+ # tests/test_views.py
3672
+ from unittest.mock import patch, Mock
3673
+ import pytest
3674
+
3675
+ class TestPaymentView:
3676
+ """Test payment view with mocked payment gateway."""
3677
+
3678
+ @patch('apps.payments.services.stripe')
3679
+ def test_successful_payment(self, mock_stripe, client, user, product):
3680
+ """Test successful payment with mocked Stripe."""
3681
+ # Configure mock
3682
+ mock_stripe.Charge.create.return_value = {
3683
+ 'id': 'ch_123',
3684
+ 'status': 'succeeded',
3685
+ 'amount': 9999,
3686
+ }
3687
+
3688
+ client.force_login(user)
3689
+ response = client.post(reverse('payments:process'), {
3690
+ 'product_id': product.id,
3691
+ 'token': 'tok_visa',
3692
+ })
3693
+
3694
+ assert response.status_code == 302
3695
+ mock_stripe.Charge.create.assert_called_once()
3696
+
3697
+ @patch('apps.payments.services.stripe')
3698
+ def test_failed_payment(self, mock_stripe, client, user, product):
3699
+ """Test failed payment."""
3700
+ mock_stripe.Charge.create.side_effect = Exception('Card declined')
3701
+
3702
+ client.force_login(user)
3703
+ response = client.post(reverse('payments:process'), {
3704
+ 'product_id': product.id,
3705
+ 'token': 'tok_visa',
3706
+ })
3707
+
3708
+ assert response.status_code == 302
3709
+ assert 'error' in response.url
3710
+ ```
3711
+
3712
+ ### Mocking Email Sending
3713
+
3714
+ ```python
3715
+ # tests/test_email.py
3716
+ from django.core import mail
3717
+ from django.test import override_settings
3718
+
3719
+ @override_settings(EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend')
3720
+ def test_order_confirmation_email(db, order):
3721
+ """Test order confirmation email."""
3722
+ order.send_confirmation_email()
3723
+
3724
+ assert len(mail.outbox) == 1
3725
+ assert order.user.email in mail.outbox[0].to
3726
+ assert 'Order Confirmation' in mail.outbox[0].subject
3727
+ ```
3728
+
3729
+ ## Integration Testing
3730
+
3731
+ ### Full Flow Testing
3732
+
3733
+ ```python
3734
+ # tests/test_integration.py
3735
+ import pytest
3736
+ from django.urls import reverse
3737
+ from tests.factories import UserFactory, ProductFactory
3738
+
3739
+ class TestCheckoutFlow:
3740
+ """Test complete checkout flow."""
3741
+
3742
+ def test_guest_to_purchase_flow(self, client, db):
3743
+ """Test complete flow from guest to purchase."""
3744
+ # Step 1: Register
3745
+ response = client.post(reverse('users:register'), {
3746
+ 'email': 'test@example.com',
3747
+ 'password': 'testpass123',
3748
+ 'password_confirm': 'testpass123',
3749
+ })
3750
+ assert response.status_code == 302
3751
+
3752
+ # Step 2: Login
3753
+ response = client.post(reverse('users:login'), {
3754
+ 'email': 'test@example.com',
3755
+ 'password': 'testpass123',
3756
+ })
3757
+ assert response.status_code == 302
3758
+
3759
+ # Step 3: Browse products
3760
+ product = ProductFactory(price=100)
3761
+ response = client.get(reverse('products:detail', kwargs={'slug': product.slug}))
3762
+ assert response.status_code == 200
3763
+
3764
+ # Step 4: Add to cart
3765
+ response = client.post(reverse('cart:add'), {
3766
+ 'product_id': product.id,
3767
+ 'quantity': 1,
3768
+ })
3769
+ assert response.status_code == 302
3770
+
3771
+ # Step 5: Checkout
3772
+ response = client.get(reverse('checkout:review'))
3773
+ assert response.status_code == 200
3774
+ assert product.name in response.content.decode()
3775
+
3776
+ # Step 6: Complete purchase
3777
+ with patch('apps.checkout.services.process_payment') as mock_payment:
3778
+ mock_payment.return_value = True
3779
+ response = client.post(reverse('checkout:complete'))
3780
+
3781
+ assert response.status_code == 302
3782
+ assert Order.objects.filter(user__email='test@example.com').exists()
3783
+ ```
3784
+
3785
+ ## Testing Best Practices
3786
+
3787
+ ### DO
3788
+
3789
+ - **Use factories**: Instead of manual object creation
3790
+ - **One assertion per test**: Keep tests focused
3791
+ - **Descriptive test names**: `test_user_cannot_delete_others_post`
3792
+ - **Test edge cases**: Empty inputs, None values, boundary conditions
3793
+ - **Mock external services**: Don't depend on external APIs
3794
+ - **Use fixtures**: Eliminate duplication
3795
+ - **Test permissions**: Ensure authorization works
3796
+ - **Keep tests fast**: Use `--reuse-db` and `--nomigrations`
3797
+
3798
+ ### DON'T
3799
+
3800
+ - **Don't test Django internals**: Trust Django to work
3801
+ - **Don't test third-party code**: Trust libraries to work
3802
+ - **Don't ignore failing tests**: All tests must pass
3803
+ - **Don't make tests dependent**: Tests should run in any order
3804
+ - **Don't over-mock**: Mock only external dependencies
3805
+ - **Don't test private methods**: Test public interface
3806
+ - **Don't use production database**: Always use test database
3807
+
3808
+ ## Coverage
3809
+
3810
+ ### Coverage Configuration
3811
+
3812
+ ```bash
3813
+ # Run tests with coverage
3814
+ pytest --cov=apps --cov-report=html --cov-report=term-missing
3815
+
3816
+ # Generate HTML report
3817
+ open htmlcov/index.html
3818
+ ```
3819
+
3820
+ ### Coverage Goals
3821
+
3822
+ | Component | Target Coverage |
3823
+ |-----------|-----------------|
3824
+ | Models | 90%+ |
3825
+ | Serializers | 85%+ |
3826
+ | Views | 80%+ |
3827
+ | Services | 90%+ |
3828
+ | Utilities | 80%+ |
3829
+ | Overall | 80%+ |
3830
+
3831
+ ## Quick Reference
3832
+
3833
+ | Pattern | Usage |
3834
+ |---------|-------|
3835
+ | `@pytest.mark.django_db` | Enable database access |
3836
+ | `client` | Django test client |
3837
+ | `api_client` | DRF API client |
3838
+ | `factory.create_batch(n)` | Create multiple objects |
3839
+ | `patch('module.function')` | Mock external dependencies |
3840
+ | `override_settings` | Temporarily change settings |
3841
+ | `force_authenticate()` | Bypass authentication in tests |
3842
+ | `assertRedirects` | Check for redirects |
3843
+ | `assertTemplateUsed` | Verify template usage |
3844
+ | `mail.outbox` | Check sent emails |
42
3845
 
43
- - After an edit, lint with ruff when it is installed.
3846
+ Remember: Tests are documentation. Good tests explain how your code should work. Keep them simple, readable, and maintainable.