Skip to content

Commit 6c982e6

Browse files
Reject SDK @deprecated on FastAPI routes (#2424)
Co-authored-by: openhands <openhands@all-hands.dev>
1 parent a6f5e46 commit 6c982e6

File tree

3 files changed

+174
-4
lines changed

3 files changed

+174
-4
lines changed

.github/scripts/check_agent_server_rest_api_breakage.py

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
1010
Policies enforced:
1111
12-
1) Deprecation metadata for REST endpoints
12+
1) REST deprecations must use FastAPI/OpenAPI metadata
13+
- FastAPI route handlers must not use `openhands.sdk.utils.deprecation.deprecated`.
1314
- Endpoints documented as deprecated in their OpenAPI description must also be
1415
marked `deprecated: true` in the generated schema.
1516
@@ -27,6 +28,7 @@
2728

2829
from __future__ import annotations
2930

31+
import ast
3032
import json
3133
import subprocess
3234
import sys
@@ -51,6 +53,7 @@
5153
"head",
5254
"trace",
5355
}
56+
ROUTE_DECORATOR_NAMES = HTTP_METHODS | {"api_route"}
5457
OPENAPI_PROGRAM = """
5558
import json
5659
import sys
@@ -173,6 +176,96 @@ def _generate_openapi_for_git_ref(git_ref: str) -> dict | None:
173176
return _generate_openapi_from_source_tree(source_tree, git_ref)
174177

175178

179+
def _dotted_name(node: ast.AST) -> str | None:
180+
if isinstance(node, ast.Name):
181+
return node.id
182+
if isinstance(node, ast.Attribute):
183+
prefix = _dotted_name(node.value)
184+
if prefix is None:
185+
return None
186+
return f"{prefix}.{node.attr}"
187+
return None
188+
189+
190+
def _find_sdk_deprecated_fastapi_routes_in_file(
191+
file_path: Path, repo_root: Path
192+
) -> list[str]:
193+
tree = ast.parse(file_path.read_text(), filename=str(file_path))
194+
195+
deprecated_names: set[str] = set()
196+
deprecation_module_names: set[str] = set()
197+
198+
for node in tree.body:
199+
if isinstance(node, ast.ImportFrom):
200+
if node.module == "openhands.sdk.utils.deprecation":
201+
for alias in node.names:
202+
if alias.name == "deprecated":
203+
deprecated_names.add(alias.asname or alias.name)
204+
elif node.module == "openhands.sdk.utils":
205+
for alias in node.names:
206+
if alias.name == "deprecation":
207+
deprecation_module_names.add(alias.asname or alias.name)
208+
elif isinstance(node, ast.Import):
209+
for alias in node.names:
210+
if alias.name == "openhands.sdk.utils.deprecation":
211+
deprecation_module_names.add(alias.asname or alias.name)
212+
213+
errors: list[str] = []
214+
for node in ast.walk(tree):
215+
if not isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
216+
continue
217+
218+
has_route_decorator = False
219+
uses_sdk_deprecated = False
220+
221+
for decorator in node.decorator_list:
222+
if not isinstance(decorator, ast.Call):
223+
continue
224+
225+
dotted_name = _dotted_name(decorator.func)
226+
if (
227+
isinstance(decorator.func, ast.Attribute)
228+
and decorator.func.attr in ROUTE_DECORATOR_NAMES
229+
):
230+
has_route_decorator = True
231+
232+
if dotted_name in deprecated_names or (
233+
dotted_name == "openhands.sdk.utils.deprecation.deprecated"
234+
):
235+
uses_sdk_deprecated = True
236+
continue
237+
238+
if (
239+
isinstance(decorator.func, ast.Attribute)
240+
and decorator.func.attr == "deprecated"
241+
):
242+
base_name = _dotted_name(decorator.func.value)
243+
if base_name in deprecation_module_names or (
244+
base_name == "openhands.sdk.utils.deprecation"
245+
):
246+
uses_sdk_deprecated = True
247+
248+
if has_route_decorator and uses_sdk_deprecated:
249+
rel_path = file_path.relative_to(repo_root)
250+
errors.append(
251+
f"{rel_path}:{node.lineno} FastAPI route `{node.name}` uses "
252+
"openhands.sdk.utils.deprecation.deprecated; use the route "
253+
"decorator's deprecated=True flag instead."
254+
)
255+
256+
return errors
257+
258+
259+
def _find_sdk_deprecated_fastapi_routes(repo_root: Path) -> list[str]:
260+
app_root = repo_root / "openhands-agent-server" / "openhands" / "agent_server"
261+
errors: list[str] = []
262+
263+
for file_path in sorted(app_root.rglob("*.py")):
264+
errors.extend(_find_sdk_deprecated_fastapi_routes_in_file(file_path, repo_root))
265+
266+
return errors
267+
268+
176269
def _find_deprecation_policy_errors(schema: dict) -> list[str]:
177270
errors: list[str] = []
178271

@@ -301,6 +394,10 @@ def main() -> int:
301394

302395
baseline_git_ref = f"v{baseline_version}"
303396

397+
static_policy_errors = _find_sdk_deprecated_fastapi_routes(REPO_ROOT)
398+
for error in static_policy_errors:
399+
print(f"::error title={PYPI_DISTRIBUTION} REST API::{error}")
400+
304401
current_schema = _generate_current_openapi()
305402
if current_schema is None:
306403
return 1
@@ -311,7 +408,7 @@ def main() -> int:
311408

312409
prev_schema = _generate_openapi_for_git_ref(baseline_git_ref)
313410
if prev_schema is None:
314-
return 0 if not deprecation_policy_errors else 1
411+
return 0 if not (static_policy_errors or deprecation_policy_errors) else 1
315412

316413
prev_schema = _normalize_openapi_for_oasdiff(prev_schema)
317414
current_schema = _normalize_openapi_for_oasdiff(current_schema)
@@ -380,7 +477,7 @@ def main() -> int:
380477
):
381478
return 1
382479

383-
return 1 if deprecation_policy_errors else 0
480+
return 1 if (static_policy_errors or deprecation_policy_errors) else 0
384481

385482

386483
if __name__ == "__main__":

openhands-agent-server/AGENTS.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ When deprecating a REST endpoint:
2828
2. Add a docstring note that includes:
2929
- the version it was deprecated in
3030
- the version it is scheduled for removal in (default: **3 minor releases** later)
31+
3. Do **not** use `openhands.sdk.utils.deprecation.deprecated` for FastAPI routes.
32+
That decorator affects Python warnings/docstrings, not OpenAPI, and may be a
33+
no-op before the declared deprecation version.
3134

3235
Example:
3336

@@ -50,9 +53,14 @@ Removing an endpoint is a breaking change.
5053
### CI enforcement
5154

5255
The workflow `Agent server REST API breakage checks` compares the current OpenAPI
53-
schema against the previous `openhands-agent-server` release on PyPI using [oasdiff](https://github.com/oasdiff/oasdiff).
56+
schema against the previous `openhands-agent-server` release selected from PyPI,
57+
but generates the baseline schema from the matching git tag under the current
58+
workspace dependency set before diffing with [oasdiff](https://github.com/oasdiff/oasdiff).
5459

5560
It currently enforces:
61+
- FastAPI route handlers must not use `openhands.sdk.utils.deprecation.deprecated`.
62+
- Endpoints that document deprecation in their OpenAPI description must also set
63+
`deprecated: true`.
5664
- No removal of operations (path + method) unless they were already marked
5765
`deprecated: true` in the previous release.
5866
- Breaking changes require a MINOR (or MAJOR) version bump.

tests/ci_scripts/test_check_agent_server_rest_api_breakage.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ def _load_prod_module():
2424
_prod = _load_prod_module()
2525

2626
_find_deprecation_policy_errors = _prod._find_deprecation_policy_errors
27+
_find_sdk_deprecated_fastapi_routes_in_file = (
28+
_prod._find_sdk_deprecated_fastapi_routes_in_file
29+
)
2730
_get_baseline_version = _prod._get_baseline_version
2831
_is_minor_or_major_bump = _prod._is_minor_or_major_bump
2932
_normalize_openapi_for_oasdiff = _prod._normalize_openapi_for_oasdiff
@@ -87,6 +90,68 @@ def test_find_deprecation_policy_errors_ignores_non_deprecated_operations():
8790
assert _find_deprecation_policy_errors(schema) == []
8891

8992

93+
def test_find_sdk_deprecated_fastapi_routes_in_file_flags_direct_import(tmp_path):
94+
repo_root = tmp_path
95+
source = repo_root / "openhands-agent-server" / "openhands" / "agent_server"
96+
source.mkdir(parents=True)
97+
file_path = source / "router.py"
98+
file_path.write_text(
99+
"from openhands.sdk.utils.deprecation import deprecated\n"
100+
"\n"
101+
'@router.get("/foo")\n'
102+
'@deprecated(deprecated_in="1.0.0", removed_in="1.1.0")\n'
103+
"async def foo():\n"
104+
" return {}\n"
105+
)
106+
107+
errors = _find_sdk_deprecated_fastapi_routes_in_file(file_path, repo_root)
108+
109+
assert errors == [
110+
"openhands-agent-server/openhands/agent_server/router.py:5 FastAPI route "
111+
"`foo` uses openhands.sdk.utils.deprecation.deprecated; use the route "
112+
"decorator's deprecated=True flag instead."
113+
]
114+
115+
116+
def test_find_sdk_deprecated_fastapi_routes_in_file_flags_alias_import(tmp_path):
117+
repo_root = tmp_path
118+
source = repo_root / "openhands-agent-server" / "openhands" / "agent_server"
119+
source.mkdir(parents=True)
120+
file_path = source / "router.py"
121+
file_path.write_text(
122+
"import openhands.sdk.utils.deprecation as dep\n"
123+
"\n"
124+
'@router.post("/foo")\n'
125+
'@dep.deprecated(deprecated_in="1.0.0", removed_in="1.1.0")\n'
126+
"async def foo():\n"
127+
" return {}\n"
128+
)
129+
130+
errors = _find_sdk_deprecated_fastapi_routes_in_file(file_path, repo_root)
131+
132+
assert errors == [
133+
"openhands-agent-server/openhands/agent_server/router.py:5 FastAPI route "
134+
"`foo` uses openhands.sdk.utils.deprecation.deprecated; use the route "
135+
"decorator's deprecated=True flag instead."
136+
]
137+
138+
139+
def test_find_sdk_deprecated_fastapi_routes_in_file_ignores_non_route_usage(tmp_path):
140+
repo_root = tmp_path
141+
source = repo_root / "openhands-agent-server" / "openhands" / "agent_server"
142+
source.mkdir(parents=True)
143+
file_path = source / "helpers.py"
144+
file_path.write_text(
145+
"from openhands.sdk.utils.deprecation import deprecated\n"
146+
"\n"
147+
'@deprecated(deprecated_in="1.0.0", removed_in="1.1.0")\n'
148+
"def helper():\n"
149+
" return None\n"
150+
)
151+
152+
assert _find_sdk_deprecated_fastapi_routes_in_file(file_path, repo_root) == []
153+
154+
90155
def test_get_baseline_version_warns_and_returns_none_when_pypi_fails(
91156
monkeypatch, capsys
92157
):

0 commit comments

Comments
 (0)