|
| 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