agentic-team-templates 0.12.1 → 0.13.1
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.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/index.js +9 -5
- package/src/index.test.js +2 -1
- package/templates/python-expert/.cursorrules/async-python.md +214 -0
- package/templates/python-expert/.cursorrules/overview.md +174 -0
- package/templates/python-expert/.cursorrules/patterns-and-idioms.md +251 -0
- package/templates/python-expert/.cursorrules/performance.md +208 -0
- package/templates/python-expert/.cursorrules/testing.md +238 -0
- package/templates/python-expert/.cursorrules/tooling.md +240 -0
- package/templates/python-expert/.cursorrules/type-system.md +203 -0
- package/templates/python-expert/.cursorrules/web-and-apis.md +231 -0
- package/templates/python-expert/CLAUDE.md +264 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# Python Patterns and Idioms
|
|
2
|
+
|
|
3
|
+
Idiomatic Python leverages the language's strengths — duck typing, protocols, generators, decorators, and the data model. Write Pythonic code, not Java-in-Python.
|
|
4
|
+
|
|
5
|
+
## Data Model (Dunder Methods)
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from functools import total_ordering
|
|
9
|
+
|
|
10
|
+
@total_ordering
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class Money:
|
|
13
|
+
amount: Decimal
|
|
14
|
+
currency: str
|
|
15
|
+
|
|
16
|
+
def __add__(self, other: Money) -> Money:
|
|
17
|
+
if self.currency != other.currency:
|
|
18
|
+
raise ValueError(f"Cannot add {self.currency} and {other.currency}")
|
|
19
|
+
return Money(self.amount + other.amount, self.currency)
|
|
20
|
+
|
|
21
|
+
def __eq__(self, other: object) -> bool:
|
|
22
|
+
if not isinstance(other, Money):
|
|
23
|
+
return NotImplemented
|
|
24
|
+
return self.amount == other.amount and self.currency == other.currency
|
|
25
|
+
|
|
26
|
+
def __lt__(self, other: Money) -> bool:
|
|
27
|
+
if self.currency != other.currency:
|
|
28
|
+
raise ValueError(f"Cannot compare {self.currency} and {other.currency}")
|
|
29
|
+
return self.amount < other.amount
|
|
30
|
+
|
|
31
|
+
def __str__(self) -> str:
|
|
32
|
+
return f"{self.currency} {self.amount:.2f}"
|
|
33
|
+
|
|
34
|
+
def __repr__(self) -> str:
|
|
35
|
+
return f"Money(amount={self.amount!r}, currency={self.currency!r})"
|
|
36
|
+
|
|
37
|
+
def __bool__(self) -> bool:
|
|
38
|
+
return self.amount != 0
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Generators and Iterators
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from collections.abc import Iterator, Generator
|
|
45
|
+
|
|
46
|
+
# Generators for lazy evaluation — process one item at a time
|
|
47
|
+
def read_large_file(path: Path) -> Iterator[str]:
|
|
48
|
+
with open(path) as f:
|
|
49
|
+
for line in f:
|
|
50
|
+
yield line.strip()
|
|
51
|
+
|
|
52
|
+
# Generator expressions over list comprehensions when you iterate once
|
|
53
|
+
total = sum(order.total for order in orders) # No intermediate list
|
|
54
|
+
|
|
55
|
+
# Generator pipelines
|
|
56
|
+
def pipeline(path: Path) -> Iterator[Record]:
|
|
57
|
+
lines = read_large_file(path)
|
|
58
|
+
parsed = (parse_record(line) for line in lines)
|
|
59
|
+
valid = (record for record in parsed if record.is_valid())
|
|
60
|
+
yield from valid
|
|
61
|
+
|
|
62
|
+
# itertools for complex iteration
|
|
63
|
+
from itertools import chain, islice, groupby, batched
|
|
64
|
+
|
|
65
|
+
# Process in batches (3.12+)
|
|
66
|
+
for batch in batched(items, 100):
|
|
67
|
+
process_batch(batch)
|
|
68
|
+
|
|
69
|
+
# Chain multiple iterables
|
|
70
|
+
for item in chain(list_a, list_b, list_c):
|
|
71
|
+
process(item)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Decorators
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from functools import wraps
|
|
78
|
+
from typing import ParamSpec, TypeVar
|
|
79
|
+
|
|
80
|
+
P = ParamSpec("P")
|
|
81
|
+
R = TypeVar("R")
|
|
82
|
+
|
|
83
|
+
# Properly typed decorator that preserves signatures
|
|
84
|
+
def retry(max_attempts: int = 3, delay: float = 1.0):
|
|
85
|
+
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
|
86
|
+
@wraps(func)
|
|
87
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
88
|
+
last_error: Exception | None = None
|
|
89
|
+
for attempt in range(max_attempts):
|
|
90
|
+
try:
|
|
91
|
+
return func(*args, **kwargs)
|
|
92
|
+
except Exception as e:
|
|
93
|
+
last_error = e
|
|
94
|
+
if attempt < max_attempts - 1:
|
|
95
|
+
time.sleep(delay * (2 ** attempt))
|
|
96
|
+
raise last_error # type: ignore[misc]
|
|
97
|
+
return wrapper
|
|
98
|
+
return decorator
|
|
99
|
+
|
|
100
|
+
@retry(max_attempts=3, delay=0.5)
|
|
101
|
+
def call_api(url: str) -> Response:
|
|
102
|
+
...
|
|
103
|
+
|
|
104
|
+
# Class-based decorator for complex state
|
|
105
|
+
class CachedProperty:
|
|
106
|
+
"""Descriptor that caches the result of a method call."""
|
|
107
|
+
def __init__(self, func: Callable[..., Any]) -> None:
|
|
108
|
+
self.func = func
|
|
109
|
+
self.attrname: str | None = None
|
|
110
|
+
|
|
111
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
112
|
+
self.attrname = name
|
|
113
|
+
|
|
114
|
+
def __get__(self, obj: Any, objtype: type | None = None) -> Any:
|
|
115
|
+
if obj is None:
|
|
116
|
+
return self
|
|
117
|
+
assert self.attrname is not None
|
|
118
|
+
cache = obj.__dict__
|
|
119
|
+
if self.attrname not in cache:
|
|
120
|
+
cache[self.attrname] = self.func(obj)
|
|
121
|
+
return cache[self.attrname]
|
|
122
|
+
|
|
123
|
+
# Or just use functools.cached_property — it's built in
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Context Managers
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from contextlib import contextmanager, asynccontextmanager
|
|
130
|
+
|
|
131
|
+
# Synchronous context manager
|
|
132
|
+
@contextmanager
|
|
133
|
+
def timed_operation(name: str) -> Iterator[None]:
|
|
134
|
+
start = time.monotonic()
|
|
135
|
+
try:
|
|
136
|
+
yield
|
|
137
|
+
finally:
|
|
138
|
+
elapsed = time.monotonic() - start
|
|
139
|
+
logger.info(f"{name} took {elapsed:.3f}s")
|
|
140
|
+
|
|
141
|
+
with timed_operation("database query"):
|
|
142
|
+
results = db.execute(query)
|
|
143
|
+
|
|
144
|
+
# Async context manager
|
|
145
|
+
@asynccontextmanager
|
|
146
|
+
async def managed_connection(url: str) -> AsyncIterator[Connection]:
|
|
147
|
+
conn = await connect(url)
|
|
148
|
+
try:
|
|
149
|
+
yield conn
|
|
150
|
+
finally:
|
|
151
|
+
await conn.close()
|
|
152
|
+
|
|
153
|
+
# contextlib.suppress for intentionally ignored exceptions
|
|
154
|
+
from contextlib import suppress
|
|
155
|
+
|
|
156
|
+
with suppress(FileNotFoundError):
|
|
157
|
+
os.remove(temp_file)
|
|
158
|
+
|
|
159
|
+
# ExitStack for dynamic resource management
|
|
160
|
+
from contextlib import ExitStack
|
|
161
|
+
|
|
162
|
+
with ExitStack() as stack:
|
|
163
|
+
files = [stack.enter_context(open(f)) for f in file_paths]
|
|
164
|
+
process_all(files)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Descriptors
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
# Descriptors power properties, classmethods, staticmethods
|
|
171
|
+
class Validated:
|
|
172
|
+
"""Descriptor that validates on assignment."""
|
|
173
|
+
def __init__(self, validator: Callable[[Any], bool], message: str) -> None:
|
|
174
|
+
self.validator = validator
|
|
175
|
+
self.message = message
|
|
176
|
+
self.attr_name = ""
|
|
177
|
+
|
|
178
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
179
|
+
self.attr_name = name
|
|
180
|
+
|
|
181
|
+
def __set__(self, obj: Any, value: Any) -> None:
|
|
182
|
+
if not self.validator(value):
|
|
183
|
+
raise ValueError(f"{self.attr_name}: {self.message}")
|
|
184
|
+
obj.__dict__[self.attr_name] = value
|
|
185
|
+
|
|
186
|
+
def __get__(self, obj: Any, objtype: type | None = None) -> Any:
|
|
187
|
+
if obj is None:
|
|
188
|
+
return self
|
|
189
|
+
return obj.__dict__.get(self.attr_name)
|
|
190
|
+
|
|
191
|
+
class User:
|
|
192
|
+
name = Validated(lambda v: isinstance(v, str) and len(v) > 0, "must be non-empty string")
|
|
193
|
+
age = Validated(lambda v: isinstance(v, int) and 0 <= v <= 150, "must be 0-150")
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## collections Module
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
from collections import defaultdict, Counter, deque, OrderedDict
|
|
200
|
+
|
|
201
|
+
# defaultdict — avoid KeyError boilerplate
|
|
202
|
+
word_counts: defaultdict[str, int] = defaultdict(int)
|
|
203
|
+
for word in words:
|
|
204
|
+
word_counts[word] += 1
|
|
205
|
+
|
|
206
|
+
# Counter — counting and most common
|
|
207
|
+
counter = Counter(words)
|
|
208
|
+
top_10 = counter.most_common(10)
|
|
209
|
+
|
|
210
|
+
# deque — O(1) append/pop from both ends
|
|
211
|
+
recent: deque[Event] = deque(maxlen=100)
|
|
212
|
+
recent.append(event) # Oldest auto-evicted when full
|
|
213
|
+
|
|
214
|
+
# Named tuples for lightweight immutable records (prefer dataclasses for new code)
|
|
215
|
+
from typing import NamedTuple
|
|
216
|
+
|
|
217
|
+
class Point(NamedTuple):
|
|
218
|
+
x: float
|
|
219
|
+
y: float
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Anti-Patterns
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
# Never: Mutable default arguments
|
|
226
|
+
def append(item, target=[]): # Shared across all calls!
|
|
227
|
+
target.append(item)
|
|
228
|
+
return target
|
|
229
|
+
|
|
230
|
+
# Never: Bare except
|
|
231
|
+
try:
|
|
232
|
+
risky()
|
|
233
|
+
except: # Catches KeyboardInterrupt, SystemExit — everything
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
# Never: String formatting with % or .format() for f-string-eligible code
|
|
237
|
+
name = "world"
|
|
238
|
+
greeting = f"hello, {name}" # Not "hello, %s" % name
|
|
239
|
+
|
|
240
|
+
# Never: Using type() for type checks
|
|
241
|
+
if type(x) == int: # Fails for subclasses
|
|
242
|
+
...
|
|
243
|
+
if isinstance(x, int): # Correct
|
|
244
|
+
...
|
|
245
|
+
|
|
246
|
+
# Never: Global mutable state
|
|
247
|
+
_cache = {} # Module-level mutable dict — test nightmare
|
|
248
|
+
|
|
249
|
+
# Never: Star imports
|
|
250
|
+
from os.path import * # Pollutes namespace, hides origins
|
|
251
|
+
```
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# Python Performance
|
|
2
|
+
|
|
3
|
+
Python is not the fastest language. It doesn't need to be. Know where the bottlenecks are, profile before optimizing, and reach for the right tool when raw speed matters.
|
|
4
|
+
|
|
5
|
+
## Profile First
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
# cProfile for function-level profiling
|
|
9
|
+
import cProfile
|
|
10
|
+
cProfile.run("main()", sort="cumulative")
|
|
11
|
+
|
|
12
|
+
# line_profiler for line-by-line analysis
|
|
13
|
+
# @profile decorator (after pip install line-profiler)
|
|
14
|
+
# kernprof -l -v script.py
|
|
15
|
+
|
|
16
|
+
# memory_profiler for memory usage
|
|
17
|
+
# @profile decorator (after pip install memory-profiler)
|
|
18
|
+
# python -m memory_profiler script.py
|
|
19
|
+
|
|
20
|
+
# py-spy for production profiling without code changes
|
|
21
|
+
# py-spy record -o profile.svg -- python myapp.py
|
|
22
|
+
|
|
23
|
+
# timeit for micro-benchmarks
|
|
24
|
+
import timeit
|
|
25
|
+
timeit.timeit("sorted(data)", globals={"data": list(range(1000))}, number=10000)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Data Structures
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
# Choose the right data structure for the access pattern
|
|
32
|
+
|
|
33
|
+
# dict — O(1) lookup, insertion, deletion
|
|
34
|
+
# Use when: key-value mapping with frequent lookups
|
|
35
|
+
cache: dict[str, Result] = {}
|
|
36
|
+
|
|
37
|
+
# set — O(1) membership testing
|
|
38
|
+
# Use when: uniqueness checks, set operations
|
|
39
|
+
seen: set[str] = set()
|
|
40
|
+
if item_id not in seen:
|
|
41
|
+
process(item)
|
|
42
|
+
seen.add(item_id)
|
|
43
|
+
|
|
44
|
+
# deque — O(1) append/pop from both ends
|
|
45
|
+
# Use when: queue, sliding window, bounded history
|
|
46
|
+
from collections import deque
|
|
47
|
+
recent_events: deque[Event] = deque(maxlen=1000)
|
|
48
|
+
|
|
49
|
+
# heapq — O(log n) push/pop for priority queue
|
|
50
|
+
# Use when: top-N, scheduling, priority processing
|
|
51
|
+
import heapq
|
|
52
|
+
top_10 = heapq.nlargest(10, items, key=lambda x: x.score)
|
|
53
|
+
|
|
54
|
+
# bisect — O(log n) search in sorted lists
|
|
55
|
+
# Use when: maintaining sorted order with insertions
|
|
56
|
+
import bisect
|
|
57
|
+
bisect.insort(sorted_list, new_item)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Avoiding Common Bottlenecks
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
# String concatenation — use join, not +=
|
|
64
|
+
# Bad: O(n²) — creates new string each iteration
|
|
65
|
+
result = ""
|
|
66
|
+
for chunk in chunks:
|
|
67
|
+
result += chunk
|
|
68
|
+
|
|
69
|
+
# Good: O(n)
|
|
70
|
+
result = "".join(chunks)
|
|
71
|
+
|
|
72
|
+
# List comprehensions over loops for simple transforms
|
|
73
|
+
# Good: Faster due to C-level optimization
|
|
74
|
+
squares = [x * x for x in range(1000)]
|
|
75
|
+
|
|
76
|
+
# Generator expressions when you don't need the full list
|
|
77
|
+
total = sum(x * x for x in range(1_000_000)) # No intermediate list
|
|
78
|
+
|
|
79
|
+
# dict.get() over try/except for missing keys (in tight loops)
|
|
80
|
+
value = mapping.get(key, default)
|
|
81
|
+
|
|
82
|
+
# Local variable lookup is faster than global/attribute
|
|
83
|
+
# In hot loops, alias frequently accessed attributes
|
|
84
|
+
append = result.append # Local lookup in loop body
|
|
85
|
+
for item in items:
|
|
86
|
+
append(transform(item))
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Concurrency Models
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
# I/O-bound: asyncio or threading
|
|
93
|
+
# CPU-bound: multiprocessing or native extensions
|
|
94
|
+
|
|
95
|
+
# Threading for I/O parallelism (GIL is released during I/O)
|
|
96
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
97
|
+
|
|
98
|
+
with ThreadPoolExecutor(max_workers=10) as executor:
|
|
99
|
+
results = list(executor.map(fetch_url, urls))
|
|
100
|
+
|
|
101
|
+
# Multiprocessing for CPU parallelism (bypasses GIL)
|
|
102
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
103
|
+
|
|
104
|
+
with ProcessPoolExecutor() as executor:
|
|
105
|
+
results = list(executor.map(cpu_intensive_work, data_chunks))
|
|
106
|
+
|
|
107
|
+
# asyncio for high-concurrency I/O (thousands of connections)
|
|
108
|
+
# See async-python.md for patterns
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Caching
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from functools import lru_cache, cache
|
|
115
|
+
|
|
116
|
+
# lru_cache for expensive pure function calls
|
|
117
|
+
@lru_cache(maxsize=256)
|
|
118
|
+
def fibonacci(n: int) -> int:
|
|
119
|
+
if n < 2:
|
|
120
|
+
return n
|
|
121
|
+
return fibonacci(n - 1) + fibonacci(n - 2)
|
|
122
|
+
|
|
123
|
+
# cache (3.9+) — unlimited cache, simpler API
|
|
124
|
+
@cache
|
|
125
|
+
def load_schema(name: str) -> Schema:
|
|
126
|
+
return Schema.from_file(f"schemas/{name}.json")
|
|
127
|
+
|
|
128
|
+
# Manual caching with TTL
|
|
129
|
+
from time import monotonic
|
|
130
|
+
|
|
131
|
+
class TTLCache:
|
|
132
|
+
def __init__(self, ttl: float) -> None:
|
|
133
|
+
self._cache: dict[str, tuple[float, Any]] = {}
|
|
134
|
+
self._ttl = ttl
|
|
135
|
+
|
|
136
|
+
def get(self, key: str) -> Any | None:
|
|
137
|
+
if key in self._cache:
|
|
138
|
+
expiry, value = self._cache[key]
|
|
139
|
+
if monotonic() < expiry:
|
|
140
|
+
return value
|
|
141
|
+
del self._cache[key]
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
def set(self, key: str, value: Any) -> None:
|
|
145
|
+
self._cache[key] = (monotonic() + self._ttl, value)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Slots and Memory
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
# __slots__ reduces memory per instance and speeds up attribute access
|
|
152
|
+
@dataclass(slots=True)
|
|
153
|
+
class Point:
|
|
154
|
+
x: float
|
|
155
|
+
y: float
|
|
156
|
+
|
|
157
|
+
# Without slots: ~200 bytes per instance (dict overhead)
|
|
158
|
+
# With slots: ~56 bytes per instance
|
|
159
|
+
# Matters when you have millions of instances
|
|
160
|
+
|
|
161
|
+
# sys.getsizeof for memory inspection
|
|
162
|
+
import sys
|
|
163
|
+
sys.getsizeof(my_object)
|
|
164
|
+
|
|
165
|
+
# tracemalloc for tracking allocations
|
|
166
|
+
import tracemalloc
|
|
167
|
+
tracemalloc.start()
|
|
168
|
+
# ... run code ...
|
|
169
|
+
snapshot = tracemalloc.take_snapshot()
|
|
170
|
+
for stat in snapshot.statistics("lineno")[:10]:
|
|
171
|
+
print(stat)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## When Python Isn't Fast Enough
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
# 1. NumPy/Pandas for vectorized numeric operations
|
|
178
|
+
import numpy as np
|
|
179
|
+
result = np.sum(array * weights) # C-level loop, orders of magnitude faster
|
|
180
|
+
|
|
181
|
+
# 2. Polars for DataFrames (faster than Pandas, Rust backend)
|
|
182
|
+
import polars as pl
|
|
183
|
+
df.filter(pl.col("age") > 30).group_by("city").agg(pl.col("salary").mean())
|
|
184
|
+
|
|
185
|
+
# 3. Cython or mypyc for compiling Python to C
|
|
186
|
+
# 4. PyO3/maturin for writing critical paths in Rust
|
|
187
|
+
# 5. ctypes/cffi for calling existing C libraries
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Anti-Patterns
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
# Never: Premature optimization
|
|
194
|
+
# Profile first. The bottleneck is almost never where you think.
|
|
195
|
+
|
|
196
|
+
# Never: Optimizing readability away
|
|
197
|
+
# A 10% speedup that makes code unmaintainable is a net loss.
|
|
198
|
+
|
|
199
|
+
# Never: Global imports of large modules in hot paths
|
|
200
|
+
def process():
|
|
201
|
+
import pandas as pd # Import overhead on every call
|
|
202
|
+
# Import at module level instead
|
|
203
|
+
|
|
204
|
+
# Never: Creating regex objects in loops
|
|
205
|
+
for line in lines:
|
|
206
|
+
match = re.search(r"pattern", line) # Recompiles every iteration
|
|
207
|
+
# Compile once: pattern = re.compile(r"pattern")
|
|
208
|
+
```
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# Python Testing
|
|
2
|
+
|
|
3
|
+
pytest is the standard. Tests are not optional. Every behavior has a test. Every bug fix starts with a failing test.
|
|
4
|
+
|
|
5
|
+
## Fundamentals
|
|
6
|
+
|
|
7
|
+
### Test Structure
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
# Arrange-Act-Assert
|
|
11
|
+
def test_user_creation() -> None:
|
|
12
|
+
# Arrange
|
|
13
|
+
repo = FakeUserRepo()
|
|
14
|
+
service = UserService(repo)
|
|
15
|
+
input_data = CreateUserInput(name="Alice", email="alice@example.com")
|
|
16
|
+
|
|
17
|
+
# Act
|
|
18
|
+
user = service.create(input_data)
|
|
19
|
+
|
|
20
|
+
# Assert
|
|
21
|
+
assert user.name == "Alice"
|
|
22
|
+
assert user.email == "alice@example.com"
|
|
23
|
+
assert user.id is not None
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Naming
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
# Test names describe the scenario and expected outcome
|
|
30
|
+
def test_create_user_with_valid_input_returns_user() -> None: ...
|
|
31
|
+
def test_create_user_with_duplicate_email_raises_conflict() -> None: ...
|
|
32
|
+
def test_create_user_with_empty_name_raises_validation_error() -> None: ...
|
|
33
|
+
|
|
34
|
+
# Not:
|
|
35
|
+
def test_create() -> None: ... # Create what? What scenario?
|
|
36
|
+
def test_1() -> None: ... # Meaningless
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Fixtures
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
import pytest
|
|
43
|
+
|
|
44
|
+
@pytest.fixture
|
|
45
|
+
def user_repo() -> FakeUserRepo:
|
|
46
|
+
return FakeUserRepo()
|
|
47
|
+
|
|
48
|
+
@pytest.fixture
|
|
49
|
+
def user_service(user_repo: FakeUserRepo) -> UserService:
|
|
50
|
+
return UserService(user_repo)
|
|
51
|
+
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def sample_user() -> User:
|
|
54
|
+
return User(id="123", name="Alice", email="alice@example.com")
|
|
55
|
+
|
|
56
|
+
# Fixtures with cleanup
|
|
57
|
+
@pytest.fixture
|
|
58
|
+
def temp_db():
|
|
59
|
+
db = create_test_database()
|
|
60
|
+
yield db
|
|
61
|
+
db.drop()
|
|
62
|
+
|
|
63
|
+
# Session-scoped fixtures for expensive setup
|
|
64
|
+
@pytest.fixture(scope="session")
|
|
65
|
+
def docker_postgres():
|
|
66
|
+
container = start_postgres_container()
|
|
67
|
+
yield container.connection_string
|
|
68
|
+
container.stop()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Parametrize
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
@pytest.mark.parametrize(
|
|
75
|
+
"input_str, expected",
|
|
76
|
+
[
|
|
77
|
+
("hello world", "hello-world"),
|
|
78
|
+
(" spaces ", "spaces"),
|
|
79
|
+
("UPPER CASE", "upper-case"),
|
|
80
|
+
("special!@#chars", "specialchars"),
|
|
81
|
+
("already-slugified", "already-slugified"),
|
|
82
|
+
("", ""),
|
|
83
|
+
],
|
|
84
|
+
)
|
|
85
|
+
def test_slugify(input_str: str, expected: str) -> None:
|
|
86
|
+
assert slugify(input_str) == expected
|
|
87
|
+
|
|
88
|
+
# Parametrize with IDs for clear test output
|
|
89
|
+
@pytest.mark.parametrize(
|
|
90
|
+
"status_code, expected_error",
|
|
91
|
+
[
|
|
92
|
+
pytest.param(400, ValidationError, id="bad-request"),
|
|
93
|
+
pytest.param(404, NotFoundError, id="not-found"),
|
|
94
|
+
pytest.param(500, ServerError, id="server-error"),
|
|
95
|
+
],
|
|
96
|
+
)
|
|
97
|
+
def test_error_mapping(status_code: int, expected_error: type[Exception]) -> None:
|
|
98
|
+
with pytest.raises(expected_error):
|
|
99
|
+
handle_response(MockResponse(status_code))
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Testing Exceptions
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
def test_division_by_zero_raises_value_error() -> None:
|
|
106
|
+
with pytest.raises(ValueError, match="divisor must be non-zero"):
|
|
107
|
+
divide(10, 0)
|
|
108
|
+
|
|
109
|
+
def test_missing_config_key_raises_key_error() -> None:
|
|
110
|
+
config = Config({})
|
|
111
|
+
with pytest.raises(KeyError) as exc_info:
|
|
112
|
+
config.get_required("missing_key")
|
|
113
|
+
assert "missing_key" in str(exc_info.value)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Mocking
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from unittest.mock import Mock, AsyncMock, patch, MagicMock
|
|
120
|
+
|
|
121
|
+
# Prefer dependency injection over patching
|
|
122
|
+
# Good: Inject the dependency
|
|
123
|
+
class UserService:
|
|
124
|
+
def __init__(self, repo: UserRepository, notifier: Notifier) -> None:
|
|
125
|
+
self.repo = repo
|
|
126
|
+
self.notifier = notifier
|
|
127
|
+
|
|
128
|
+
def test_create_user_sends_notification() -> None:
|
|
129
|
+
repo = FakeUserRepo()
|
|
130
|
+
notifier = Mock(spec=Notifier)
|
|
131
|
+
|
|
132
|
+
service = UserService(repo, notifier)
|
|
133
|
+
service.create(CreateUserInput(name="Alice", email="a@b.com"))
|
|
134
|
+
|
|
135
|
+
notifier.send_welcome.assert_called_once_with("a@b.com")
|
|
136
|
+
|
|
137
|
+
# When you must patch (third-party code, module-level functions)
|
|
138
|
+
@patch("mypackage.services.user_service.send_email")
|
|
139
|
+
def test_sends_email_on_signup(mock_send: Mock) -> None:
|
|
140
|
+
service = UserService(FakeUserRepo())
|
|
141
|
+
service.signup(email="test@example.com")
|
|
142
|
+
mock_send.assert_called_once_with(to="test@example.com", template="welcome")
|
|
143
|
+
|
|
144
|
+
# Async mocking
|
|
145
|
+
async def test_async_fetch() -> None:
|
|
146
|
+
client = AsyncMock(spec=HttpClient)
|
|
147
|
+
client.get.return_value = Response(status=200, body=b'{"ok": true}')
|
|
148
|
+
|
|
149
|
+
result = await fetch_data(client, "/api/data")
|
|
150
|
+
assert result == {"ok": True}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Integration Testing
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
import pytest
|
|
157
|
+
from httpx import AsyncClient
|
|
158
|
+
|
|
159
|
+
@pytest.fixture
|
|
160
|
+
async def app():
|
|
161
|
+
"""Create test application with test database."""
|
|
162
|
+
test_app = create_app(testing=True)
|
|
163
|
+
async with test_app.lifespan():
|
|
164
|
+
yield test_app
|
|
165
|
+
|
|
166
|
+
@pytest.fixture
|
|
167
|
+
async def client(app) -> AsyncClient:
|
|
168
|
+
async with AsyncClient(app=app, base_url="http://test") as ac:
|
|
169
|
+
yield ac
|
|
170
|
+
|
|
171
|
+
@pytest.mark.asyncio
|
|
172
|
+
async def test_create_and_retrieve_user(client: AsyncClient) -> None:
|
|
173
|
+
# Create
|
|
174
|
+
response = await client.post("/users", json={"name": "Alice", "email": "a@b.com"})
|
|
175
|
+
assert response.status_code == 201
|
|
176
|
+
user_id = response.json()["id"]
|
|
177
|
+
|
|
178
|
+
# Retrieve
|
|
179
|
+
response = await client.get(f"/users/{user_id}")
|
|
180
|
+
assert response.status_code == 200
|
|
181
|
+
assert response.json()["name"] == "Alice"
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Snapshot Testing
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
# Using syrupy or inline-snapshot
|
|
188
|
+
def test_api_response_format(snapshot) -> None:
|
|
189
|
+
response = generate_api_response(sample_data)
|
|
190
|
+
assert response == snapshot
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## conftest.py Patterns
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
# tests/conftest.py — shared across all tests
|
|
197
|
+
import pytest
|
|
198
|
+
|
|
199
|
+
@pytest.fixture(autouse=True)
|
|
200
|
+
def _reset_singletons():
|
|
201
|
+
"""Reset module-level singletons between tests."""
|
|
202
|
+
yield
|
|
203
|
+
SingletonClass._instance = None
|
|
204
|
+
|
|
205
|
+
@pytest.fixture
|
|
206
|
+
def freeze_time():
|
|
207
|
+
"""Fixture that freezes time for deterministic tests."""
|
|
208
|
+
from freezegun import freeze_time
|
|
209
|
+
with freeze_time("2025-01-01T12:00:00Z") as frozen:
|
|
210
|
+
yield frozen
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Anti-Patterns
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
# Never: Tests that depend on execution order
|
|
217
|
+
# Each test must be independently runnable
|
|
218
|
+
|
|
219
|
+
# Never: Assertions without messages in complex tests
|
|
220
|
+
assert result # What is result? What did we expect?
|
|
221
|
+
# Better:
|
|
222
|
+
assert result.status == "active", f"Expected active, got {result.status}"
|
|
223
|
+
|
|
224
|
+
# Never: Testing implementation details
|
|
225
|
+
assert service._internal_cache == {"key": "value"} # Private API!
|
|
226
|
+
# Test through the public interface
|
|
227
|
+
|
|
228
|
+
# Never: Excessive mocking
|
|
229
|
+
# If you're mocking 5+ dependencies, the unit is too large — refactor
|
|
230
|
+
|
|
231
|
+
# Never: time.sleep() for synchronization in tests
|
|
232
|
+
time.sleep(1) # Slow AND flaky
|
|
233
|
+
# Use polling, events, or mock the clock
|
|
234
|
+
|
|
235
|
+
# Never: Tests without assertions
|
|
236
|
+
def test_user_creation() -> None:
|
|
237
|
+
service.create_user(data) # What are we verifying? Nothing.
|
|
238
|
+
```
|