11#!/usr/bin/env python3
2- """SDK API breakage detection using Griffe.
2+ """API breakage detection for published OpenHands packages using Griffe.
33
4- This script compares the current workspace SDK against the previous PyPI release
5- to detect breaking changes in the public API. It focuses on symbols exported via
6- ``__all__`` in ``openhands.sdk `` and enforces two policies:
4+ This script compares current workspace packages against their previous PyPI
5+ releases to detect breaking changes in the public API. It focuses on symbols
6+ exported via ``__all__ `` and enforces two policies:
77
881. **Deprecation-before-removal** – any symbol removed from ``__all__`` must
9- have been marked deprecated in the *previous* release using the SDK's
10- canonical deprecation helpers (``@deprecated`` decorator or
11- ``warn_deprecated()`` call from ``openhands.sdk.utils.deprecation``).
9+ have been marked deprecated in the *previous* release using the canonical
10+ deprecation helpers (``@deprecated`` decorator or ``warn_deprecated()``
11+ call from ``openhands.sdk.utils.deprecation``).
1212
13132. **MINOR version bump** – any breaking change (removal or structural) requires
1414 at least a MINOR version bump according to SemVer.
2727import tomllib
2828import urllib .request
2929from collections .abc import Iterable
30+ from dataclasses import dataclass
3031from pathlib import Path
3132
3233from packaging import version as pkg_version
3334
3435
35- # Package configuration - centralized for maintainability
36- SDK_PACKAGE = "openhands.sdk"
37- DISTRIBUTION_NAME = "openhands-sdk"
38- PYPROJECT_RELATIVE_PATH = "openhands-sdk/pyproject.toml"
36+ @dataclass (frozen = True )
37+ class PackageConfig :
38+ """Configuration for a single published package."""
39+
40+ package : str # dotted module path, e.g. "openhands.sdk"
41+ distribution : str # PyPI distribution name, e.g. "openhands-sdk"
42+ source_dir : str # repo-relative directory, e.g. "openhands-sdk"
43+
44+
45+ PACKAGES : tuple [PackageConfig , ...] = (
46+ PackageConfig (
47+ package = "openhands.sdk" ,
48+ distribution = "openhands-sdk" ,
49+ source_dir = "openhands-sdk" ,
50+ ),
51+ PackageConfig (
52+ package = "openhands.workspace" ,
53+ distribution = "openhands-workspace" ,
54+ source_dir = "openhands-workspace" ,
55+ ),
56+ )
3957
4058
4159def read_version_from_pyproject (path : str ) -> str :
@@ -73,7 +91,7 @@ def get_prev_pypi_version(pkg: str, current: str | None) -> str | None:
7391 with urllib .request .urlopen (req , timeout = 10 ) as r :
7492 meta = json .load (r )
7593 except Exception as e :
76- print (f"::warning title=SDK API::Failed to fetch PyPI metadata: { e } " )
94+ print (f"::warning title={ pkg } API::Failed to fetch PyPI metadata: { e } " )
7795 return None
7896
7997 releases = list (meta .get ("releases" , {}).keys ())
@@ -199,7 +217,7 @@ def _check_version_bump(prev: str, new_version: str, total_breaks: int) -> int:
199217 return 0
200218
201219
202- def _resolve_griffe_object (root , dotted : str ):
220+ def _resolve_griffe_object (root , dotted : str , root_package : str = "" ):
203221 """Resolve a dotted path to a griffe object."""
204222 root_path = getattr (root , "path" , None )
205223 if root_path == dotted :
@@ -212,13 +230,13 @@ def _resolve_griffe_object(root, dotted: str):
212230 return root [dotted ]
213231 except (KeyError , TypeError ) as e :
214232 print (
215- f"::warning title=SDK API::Unable to resolve { dotted } via direct lookup; "
216- f"falling back to manual traversal: { e } "
233+ f"::warning title=SDK API::Unable to resolve { dotted } via "
234+ f"direct lookup; falling back to manual traversal: { e } "
217235 )
218236
219237 rel = dotted
220- if dotted .startswith (SDK_PACKAGE + "." ):
221- rel = dotted [len (SDK_PACKAGE ) + 1 :]
238+ if root_package and dotted .startswith (root_package + "." ):
239+ rel = dotted [len (root_package ) + 1 :]
222240
223241 obj = root
224242 for part in rel .split ("." ):
@@ -229,28 +247,35 @@ def _resolve_griffe_object(root, dotted: str):
229247 return obj
230248
231249
232- def _load_current_sdk (griffe_module , repo_root : str ):
250+ def _load_current (griffe_module , repo_root : str , cfg : PackageConfig ):
233251 try :
234252 return griffe_module .load (
235- SDK_PACKAGE , search_paths = [os .path .join (repo_root , "openhands-sdk" )]
253+ cfg .package ,
254+ search_paths = [os .path .join (repo_root , cfg .source_dir )],
236255 )
237256 except Exception as e :
238- print (f"::error title=SDK API::Failed to load current SDK: { e } " )
257+ print (
258+ f"::error title={ cfg .distribution } API::"
259+ f"Failed to load current { cfg .distribution } : { e } "
260+ )
239261 return None
240262
241263
242- def _load_prev_sdk_from_pypi (griffe_module , prev : str ):
264+ def _load_prev_from_pypi (griffe_module , prev : str , cfg : PackageConfig ):
243265 griffe_cache = os .path .expanduser ("~/.cache/griffe" )
244266 os .makedirs (griffe_cache , exist_ok = True )
245267
246268 try :
247269 return griffe_module .load_pypi (
248- package = SDK_PACKAGE ,
249- distribution = DISTRIBUTION_NAME ,
270+ package = cfg . package ,
271+ distribution = cfg . distribution ,
250272 version_spec = f"=={ prev } " ,
251273 )
252274 except Exception as e :
253- print (f"::error title=SDK API::Failed to load { prev } from PyPI: { e } " )
275+ print (
276+ f"::error title={ cfg .distribution } API::"
277+ f"Failed to load { cfg .distribution } =={ prev } from PyPI: { e } "
278+ )
254279 return None
255280
256281
@@ -318,21 +343,25 @@ def _get_source_root(griffe_root: object) -> Path | None:
318343 return None
319344
320345
321- def _compute_breakages (old_root , new_root , include : list [str ]) -> tuple [int , int ]:
322- """Detect breaking changes between old and new SDK versions.
346+ def _compute_breakages (
347+ old_root , new_root , cfg : PackageConfig , include : list [str ]
348+ ) -> tuple [int , int ]:
349+ """Detect breaking changes between old and new package versions.
323350
324351 Returns:
325352 ``(total_breaks, undeprecated_removals)`` — *total_breaks* counts all
326353 structural breakages (for the version-bump policy), while
327354 *undeprecated_removals* counts exports removed without a prior
328355 deprecation marker (a separate hard failure).
329356 """
357+ pkg = cfg .package
358+ title = f"{ cfg .distribution } API"
330359 total_breaks = 0
331360 undeprecated_removals = 0
332361
333362 try :
334- old_mod = _resolve_griffe_object (old_root , SDK_PACKAGE )
335- new_mod = _resolve_griffe_object (new_root , SDK_PACKAGE )
363+ old_mod = _resolve_griffe_object (old_root , pkg , root_package = pkg )
364+ new_mod = _resolve_griffe_object (new_root , pkg , root_package = pkg )
336365 old_exports = _extract_exported_names (old_mod )
337366 new_exports = _extract_exported_names (new_mod )
338367
@@ -349,17 +378,17 @@ def _compute_breakages(old_root, new_root, include: list[str]) -> tuple[int, int
349378 total_breaks += 1 # every removal is a structural break
350379 if name not in deprecated_names :
351380 print (
352- f"::error title=SDK API ::Removed '{ name } ' from "
353- f"{ SDK_PACKAGE } .__all__ without prior deprecation. "
381+ f"::error title={ title } ::Removed '{ name } ' from "
382+ f"{ pkg } .__all__ without prior deprecation. "
354383 f"Mark it with @deprecated or warn_deprecated() "
355384 f"for at least one release before removing."
356385 )
357386 undeprecated_removals += 1
358387 else :
359388 print (
360- f"::notice title=SDK API ::Removed previously-"
389+ f"::notice title={ title } ::Removed previously-"
361390 f"deprecated symbol '{ name } ' from "
362- f"{ SDK_PACKAGE } .__all__"
391+ f"{ pkg } .__all__"
363392 )
364393
365394 common = sorted (old_exports & new_exports )
@@ -368,71 +397,85 @@ def _compute_breakages(old_root, new_root, include: list[str]) -> tuple[int, int
368397 try :
369398 pairs .append ((old_mod [name ], new_mod [name ]))
370399 except Exception as e :
371- print (f"::warning title=SDK API ::Unable to resolve symbol { name } : { e } " )
400+ print (f"::warning title={ title } ::Unable to resolve symbol { name } : { e } " )
372401 total_breaks += len (_collect_breakages_pairs (pairs ))
373402 except Exception as e :
374- print (f"::warning title=SDK API ::Failed to process top-level exports: { e } " )
403+ print (f"::warning title={ title } ::Failed to process top-level exports: { e } " )
375404
376405 extra_pairs : list [tuple [object , object ]] = []
377406 for path in include :
378- if path == SDK_PACKAGE :
407+ if path == pkg :
379408 continue
380409 try :
381- old_obj = _resolve_griffe_object (old_root , path )
382- new_obj = _resolve_griffe_object (new_root , path )
410+ old_obj = _resolve_griffe_object (old_root , path , root_package = pkg )
411+ new_obj = _resolve_griffe_object (new_root , path , root_package = pkg )
383412 extra_pairs .append ((old_obj , new_obj ))
384413 except Exception as e :
385- print (f"::warning title=SDK API ::Path { path } not found: { e } " )
414+ print (f"::warning title={ title } ::Path { path } not found: { e } " )
386415
387416 if extra_pairs :
388417 total_breaks += len (_collect_breakages_pairs (extra_pairs ))
389418
390419 return total_breaks , undeprecated_removals
391420
392421
393- def main ( ) -> int :
394- """Main entry point for SDK API breakage detection ."""
395- ensure_griffe ( )
396- import griffe
422+ def _check_package ( griffe_module , repo_root : str , cfg : PackageConfig ) -> int :
423+ """Run breakage checks for a single package. Returns 0 on success ."""
424+ pyproj = os . path . join ( repo_root , cfg . source_dir , "pyproject.toml" )
425+ new_version = read_version_from_pyproject ( pyproj )
397426
398- repo_root = os .getcwd ()
399- current_pyproj = os .path .join (repo_root , PYPROJECT_RELATIVE_PATH )
400- new_version = read_version_from_pyproject (current_pyproj )
401-
402- include = os .environ .get ("SDK_INCLUDE_PATHS" , SDK_PACKAGE ).split ("," )
427+ include_env = f"{ cfg .package .upper ().replace ('.' , '_' )} _INCLUDE_PATHS"
428+ include = os .environ .get (include_env , cfg .package ).split ("," )
403429 include = [p .strip () for p in include if p .strip ()]
404430
405- prev = get_prev_pypi_version (DISTRIBUTION_NAME , new_version )
431+ title = f"{ cfg .distribution } API"
432+ prev = get_prev_pypi_version (cfg .distribution , new_version )
406433 if not prev :
407434 print (
408- f"::warning title=SDK API ::No previous { DISTRIBUTION_NAME } release found; "
409- " skipping breakage check" ,
435+ f"::warning title={ title } ::No previous { cfg . distribution } "
436+ f"release found; skipping breakage check" ,
410437 )
411438 return 0
412439
413- print (f"Comparing { DISTRIBUTION_NAME } { new_version } against { prev } " )
440+ print (f"Comparing { cfg . distribution } { new_version } against { prev } " )
414441
415- new_root = _load_current_sdk ( griffe , repo_root )
442+ new_root = _load_current ( griffe_module , repo_root , cfg )
416443 if not new_root :
417444 return 1
418445
419- old_root = _load_prev_sdk_from_pypi ( griffe , prev )
446+ old_root = _load_prev_from_pypi ( griffe_module , prev , cfg )
420447 if not old_root :
421448 return 1
422449
423- total_breaks , undeprecated = _compute_breakages (old_root , new_root , include )
450+ total_breaks , undeprecated = _compute_breakages (old_root , new_root , cfg , include )
424451
425452 if undeprecated :
426453 print (
427- f"::error title=SDK API::{ undeprecated } symbol(s) removed "
428- f"without prior deprecation — see errors above"
454+ f"::error title={ title } ::{ undeprecated } symbol(s) removed "
455+ f"from { cfg .package } without prior deprecation — "
456+ f"see errors above"
429457 )
430458
431459 bump_rc = _check_version_bump (prev , new_version , total_breaks )
432460
433- # Fail if either policy is violated
434461 return 1 if (undeprecated or bump_rc ) else 0
435462
436463
464+ def main () -> int :
465+ """Main entry point for API breakage detection."""
466+ ensure_griffe ()
467+ import griffe
468+
469+ repo_root = os .getcwd ()
470+ rc = 0
471+ for cfg in PACKAGES :
472+ print (f"\n { '=' * 60 } " )
473+ print (f"Checking { cfg .distribution } ({ cfg .package } )" )
474+ print (f"{ '=' * 60 } " )
475+ rc |= _check_package (griffe , repo_root , cfg )
476+
477+ return rc
478+
479+
437480if __name__ == "__main__" :
438481 raise SystemExit (main ())
0 commit comments