Skip to content

Commit 87cd55a

Browse files
committed
docs(dev): document the field/API deprecation standard
Add a developer-guide page defining the house pattern for deprecating fields, config keys, parameters, and APIs: match the signal channel to the audience. Operator-facing surfaces (Pydantic config/REST) use a model_validator(mode="before") + logger.warning + Field(deprecated=); developer-facing surfaces (SDK/Python) use warnings.warn(DeprecationWarning). Explains why warnings.warn is invisible on operator config paths (the repo's filterwarnings suppresses it and operators never pass -W).
1 parent e17a108 commit 87cd55a

2 files changed

Lines changed: 151 additions & 0 deletions

File tree

docs/mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ nav:
136136
- Performance Optimization: developer_guide/performance-optimization.md
137137
- Development Setup: developer_guide/development.md
138138
- BaseSetting Providers: developer_guide/basesettings-providers.md
139+
- Deprecating Fields & APIs: developer_guide/deprecation.md
139140
- Changelog Management: developer_guide/changelog_management.md
140141
- Releases: developer_guide/releases.md
141142
- Testing: developer_guide/testing.md
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Deprecating Fields, Parameters, and APIs
2+
3+
How to deprecate a field, config key, parameter, method, or endpoint so the
4+
deprecation signal actually reaches the people who need to see it.
5+
6+
## The core principle: match the signal to the audience
7+
8+
A deprecation is only useful if the person still using the old thing finds
9+
out. The right mechanism depends entirely on **who** consumes the surface:
10+
11+
| Audience | Surface | Correct signal |
12+
|----------|---------|----------------|
13+
| **Operators** | Config YAML/JSON, REST request bodies (anything deserialized via Pydantic `model_validate`) | `logger.warning` (+ `Field(deprecated=)` for schema) |
14+
| **Operators** | Config keys detected in a loaded dict at startup | `logger.warning` |
15+
| **Developers** | Python/SDK API — constructors, `@property`, classmethods, functions called from Python | `warnings.warn(..., DeprecationWarning, stacklevel=2)` |
16+
17+
### Why `warnings.warn` is wrong for operator-facing surfaces
18+
19+
`DeprecationWarning` is designed for **library consumers** — it surfaces via
20+
test runners, linters, and `python -W all`. It does **not** reach operators:
21+
22+
- Operators run the packaged server; they never pass `-W`.
23+
- `pyproject.toml` sets `filterwarnings = ["ignore::DeprecationWarning"]`, so
24+
the warning is suppressed even in our own test suite.
25+
- A `DeprecationWarning` on a config-file load path is effectively silent in
26+
production and in CI.
27+
28+
Operators read **logs**. So an operator-facing deprecation must emit a
29+
`logger.warning`.
30+
31+
## Pattern 1 — Operator-facing Pydantic field deprecation (the common case)
32+
33+
When you rename a field on a Pydantic model that operators populate via config
34+
or REST (e.g. `instance_type``machine_type`):
35+
36+
```python
37+
import logging
38+
39+
from pydantic import AliasChoices, BaseModel, Field, model_validator
40+
41+
logger = logging.getLogger(__name__)
42+
43+
44+
class Template(BaseModel):
45+
# 1. AliasChoices keeps old data deserialising.
46+
# 2. Field(deprecated=...) surfaces `deprecated: true` in the OpenAPI /
47+
# JSON schema so schema tooling and API docs flag it.
48+
machine_type: Optional[str] = Field(
49+
default=None,
50+
validation_alias=AliasChoices("machine_type", "instance_type"),
51+
)
52+
53+
# 3. A model_validator(mode="before") runs on the RAW input dict on EVERY
54+
# entry path — __init__ kwargs AND model_validate()/YAML — so the
55+
# logger.warning fires no matter how the object was built. This is the
56+
# load-bearing part: it closes the gap AliasChoices alone leaves open.
57+
@model_validator(mode="before")
58+
@classmethod
59+
def _warn_deprecated_aliases(cls, data: Any) -> Any:
60+
if isinstance(data, dict):
61+
if "instance_type" in data and "machine_type" not in data:
62+
logger.warning(
63+
"Template field 'instance_type' is deprecated; "
64+
"use 'machine_type' instead."
65+
)
66+
return data
67+
```
68+
69+
`AliasChoices` alone is **not** enough — it accepts the old key *silently*.
70+
The `model_validator(mode="before")` is what makes the deprecation visible.
71+
72+
## Pattern 2 — Operator-facing config-key deprecation
73+
74+
When a top-level config key is renamed or relocated and you detect it in a
75+
loaded dict at startup:
76+
77+
```python
78+
if "dynamodb_strategy" in storage_config:
79+
logger.warning(
80+
"Config key 'storage.dynamodb_strategy' is deprecated and will be "
81+
"removed in ORB 3.0; move it under "
82+
"provider.providers[N].config.storage.dynamodb."
83+
)
84+
# ... migrate the value ...
85+
```
86+
87+
Emit the `logger.warning` at the point of detection. A `warnings.warn` here is
88+
invisible to operators.
89+
90+
## Pattern 3 — Developer-facing Python/SDK deprecation
91+
92+
For a Python API surface consumed by developers (SDK constructors, properties,
93+
classmethods), `warnings.warn` is the **correct** tool — it is what developers'
94+
test suites and `python -W all` surface:
95+
96+
```python
97+
import warnings
98+
99+
100+
class SDKConfig:
101+
@property
102+
def region(self) -> Optional[str]:
103+
warnings.warn(
104+
"SDKConfig.region is deprecated and will be removed in the next "
105+
"major release; read provider_config['region'] instead.",
106+
DeprecationWarning,
107+
stacklevel=2,
108+
)
109+
return self.provider_config.get("region")
110+
```
111+
112+
Always pass `stacklevel=2` so the warning points at the *caller's* line, not
113+
the property body.
114+
115+
## Pattern 4 — Deprecated functions/methods with active callers
116+
117+
If a function is deprecated but still has callers, emit `warnings.warn` inside
118+
the body on every call:
119+
120+
```python
121+
def register_all_provider_types() -> None:
122+
warnings.warn(
123+
"register_all_provider_types() is deprecated; "
124+
"call register_all_providers() instead.",
125+
DeprecationWarning,
126+
stacklevel=2,
127+
)
128+
return register_all_providers()
129+
```
130+
131+
Only `raise NotImplementedError` when **zero** callers remain — a hard break is
132+
inappropriate while callers still exist.
133+
134+
## Always include a removal horizon
135+
136+
State when the deprecated surface will be removed (e.g. "removed in ORB 3.0" /
137+
"the next major release"). An open-ended deprecation gives no migration
138+
deadline and tends to live forever.
139+
140+
## Checklist
141+
142+
- [ ] Identified the audience: operator (config/REST) or developer (Python/SDK)?
143+
- [ ] Operator-facing → `logger.warning` on the deserialization / detection path
144+
- [ ] Operator-facing Pydantic field → `model_validator(mode="before")` +
145+
`AliasChoices` + `Field(deprecated=)`
146+
- [ ] Developer-facing → `warnings.warn(DeprecationWarning, stacklevel=2)`
147+
- [ ] Named the replacement in the message
148+
- [ ] Stated the removal version
149+
- [ ] Added a test asserting the signal fires (use `caplog` for `logger.warning`,
150+
`pytest.warns` for `DeprecationWarning`)

0 commit comments

Comments
 (0)