@openhands/extensions 0.9.0 → 0.10.0

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.
@@ -0,0 +1,488 @@
1
+ #!/usr/bin/env python3
2
+ """Static analysis for deprecation deadlines.
3
+
4
+ This is the OpenHands SDK deprecation checker, scoped to the extensions
5
+ package. It scans the SDK's three supported mechanisms: ``@deprecated``,
6
+ ``warn_deprecated``, and ``warn_cleanup``. The only policy difference is the
7
+ two-minor-release runway used by extensions.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+ import sys
14
+ import tomllib
15
+ from collections.abc import Iterable, Iterator, Sequence
16
+ from dataclasses import dataclass
17
+ from datetime import date
18
+ from pathlib import Path
19
+ from typing import Literal
20
+
21
+ from packaging import version as pkg_version
22
+
23
+
24
+ # This is intentionally shorter than the SDK's five-release policy: the public
25
+ # extensions API removes deprecated raw catalog helpers after two minor releases.
26
+ DEPRECATION_RUNWAY_MINOR_RELEASES = 2
27
+ REPO_ROOT = Path(__file__).resolve().parents[1]
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class PackageConfig:
32
+ name: str
33
+ pyproject: Path
34
+ source_roots: tuple[Path, ...]
35
+
36
+
37
+ PACKAGES: tuple[PackageConfig, ...] = (
38
+ PackageConfig(
39
+ name="openhands-extensions",
40
+ pyproject=REPO_ROOT / "pyproject.toml",
41
+ source_roots=(REPO_ROOT / "python" / "openhands_extensions",),
42
+ ),
43
+ )
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class DeprecationRecord:
48
+ identifier: str
49
+ removed_in: str | date | None
50
+ deprecated_in: str | None
51
+ path: Path
52
+ line: int
53
+ kind: Literal["decorator", "warn_call", "cleanup_call"]
54
+ package: str
55
+
56
+
57
+ def _load_current_version(pyproject: Path) -> str:
58
+ data = tomllib.loads(pyproject.read_text())
59
+ try:
60
+ return str(data["project"]["version"])
61
+ except KeyError as exc: # pragma: no cover - configuration error
62
+ raise SystemExit(
63
+ f"Unable to determine project version from {pyproject}"
64
+ ) from exc
65
+
66
+
67
+ def _iter_python_files(root: Path) -> Iterator[Path]:
68
+ for path in root.rglob("*.py"):
69
+ if path.name == "__init__.py" and path.parent == root:
70
+ continue
71
+ yield path
72
+
73
+
74
+ def _parse_removed_value(
75
+ node: ast.AST | None,
76
+ *,
77
+ path: Path,
78
+ line: int,
79
+ ) -> str | date | None:
80
+ if node is None:
81
+ return None
82
+
83
+ expression = ast.unparse(node)
84
+
85
+ if isinstance(node, ast.Constant):
86
+ if isinstance(node.value, str):
87
+ return node.value
88
+ if node.value is None:
89
+ return None
90
+ raise SystemExit(
91
+ f"Unsupported removed_in literal at {path}:{line}: {expression}"
92
+ )
93
+
94
+ if isinstance(node, ast.Call):
95
+ func = node.func
96
+ if isinstance(func, ast.Name) and func.id == "date":
97
+ try:
98
+ args = [_safe_int_literal(arg) for arg in node.args]
99
+ kwargs = {
100
+ kw.arg: _safe_int_literal(kw.value)
101
+ for kw in node.keywords
102
+ if kw.arg is not None
103
+ }
104
+ except ValueError as exc:
105
+ raise SystemExit(
106
+ f"Unsupported removed_in date() arguments at {path}:{line}:"
107
+ f" {expression}"
108
+ ) from exc
109
+
110
+ if any(kw.arg is None for kw in node.keywords):
111
+ raise SystemExit(
112
+ "Unsupported removed_in date() call (uses **kwargs) at "
113
+ f"{path}:{line}: {expression}"
114
+ )
115
+
116
+ try:
117
+ return date(*args, **kwargs)
118
+ except TypeError as exc:
119
+ raise SystemExit(
120
+ f"Invalid removed_in date() call at {path}:{line}: {expression}"
121
+ ) from exc
122
+
123
+ if (
124
+ isinstance(func, ast.Attribute)
125
+ and isinstance(func.value, ast.Name)
126
+ and func.value.id == "date"
127
+ and func.attr == "today"
128
+ ):
129
+ if node.args or node.keywords:
130
+ raise SystemExit(
131
+ "date.today() removed_in call must not include arguments at "
132
+ f"{path}:{line}: {expression}"
133
+ )
134
+ return date.today()
135
+
136
+ raise SystemExit(
137
+ f"Unsupported removed_in expression at {path}:{line}: {expression}"
138
+ )
139
+
140
+
141
+ def _parse_deprecated_value(
142
+ node: ast.AST | None,
143
+ *,
144
+ path: Path,
145
+ line: int,
146
+ ) -> str | None:
147
+ if node is None:
148
+ return None
149
+
150
+ expression = ast.unparse(node)
151
+
152
+ if isinstance(node, ast.Constant):
153
+ if isinstance(node.value, str):
154
+ return node.value
155
+ if node.value is None:
156
+ return None
157
+
158
+ raise SystemExit(
159
+ f"Unsupported deprecated_in expression at {path}:{line}: {expression}"
160
+ )
161
+
162
+
163
+ def _safe_int_literal(node: ast.AST) -> int:
164
+ if not isinstance(node, ast.Constant) or not isinstance(node.value, int):
165
+ raise ValueError(
166
+ f"Unsupported expression inside literal evaluation: {ast.unparse(node)}"
167
+ )
168
+ return node.value
169
+
170
+
171
+ def _extract_kw(call: ast.Call, name: str) -> ast.AST | None:
172
+ for kw in call.keywords:
173
+ if kw.arg == name:
174
+ return kw.value
175
+ return None
176
+
177
+
178
+ def _gather_decorators(
179
+ tree: ast.AST, path: Path, *, package: str
180
+ ) -> Iterator[DeprecationRecord]:
181
+ for node in ast.walk(tree):
182
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
183
+ continue
184
+
185
+ for deco in node.decorator_list:
186
+ call = deco if isinstance(deco, ast.Call) else None
187
+ if call is None:
188
+ continue
189
+
190
+ target = call.func
191
+ if isinstance(target, ast.Name):
192
+ decorator_name = target.id
193
+ elif isinstance(target, ast.Attribute):
194
+ decorator_name = target.attr
195
+ else:
196
+ continue
197
+
198
+ if decorator_name != "deprecated":
199
+ continue
200
+
201
+ removed_expr = _extract_kw(call, "removed_in")
202
+ deprecated_expr = _extract_kw(call, "deprecated_in")
203
+
204
+ yield DeprecationRecord(
205
+ identifier=_build_identifier(node),
206
+ removed_in=_parse_removed_value(
207
+ removed_expr, path=path, line=node.lineno
208
+ ),
209
+ deprecated_in=_parse_deprecated_value(
210
+ deprecated_expr, path=path, line=node.lineno
211
+ ),
212
+ path=path,
213
+ line=node.lineno,
214
+ kind="decorator",
215
+ package=package,
216
+ )
217
+
218
+
219
+ def _gather_warn_calls(
220
+ tree: ast.AST, path: Path, *, package: str
221
+ ) -> Iterator[DeprecationRecord]:
222
+ for node in ast.walk(tree):
223
+ if not isinstance(node, ast.Call):
224
+ continue
225
+
226
+ target = node.func
227
+ if isinstance(target, ast.Name):
228
+ func_name = target.id
229
+ elif isinstance(target, ast.Attribute):
230
+ func_name = target.attr
231
+ else:
232
+ continue
233
+
234
+ if func_name == "warn_deprecated":
235
+ identifier_node = node.args[0] if node.args else None
236
+ if identifier_node is None:
237
+ continue
238
+ identifier = ast.unparse(identifier_node)
239
+
240
+ removed_expr = _extract_kw(node, "removed_in")
241
+ deprecated_expr = _extract_kw(node, "deprecated_in")
242
+
243
+ yield DeprecationRecord(
244
+ identifier=identifier,
245
+ removed_in=_parse_removed_value(
246
+ removed_expr, path=path, line=node.lineno
247
+ ),
248
+ deprecated_in=_parse_deprecated_value(
249
+ deprecated_expr, path=path, line=node.lineno
250
+ ),
251
+ path=path,
252
+ line=node.lineno,
253
+ kind="warn_call",
254
+ package=package,
255
+ )
256
+ elif func_name == "warn_cleanup":
257
+ identifier_node = node.args[0] if node.args else None
258
+ if identifier_node is None:
259
+ continue
260
+ identifier = ast.unparse(identifier_node)
261
+
262
+ cleanup_expr = _extract_kw(node, "cleanup_by")
263
+
264
+ yield DeprecationRecord(
265
+ identifier=identifier,
266
+ removed_in=_parse_removed_value(
267
+ cleanup_expr, path=path, line=node.lineno
268
+ ),
269
+ deprecated_in=None,
270
+ path=path,
271
+ line=node.lineno,
272
+ kind="cleanup_call",
273
+ package=package,
274
+ )
275
+
276
+
277
+ def _build_identifier(node: ast.AST) -> str:
278
+ if isinstance(node, ast.ClassDef):
279
+ return node.name
280
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
281
+ qual_name = node.name
282
+ if node.decorator_list:
283
+ parent = getattr(node, "parent", None)
284
+ if parent and isinstance(parent, ast.ClassDef):
285
+ return f"{parent.name}.{node.name}"
286
+ return qual_name
287
+ return "<unknown>"
288
+
289
+
290
+ def _attach_parents(tree: ast.AST) -> None:
291
+ for node in ast.walk(tree):
292
+ for child in ast.iter_child_nodes(node):
293
+ setattr(child, "parent", node)
294
+
295
+
296
+ def _collect_records(files: Iterable[Path], *, package: str) -> list[DeprecationRecord]:
297
+ records: list[DeprecationRecord] = []
298
+ for path in files:
299
+ tree = ast.parse(path.read_text())
300
+ _attach_parents(tree)
301
+ records.extend(_gather_decorators(tree, path, package=package))
302
+ records.extend(_gather_warn_calls(tree, path, package=package))
303
+ return records
304
+
305
+
306
+ def _version_ge(current: str, target: str) -> bool:
307
+ try:
308
+ return pkg_version.parse(current) >= pkg_version.parse(target)
309
+ except pkg_version.InvalidVersion as exc:
310
+ raise SystemExit(
311
+ f"Invalid semantic version comparison: {current=} {target=}"
312
+ ) from exc
313
+
314
+
315
+ def _minimum_removed_in(deprecated_in: str) -> str:
316
+ parsed = pkg_version.parse(deprecated_in)
317
+ if not isinstance(parsed, pkg_version.Version):
318
+ raise SystemExit(f"Invalid deprecated_in version: {deprecated_in!r}")
319
+ return f"{parsed.major}.{parsed.minor + DEPRECATION_RUNWAY_MINOR_RELEASES}.0"
320
+
321
+
322
+ def _runway_error(record: DeprecationRecord) -> str | None:
323
+ if record.kind == "cleanup_call" or record.removed_in is None:
324
+ return None
325
+ if isinstance(record.removed_in, date):
326
+ return None
327
+ if record.deprecated_in is None:
328
+ return (
329
+ f"{record.identifier} does not declare deprecated_in; version-based "
330
+ "deprecations must provide an explicit runway."
331
+ )
332
+
333
+ try:
334
+ minimum_removed_in = _minimum_removed_in(record.deprecated_in)
335
+ if pkg_version.parse(str(record.removed_in)) >= pkg_version.parse(
336
+ minimum_removed_in
337
+ ):
338
+ return None
339
+ except pkg_version.InvalidVersion as exc:
340
+ raise SystemExit(
341
+ f"Invalid deprecation schedule at {record.path}:{record.line}: "
342
+ f"deprecated_in={record.deprecated_in!r}, "
343
+ f"removed_in={record.removed_in!r}"
344
+ ) from exc
345
+
346
+ return (
347
+ f"{record.identifier} uses an invalid deprecation schedule: "
348
+ f"deprecated_in={record.deprecated_in} and "
349
+ f"removed_in={record.removed_in}. Deprecations require at least "
350
+ f"{DEPRECATION_RUNWAY_MINOR_RELEASES} minor releases of runway "
351
+ f"(minimum removed_in: {minimum_removed_in})."
352
+ )
353
+
354
+
355
+ def _should_fail(current_version: str, record: DeprecationRecord) -> bool:
356
+ removed = record.removed_in
357
+ if removed is None:
358
+ return False
359
+ if isinstance(removed, date):
360
+ return date.today() >= removed
361
+ try:
362
+ return _version_ge(current_version, str(removed))
363
+ except SystemExit:
364
+ raise
365
+ except Exception as exc: # pragma: no cover - unexpected literal type
366
+ raise SystemExit(
367
+ f"Unsupported removed_in expression in {record.path}:{record.line}:"
368
+ f" {removed!r}"
369
+ ) from exc
370
+
371
+
372
+ def _format_record(record: DeprecationRecord) -> str:
373
+ location = record.path.relative_to(REPO_ROOT)
374
+ removed = record.removed_in if record.removed_in is not None else "(none)"
375
+
376
+ if record.kind == "cleanup_call":
377
+ return (
378
+ f"- [{record.package}] {record.identifier} ({record.kind})\n"
379
+ f" cleanup by: {removed}\n"
380
+ f" defined at: {location}:{record.line}"
381
+ )
382
+
383
+ deprecated = (
384
+ record.deprecated_in if record.deprecated_in is not None else "(unknown)"
385
+ )
386
+ return (
387
+ f"- [{record.package}] {record.identifier} ({record.kind})\n"
388
+ f" deprecated in: {deprecated}\n"
389
+ f" removed in: {removed}\n"
390
+ f" defined at: {location}:{record.line}"
391
+ )
392
+
393
+
394
+ def main(argv: Sequence[str] | None = None) -> int:
395
+ del argv # The SDK checker takes no command-line configuration.
396
+
397
+ overdue: list[DeprecationRecord] = []
398
+ invalid_runway: list[tuple[DeprecationRecord, str]] = []
399
+ total_records = 0
400
+ package_summaries: list[tuple[str, str, int]] = []
401
+
402
+ for package in PACKAGES:
403
+ if not package.pyproject.exists():
404
+ raise SystemExit(
405
+ f"Unable to locate pyproject.toml for {package.name}: "
406
+ f"{package.pyproject}"
407
+ )
408
+
409
+ current_version = _load_current_version(package.pyproject)
410
+
411
+ files: list[Path] = []
412
+ for root in package.source_roots:
413
+ if not root.exists():
414
+ raise SystemExit(
415
+ f"Source root {root} for package {package.name} does not exist"
416
+ )
417
+ files.extend(_iter_python_files(root))
418
+
419
+ records = _collect_records(files, package=package.name)
420
+ overdue.extend(r for r in records if _should_fail(current_version, r))
421
+ invalid_runway.extend(
422
+ (record, error)
423
+ for record in records
424
+ if (error := _runway_error(record)) is not None
425
+ )
426
+ total_records += len(records)
427
+ package_summaries.append((package.name, current_version, len(records)))
428
+
429
+ if overdue or invalid_runway:
430
+ deprecated_items = [r for r in overdue if r.kind != "cleanup_call"]
431
+ cleanup_items = [r for r in overdue if r.kind == "cleanup_call"]
432
+
433
+ if invalid_runway:
434
+ print(
435
+ "The following deprecation schedules provide less than "
436
+ f"{DEPRECATION_RUNWAY_MINOR_RELEASES} minor releases of runway:\n"
437
+ )
438
+ for record, error in invalid_runway:
439
+ print(_format_record(record))
440
+ print(f" error: {error}")
441
+ print()
442
+
443
+ if deprecated_items:
444
+ print(
445
+ "The following deprecated features have passed their removal "
446
+ "deadline:\n"
447
+ )
448
+ for record in deprecated_items:
449
+ print(_format_record(record))
450
+ print()
451
+
452
+ if cleanup_items:
453
+ print("The following workarounds have passed their cleanup deadline:\n")
454
+ for record in cleanup_items:
455
+ print(_format_record(record))
456
+ print()
457
+
458
+ if deprecated_items:
459
+ print(
460
+ "Update or remove the listed features before publishing a version that "
461
+ "meets or exceeds their removal deadline."
462
+ )
463
+ if cleanup_items:
464
+ print(
465
+ "Remove the listed workarounds before publishing a version that "
466
+ "meets or exceeds their cleanup deadline."
467
+ )
468
+ if invalid_runway:
469
+ print(
470
+ "Move each removal target far enough out, or remove the invalid "
471
+ "deprecation metadata before merging."
472
+ )
473
+ return 1
474
+
475
+ for package_name, version, count in package_summaries:
476
+ print(
477
+ f"{package_name}: checked {count} deprecation metadata entries against "
478
+ f"version {version}."
479
+ )
480
+ print(
481
+ f"Checked {total_records} deprecation metadata entries across "
482
+ f"{len(package_summaries)} package(s)."
483
+ )
484
+ return 0
485
+
486
+
487
+ if __name__ == "__main__": # pragma: no cover - manual invocation
488
+ sys.exit(main(sys.argv[1:]))
@@ -9,6 +9,7 @@ AI reviewer kept catching is now caught in CI.
9
9
  """
10
10
 
11
11
  import json
12
+ from copy import deepcopy
12
13
  from pathlib import Path
13
14
 
14
15
  import pytest
@@ -45,3 +46,60 @@ def test_catalog_entry_validates_against_schema(entry_path: Path) -> None:
45
46
  def test_schema_file_is_valid_draft_2020_12() -> None:
46
47
  """The schema itself must be a valid Draft 2020-12 document."""
47
48
  Draft202012Validator.check_schema(_SCHEMA)
49
+
50
+
51
+ def test_schema_requires_catalog_and_install_metadata() -> None:
52
+ entry = json.loads((CATALOG_DIR / "slack.json").read_text())
53
+
54
+ invalid_entries: list[tuple[str, dict]] = []
55
+
56
+ missing_docs = deepcopy(entry)
57
+ missing_docs.pop("docsUrl")
58
+ invalid_entries.append(("docsUrl", missing_docs))
59
+
60
+ missing_field_type = deepcopy(entry)
61
+ missing_field_type["connectionOptions"][1]["transport"]["envFields"][0].pop("type")
62
+ invalid_entries.append(("type", missing_field_type))
63
+
64
+ missing_required_marker = deepcopy(entry)
65
+ missing_required_marker["connectionOptions"][1]["transport"]["envFields"][0].pop(
66
+ "required"
67
+ )
68
+ invalid_entries.append(("required", missing_required_marker))
69
+
70
+ missing_principal_mapping = deepcopy(entry)
71
+ missing_principal_mapping["connectionOptions"][0]["connectionModel"][
72
+ "identityMapping"
73
+ ].pop("externalPrincipalIdPath")
74
+ invalid_entries.append(("externalPrincipalIdPath", missing_principal_mapping))
75
+
76
+ for field, invalid in invalid_entries:
77
+ errors = list(VALIDATOR.iter_errors(invalid))
78
+ assert any(field in error.message for error in errors), errors
79
+
80
+
81
+ @pytest.mark.parametrize(
82
+ ("entry_id", "resource_type", "cardinality", "selection_mode"),
83
+ [
84
+ ("slack", "workspace", "one", "automatic"),
85
+ ("notion", "workspace", "one", "automatic"),
86
+ ("microsoft-teams", "tenant", "one", "automatic"),
87
+ ("atlassian", "site", "many", "post_auth"),
88
+ ("github", "organization", "many", "post_auth"),
89
+ ],
90
+ )
91
+ def test_oauth_connection_model_examples(
92
+ entry_id: str,
93
+ resource_type: str,
94
+ cardinality: str,
95
+ selection_mode: str,
96
+ ) -> None:
97
+ entry = json.loads((CATALOG_DIR / f"{entry_id}.json").read_text())
98
+ oauth_option = next(
99
+ option for option in entry["connectionOptions"] if option["id"] == "oauth"
100
+ )
101
+ model = oauth_option["connectionModel"]
102
+
103
+ assert model["resourceType"] == resource_type
104
+ assert model["resourceCardinality"] == cardinality
105
+ assert model["selectionMode"] == selection_mode
@@ -0,0 +1,136 @@
1
+ """Tests for the SDK-aligned public deprecation policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import importlib.util
7
+ import sys
8
+ from datetime import date, timedelta
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+ from deprecation import DeprecatedWarning, UnsupportedWarning
13
+
14
+ from openhands_extensions.deprecation import (
15
+ deprecated,
16
+ handle_deprecated_model_fields,
17
+ warn_cleanup,
18
+ warn_deprecated,
19
+ )
20
+
21
+
22
+ ROOT = Path(__file__).resolve().parents[1]
23
+ CHECK = ROOT / "scripts" / "check_deprecations.py"
24
+
25
+
26
+ def _load_checker():
27
+ spec = importlib.util.spec_from_file_location("extensions_deprecation_check", CHECK)
28
+ assert spec is not None
29
+ assert spec.loader is not None
30
+ module = importlib.util.module_from_spec(spec)
31
+ sys.modules[spec.name] = module
32
+ spec.loader.exec_module(module)
33
+ return module
34
+
35
+
36
+ def test_deprecated_decorator_warns_and_preserves_calls() -> None:
37
+ @deprecated(
38
+ deprecated_in="0.10.0",
39
+ removed_in="0.12.0",
40
+ current_version="0.10.0",
41
+ details="Use replacement().",
42
+ )
43
+ def legacy(value: int) -> int:
44
+ return value * 2
45
+
46
+ with pytest.warns(DeprecatedWarning, match="legacy"):
47
+ assert legacy(3) == 6
48
+
49
+
50
+ def test_dynamic_deprecation_warnings_match_the_sdk() -> None:
51
+ with pytest.warns(DeprecatedWarning, match="dynamic legacy API"):
52
+ warn_deprecated(
53
+ "dynamic legacy API",
54
+ deprecated_in="0.10.0",
55
+ removed_in="0.12.0",
56
+ current_version="0.10.0",
57
+ )
58
+
59
+ with pytest.warns(UnsupportedWarning, match="dynamic legacy API"):
60
+ warn_deprecated(
61
+ "dynamic legacy API",
62
+ deprecated_in="0.10.0",
63
+ removed_in="0.12.0",
64
+ current_version="0.12.0",
65
+ )
66
+
67
+
68
+ def test_cleanup_warning_and_model_field_compatibility_match_the_sdk() -> None:
69
+ with pytest.warns(UserWarning, match="Cleanup required: temporary workaround"):
70
+ warn_cleanup(
71
+ "temporary workaround",
72
+ cleanup_by="0.12.0",
73
+ current_version="0.12.0",
74
+ )
75
+
76
+ data = {"current": "value", "legacy": "old"}
77
+ assert handle_deprecated_model_fields(data, ("legacy",)) == {"current": "value"}
78
+ assert (
79
+ handle_deprecated_model_fields("not a mapping", ("legacy",)) == "not a mapping"
80
+ )
81
+
82
+
83
+ def test_checker_scans_all_sdk_deprecation_mechanisms() -> None:
84
+ checker = _load_checker()
85
+ tree = ast.parse(
86
+ """
87
+ @deprecated(deprecated_in=\"0.10.0\", removed_in=\"0.12.0\")
88
+ def decorated(): pass
89
+
90
+ warn_deprecated(\"dynamic API\", deprecated_in=\"0.10.0\", removed_in=\"0.12.0\")
91
+ warn_cleanup(\"temporary workaround\", cleanup_by=date(2030, 1, 1))
92
+ """
93
+ )
94
+
95
+ records = [
96
+ *checker._gather_decorators(tree, CHECK, package="openhands-extensions"),
97
+ *checker._gather_warn_calls(tree, CHECK, package="openhands-extensions"),
98
+ ]
99
+
100
+ assert [(record.identifier, record.kind) for record in records] == [
101
+ ("decorated", "decorator"),
102
+ ("'dynamic API'", "warn_call"),
103
+ ("'temporary workaround'", "cleanup_call"),
104
+ ]
105
+ assert records[2].removed_in == date(2030, 1, 1)
106
+
107
+
108
+ def test_release_please_deadline_passes_before_removal_version(
109
+ monkeypatch: pytest.MonkeyPatch,
110
+ ) -> None:
111
+ checker = _load_checker()
112
+ monkeypatch.setattr(checker, "_load_current_version", lambda _: "0.10.0")
113
+
114
+ assert checker.main() == 0
115
+
116
+
117
+ def test_release_please_deadline_fails_at_removal_version(
118
+ monkeypatch: pytest.MonkeyPatch,
119
+ capsys: pytest.CaptureFixture[str],
120
+ ) -> None:
121
+ checker = _load_checker()
122
+ monkeypatch.setattr(checker, "_load_current_version", lambda _: "0.12.0")
123
+
124
+ assert checker.main() == 1
125
+ output = capsys.readouterr().out
126
+ assert "list_integration_catalog" in output
127
+ assert "get_integration_catalog_entry" in output
128
+
129
+
130
+ def test_date_based_deadlines_use_the_sdk_semantics() -> None:
131
+ with pytest.warns(DeprecatedWarning, match="scheduled dynamic API"):
132
+ warn_deprecated(
133
+ "scheduled dynamic API",
134
+ deprecated_in="0.10.0",
135
+ removed_in=date.today() + timedelta(days=1),
136
+ )