apcore-toolkit 0.4.0__tar.gz → 0.4.2__tar.gz

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.
Files changed (50) hide show
  1. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/CHANGELOG.md +11 -0
  2. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/PKG-INFO +12 -3
  3. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/README.md +10 -1
  4. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/pyproject.toml +2 -2
  5. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/src/apcore_toolkit/__init__.py +2 -0
  6. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/src/apcore_toolkit/ai_enhancer.py +64 -54
  7. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/src/apcore_toolkit/openapi.py +21 -0
  8. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/src/apcore_toolkit/output/http_proxy_writer.py +6 -4
  9. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/src/apcore_toolkit/scanner.py +5 -5
  10. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/tests/test_openapi.py +96 -0
  11. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/.github/CODEOWNERS +0 -0
  12. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/.github/copilot-ignore +0 -0
  13. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/.github/workflows/ci.yml +0 -0
  14. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/.gitignore +0 -0
  15. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/.gitmessage +0 -0
  16. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/.pre-commit-config.yaml +0 -0
  17. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/src/apcore_toolkit/convention_scanner.py +0 -0
  18. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/src/apcore_toolkit/display/__init__.py +0 -0
  19. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/src/apcore_toolkit/display/resolver.py +0 -0
  20. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/src/apcore_toolkit/formatting/__init__.py +0 -0
  21. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/src/apcore_toolkit/formatting/markdown.py +0 -0
  22. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/src/apcore_toolkit/output/__init__.py +0 -0
  23. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/src/apcore_toolkit/output/errors.py +0 -0
  24. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/src/apcore_toolkit/output/python_writer.py +0 -0
  25. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/src/apcore_toolkit/output/registry_writer.py +0 -0
  26. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/src/apcore_toolkit/output/types.py +0 -0
  27. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/src/apcore_toolkit/output/verifiers.py +0 -0
  28. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/src/apcore_toolkit/output/yaml_writer.py +0 -0
  29. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/src/apcore_toolkit/pydantic_utils.py +0 -0
  30. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/src/apcore_toolkit/schema_utils.py +0 -0
  31. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/src/apcore_toolkit/serializers.py +0 -0
  32. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/src/apcore_toolkit/types.py +0 -0
  33. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/tests/conftest.py +0 -0
  34. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/tests/test_ai_enhancer.py +0 -0
  35. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/tests/test_convention_scanner.py +0 -0
  36. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/tests/test_display_resolver.py +0 -0
  37. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/tests/test_http_proxy_writer.py +0 -0
  38. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/tests/test_markdown.py +0 -0
  39. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/tests/test_output_factory.py +0 -0
  40. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/tests/test_pydantic_utils.py +0 -0
  41. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/tests/test_python_writer.py +0 -0
  42. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/tests/test_registry_writer.py +0 -0
  43. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/tests/test_scanner.py +0 -0
  44. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/tests/test_schema_utils.py +0 -0
  45. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/tests/test_serializers.py +0 -0
  46. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/tests/test_types.py +0 -0
  47. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/tests/test_verifiers.py +0 -0
  48. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/tests/test_write_error.py +0 -0
  49. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/tests/test_write_result.py +0 -0
  50. {apcore_toolkit-0.4.0 → apcore_toolkit-0.4.2}/tests/test_yaml_writer.py +0 -0
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.4.1] - 2026-03-25
6
+
7
+ ### Added
8
+
9
+ - **`deep_resolve_refs()`** — public API for recursive `$ref` resolution in OpenAPI schemas (previously internal `_deep_resolve_refs`). Resolves nested `allOf`/`anyOf`/`oneOf`, `items`, and `properties`. Depth-limited to 16 levels.
10
+
11
+ ### Fixed
12
+
13
+ - README: apcore dependency version updated from `>= 0.13.1` to `>= 0.14.0` (matches pyproject.toml).
14
+ - README: Core Modules table now lists all public API functions (added 10 missing entries).
15
+
5
16
  ## [0.4.0] - 2026-03-23
6
17
 
7
18
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apcore-toolkit
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Shared scanner, schema extraction, and output toolkit for apcore framework adapters
5
5
  Project-URL: Homepage, https://aiperceivable.com
6
6
  Project-URL: Repository, https://github.com/aiperceivable/apcore-toolkit-python
@@ -10,7 +10,7 @@ Author-email: aiperceivable <tercel.yi@gmail.com>
10
10
  License-Expression: Apache-2.0
11
11
  Keywords: apcore,mcp,openapi,pydantic,scanner,schema,toolkit,yaml
12
12
  Requires-Python: >=3.11
13
- Requires-Dist: apcore>=0.13.1
13
+ Requires-Dist: apcore>=0.18.0
14
14
  Requires-Dist: pydantic>=2.0
15
15
  Requires-Dist: pyyaml>=6.0
16
16
  Provides-Extra: dev
@@ -69,6 +69,15 @@ pip install apcore-toolkit
69
69
  | `get_writer` | Factory function for writer instances |
70
70
  | `DisplayResolver` | Sparse binding.yaml display overlay — resolves surface-facing alias, description, guidance, tags into `metadata["display"]` (§5.13) |
71
71
  | `ConventionScanner` | Scans a `commands/` directory of plain Python files for public functions and converts them to `ScannedModule` instances with schema inferred from type annotations (§5.14) |
72
+ | `extract_input_schema` | Merges OpenAPI query, path, and request body params into a single JSON Schema |
73
+ | `extract_output_schema` | Extracts response schema from OpenAPI operation objects |
74
+ | `resolve_ref` | Resolves a single internal `$ref` JSON pointer |
75
+ | `resolve_schema` | Resolves a top-level `$ref` in a schema |
76
+ | `deep_resolve_refs` | Recursively resolves all `$ref` pointers, depth-limited to 16 levels |
77
+ | `annotations_to_dict` | Converts `ModuleAnnotations` to a plain dict |
78
+ | `module_to_dict` | Converts a `ScannedModule` to a dict for JSON/YAML serialization |
79
+ | `modules_to_dicts` | Batch version of `module_to_dict` |
80
+ | `run_verifier_chain` | Runs multiple verifiers in sequence, stopping on first failure |
72
81
 
73
82
  ## Usage
74
83
 
@@ -240,7 +249,7 @@ Input and output schemas are inferred from PEP 484 type annotations. Use `includ
240
249
  ## Requirements
241
250
 
242
251
  - Python >= 3.11
243
- - apcore >= 0.13.1
252
+ - apcore >= 0.14.0
244
253
  - pydantic >= 2.0
245
254
  - PyYAML >= 6.0
246
255
 
@@ -43,6 +43,15 @@ pip install apcore-toolkit
43
43
  | `get_writer` | Factory function for writer instances |
44
44
  | `DisplayResolver` | Sparse binding.yaml display overlay — resolves surface-facing alias, description, guidance, tags into `metadata["display"]` (§5.13) |
45
45
  | `ConventionScanner` | Scans a `commands/` directory of plain Python files for public functions and converts them to `ScannedModule` instances with schema inferred from type annotations (§5.14) |
46
+ | `extract_input_schema` | Merges OpenAPI query, path, and request body params into a single JSON Schema |
47
+ | `extract_output_schema` | Extracts response schema from OpenAPI operation objects |
48
+ | `resolve_ref` | Resolves a single internal `$ref` JSON pointer |
49
+ | `resolve_schema` | Resolves a top-level `$ref` in a schema |
50
+ | `deep_resolve_refs` | Recursively resolves all `$ref` pointers, depth-limited to 16 levels |
51
+ | `annotations_to_dict` | Converts `ModuleAnnotations` to a plain dict |
52
+ | `module_to_dict` | Converts a `ScannedModule` to a dict for JSON/YAML serialization |
53
+ | `modules_to_dicts` | Batch version of `module_to_dict` |
54
+ | `run_verifier_chain` | Runs multiple verifiers in sequence, stopping on first failure |
46
55
 
47
56
  ## Usage
48
57
 
@@ -214,7 +223,7 @@ Input and output schemas are inferred from PEP 484 type annotations. Use `includ
214
223
  ## Requirements
215
224
 
216
225
  - Python >= 3.11
217
- - apcore >= 0.13.1
226
+ - apcore >= 0.14.0
218
227
  - pydantic >= 2.0
219
228
  - PyYAML >= 6.0
220
229
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "apcore-toolkit"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "Shared scanner, schema extraction, and output toolkit for apcore framework adapters"
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -23,7 +23,7 @@ keywords = [
23
23
  "toolkit",
24
24
  ]
25
25
  dependencies = [
26
- "apcore>=0.13.1",
26
+ "apcore>=0.18.0",
27
27
  "pydantic>=2.0",
28
28
  "PyYAML>=6.0",
29
29
  ]
@@ -9,6 +9,7 @@ from apcore_toolkit.ai_enhancer import AIEnhancer, Enhancer
9
9
  from apcore_toolkit.display import DisplayResolver
10
10
  from apcore_toolkit.formatting import to_markdown
11
11
  from apcore_toolkit.openapi import (
12
+ deep_resolve_refs,
12
13
  extract_input_schema,
13
14
  extract_output_schema,
14
15
  resolve_ref,
@@ -62,6 +63,7 @@ __all__ = [
62
63
  "YAMLVerifier",
63
64
  "YAMLWriter",
64
65
  "annotations_to_dict",
66
+ "deep_resolve_refs",
65
67
  "enrich_schema_descriptions",
66
68
  "extract_input_schema",
67
69
  "extract_output_schema",
@@ -10,13 +10,16 @@ metadata dict for auditability.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import dataclasses
13
14
  import json
14
15
  import logging
15
16
  import os
17
+ import types
18
+ import typing
16
19
  from dataclasses import replace
17
- from typing import Any, Protocol
20
+ from typing import Any, Callable, Protocol
18
21
 
19
- from apcore import ModuleAnnotations
22
+ from apcore import DEFAULT_ANNOTATIONS, ModuleAnnotations
20
23
 
21
24
  from apcore_toolkit.types import ScannedModule
22
25
 
@@ -27,7 +30,53 @@ _DEFAULT_MODEL = "qwen:0.6b"
27
30
  _DEFAULT_THRESHOLD = 0.7
28
31
  _DEFAULT_BATCH_SIZE = 5
29
32
  _DEFAULT_TIMEOUT = 30
30
- _DEFAULT_ANNOTATIONS = ModuleAnnotations()
33
+
34
+
35
+ # SLM must never write to ModuleAnnotations.extra: it is reserved for adapter
36
+ # extensions and is not user-facing semantic content.
37
+ _SLM_EXCLUDED_ANNOTATION_FIELDS = frozenset({"extra"})
38
+
39
+
40
+ def _build_annotation_field_validators() -> dict[str, Callable[[Any], bool]]:
41
+ """Derive per-field type validators from ``ModuleAnnotations`` at import time.
42
+
43
+ Introspecting the dataclass instead of hardcoding a whitelist ensures that
44
+ when apcore adds new annotation fields the AI Enhancer automatically picks
45
+ them up (still subject to confidence gating). ``extra`` is excluded so the
46
+ SLM cannot inject arbitrary keys via that escape hatch.
47
+
48
+ The order of ``isinstance`` checks matters: ``bool`` is a subclass of
49
+ ``int`` in Python, so a boolean must not be silently accepted as an int
50
+ field.
51
+ """
52
+ hints = typing.get_type_hints(ModuleAnnotations)
53
+ validators: dict[str, Callable[[Any], bool]] = {}
54
+ for field in dataclasses.fields(ModuleAnnotations):
55
+ if field.name in _SLM_EXCLUDED_ANNOTATION_FIELDS:
56
+ continue
57
+ hint = hints.get(field.name)
58
+ origin = typing.get_origin(hint)
59
+ # Strip Optional[X] / X | None — SLM returns concrete values, not None.
60
+ # PEP 604 (X | None) yields types.UnionType; typing.Optional yields typing.Union.
61
+ if origin is typing.Union or origin is types.UnionType:
62
+ args = [a for a in typing.get_args(hint) if a is not type(None)]
63
+ if len(args) == 1:
64
+ hint = args[0]
65
+ origin = typing.get_origin(hint)
66
+ if hint is bool:
67
+ validators[field.name] = lambda v: isinstance(v, bool)
68
+ elif hint is int:
69
+ validators[field.name] = lambda v: isinstance(v, int) and not isinstance(v, bool)
70
+ elif hint is str:
71
+ validators[field.name] = lambda v: isinstance(v, str)
72
+ elif origin in (list, tuple):
73
+ validators[field.name] = lambda v: isinstance(v, list)
74
+ else:
75
+ logger.debug("AIEnhancer: skipping ModuleAnnotations field %r with unsupported type %r", field.name, hint)
76
+ return validators
77
+
78
+
79
+ _ANNOTATION_FIELD_VALIDATORS = _build_annotation_field_validators()
31
80
 
32
81
 
33
82
  class Enhancer(Protocol):
@@ -152,7 +201,7 @@ class AIEnhancer:
152
201
  gaps.append("description")
153
202
  if not module.documentation:
154
203
  gaps.append("documentation")
155
- if module.annotations is None or module.annotations == _DEFAULT_ANNOTATIONS:
204
+ if module.annotations is None or module.annotations == DEFAULT_ANNOTATIONS:
156
205
  gaps.append("annotations")
157
206
  if not module.input_schema.get("properties"):
158
207
  gaps.append("input_schema")
@@ -186,65 +235,26 @@ class AIEnhancer:
186
235
  else:
187
236
  warnings.append(f"Low confidence ({doc_conf:.2f}) for documentation — skipped. Review manually.")
188
237
 
189
- # Apply annotations if above threshold
238
+ # Apply annotations if above threshold. Field set is derived from
239
+ # ModuleAnnotations at import time, so adding new fields upstream
240
+ # automatically widens what the SLM may populate (extra excluded).
190
241
  if "annotations" in gaps and "annotations" in parsed and isinstance(parsed["annotations"], dict):
191
242
  ann_data = parsed["annotations"]
192
243
  ann_conf = parsed.get("confidence", {})
193
244
  accepted: dict[str, Any] = {}
194
- _BOOL_FIELDS = (
195
- "readonly",
196
- "destructive",
197
- "idempotent",
198
- "requires_approval",
199
- "open_world",
200
- "streaming",
201
- "cacheable",
202
- "paginated",
203
- )
204
- for field in _BOOL_FIELDS:
205
- if field in ann_data and isinstance(ann_data[field], bool):
206
- field_conf = ann_conf.get(f"annotations.{field}", ann_conf.get(field, 0.0))
207
- confidence[f"annotations.{field}"] = field_conf
208
- if field_conf >= self.threshold:
209
- accepted[field] = ann_data[field]
210
- else:
211
- warnings.append(
212
- f"Low confidence ({field_conf:.2f}) for annotations.{field} — skipped. Review manually."
213
- )
214
- # Handle non-boolean annotation fields
215
- _INT_FIELDS = ("cache_ttl",)
216
- for field in _INT_FIELDS:
217
- if field in ann_data and isinstance(ann_data[field], int):
218
- field_conf = ann_conf.get(f"annotations.{field}", ann_conf.get(field, 0.0))
219
- confidence[f"annotations.{field}"] = field_conf
220
- if field_conf >= self.threshold:
221
- accepted[field] = ann_data[field]
222
- else:
223
- warnings.append(
224
- f"Low confidence ({field_conf:.2f}) for annotations.{field} — skipped. Review manually."
225
- )
226
- _STR_FIELDS = ("pagination_style",)
227
- for field in _STR_FIELDS:
228
- if field in ann_data and isinstance(ann_data[field], str):
229
- field_conf = ann_conf.get(f"annotations.{field}", ann_conf.get(field, 0.0))
230
- confidence[f"annotations.{field}"] = field_conf
231
- if field_conf >= self.threshold:
232
- accepted[field] = ann_data[field]
233
- else:
234
- warnings.append(
235
- f"Low confidence ({field_conf:.2f}) for annotations.{field} — skipped. Review manually."
236
- )
237
- if "cache_key_fields" in ann_data and isinstance(ann_data["cache_key_fields"], list):
238
- field_conf = ann_conf.get("annotations.cache_key_fields", ann_conf.get("cache_key_fields", 0.0))
239
- confidence["annotations.cache_key_fields"] = field_conf
245
+ for field_name, validate in _ANNOTATION_FIELD_VALIDATORS.items():
246
+ if field_name not in ann_data or not validate(ann_data[field_name]):
247
+ continue
248
+ field_conf = ann_conf.get(f"annotations.{field_name}", ann_conf.get(field_name, 0.0))
249
+ confidence[f"annotations.{field_name}"] = field_conf
240
250
  if field_conf >= self.threshold:
241
- accepted["cache_key_fields"] = ann_data["cache_key_fields"]
251
+ accepted[field_name] = ann_data[field_name]
242
252
  else:
243
253
  warnings.append(
244
- f"Low confidence ({field_conf:.2f}) for annotations.cache_key_fields — skipped. Review manually."
254
+ f"Low confidence ({field_conf:.2f}) for annotations.{field_name} — skipped. Review manually."
245
255
  )
246
256
  if accepted:
247
- base = module.annotations or ModuleAnnotations()
257
+ base = module.annotations or DEFAULT_ANNOTATIONS
248
258
  updates["annotations"] = replace(base, **accepted)
249
259
 
250
260
  # Apply input_schema if above threshold
@@ -85,6 +85,27 @@ def _deep_resolve_refs(
85
85
  return result
86
86
 
87
87
 
88
+ def deep_resolve_refs(
89
+ schema: dict[str, Any],
90
+ openapi_doc: dict[str, Any],
91
+ depth: int = 0,
92
+ ) -> dict[str, Any]:
93
+ """Recursively resolve all ``$ref`` pointers in a schema.
94
+
95
+ Handles nested ``$ref``, ``allOf``, ``anyOf``, ``oneOf``, and ``items``.
96
+ Depth-limited to 16 levels to prevent infinite recursion on circular refs.
97
+
98
+ Args:
99
+ schema: A JSON Schema dict (possibly containing ``$ref`` pointers).
100
+ openapi_doc: The full OpenAPI document dict.
101
+ depth: Current recursion depth (callers should not set this).
102
+
103
+ Returns:
104
+ A new schema dict with all ``$ref`` pointers resolved.
105
+ """
106
+ return _deep_resolve_refs(schema, openapi_doc, depth)
107
+
108
+
88
109
  def extract_input_schema(
89
110
  operation: dict[str, Any],
90
111
  openapi_doc: dict[str, Any] | None = None,
@@ -29,7 +29,7 @@ import re
29
29
  from collections.abc import Callable
30
30
  from typing import Any
31
31
 
32
- from apcore import ModuleAnnotations, Registry
32
+ from apcore import DEFAULT_ANNOTATIONS, Registry
33
33
  from apcore_toolkit.output.types import WriteResult
34
34
  from apcore_toolkit.types import ScannedModule
35
35
 
@@ -104,7 +104,7 @@ class HTTPProxyRegistryWriter:
104
104
  auth_factory = self._auth_header_factory
105
105
  timeout = self._timeout
106
106
 
107
- annotations = mod.annotations or ModuleAnnotations()
107
+ annotations = mod.annotations or DEFAULT_ANNOTATIONS
108
108
 
109
109
  # Raw dict schemas are auto-wrapped by Registry._ensure_schema_adapter
110
110
  # (apcore >= 0.13.1) during register(), so no manual wrapping needed.
@@ -152,11 +152,13 @@ class HTTPProxyRegistryWriter:
152
152
  return resp.json() # type: ignore[no-any-return]
153
153
 
154
154
  error_msg = _extract_error_message(resp)
155
+ from apcore import ErrorCodes
155
156
  from apcore.errors import ModuleError
156
157
 
157
158
  raise ModuleError(
158
- code=f"HTTP_{resp.status_code}",
159
- message=error_msg,
159
+ code=ErrorCodes.MODULE_EXECUTE_ERROR,
160
+ message=f"HTTP {resp.status_code}: {error_msg}",
161
+ details={"http_status": resp.status_code},
160
162
  )
161
163
 
162
164
  ProxyModule.__name__ = mod.module_id.replace(".", "_")
@@ -12,7 +12,7 @@ from abc import ABC, abstractmethod
12
12
  from dataclasses import replace
13
13
  from typing import Any
14
14
 
15
- from apcore import ModuleAnnotations, parse_docstring
15
+ from apcore import DEFAULT_ANNOTATIONS, ModuleAnnotations, parse_docstring
16
16
  from apcore_toolkit.types import ScannedModule
17
17
 
18
18
 
@@ -97,12 +97,12 @@ class BaseScanner(ABC):
97
97
  """
98
98
  method_upper = method.upper()
99
99
  if method_upper == "GET":
100
- return ModuleAnnotations(readonly=True, cacheable=True)
100
+ return replace(DEFAULT_ANNOTATIONS, readonly=True, cacheable=True)
101
101
  elif method_upper == "DELETE":
102
- return ModuleAnnotations(destructive=True)
102
+ return replace(DEFAULT_ANNOTATIONS, destructive=True)
103
103
  elif method_upper == "PUT":
104
- return ModuleAnnotations(idempotent=True)
105
- return ModuleAnnotations()
104
+ return replace(DEFAULT_ANNOTATIONS, idempotent=True)
105
+ return DEFAULT_ANNOTATIONS
106
106
 
107
107
  def deduplicate_ids(self, modules: list[ScannedModule]) -> list[ScannedModule]:
108
108
  """Resolve duplicate module IDs by appending _2, _3, etc.
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from apcore_toolkit.openapi import (
6
6
  _deep_resolve_refs,
7
+ deep_resolve_refs,
7
8
  extract_input_schema,
8
9
  extract_output_schema,
9
10
  resolve_ref,
@@ -168,6 +169,101 @@ class TestDeepResolveRefs:
168
169
  assert OPENAPI_DOC["components"]["schemas"]["Address"]["properties"] == original_props
169
170
 
170
171
 
172
+ class TestDeepResolveRefsPublic:
173
+ """Tests for the public deep_resolve_refs wrapper."""
174
+
175
+ def test_top_level_ref(self) -> None:
176
+ schema = {"$ref": "#/components/schemas/User"}
177
+ result = deep_resolve_refs(schema, OPENAPI_DOC)
178
+ assert result["type"] == "object"
179
+ assert "id" in result["properties"]
180
+ assert "name" in result["properties"]
181
+
182
+ def test_nested_ref_in_properties(self) -> None:
183
+ schema = {
184
+ "type": "object",
185
+ "properties": {
186
+ "address": {"$ref": "#/components/schemas/Address"},
187
+ },
188
+ }
189
+ result = deep_resolve_refs(schema, OPENAPI_DOC)
190
+ assert result["properties"]["address"]["type"] == "object"
191
+ assert "street" in result["properties"]["address"]["properties"]
192
+
193
+ def test_ref_in_allof(self) -> None:
194
+ schema = {
195
+ "allOf": [
196
+ {"$ref": "#/components/schemas/User"},
197
+ {"type": "object", "properties": {"extra": {"type": "boolean"}}},
198
+ ]
199
+ }
200
+ result = deep_resolve_refs(schema, OPENAPI_DOC)
201
+ assert result["allOf"][0]["type"] == "object"
202
+ assert "id" in result["allOf"][0]["properties"]
203
+
204
+ def test_ref_in_anyof(self) -> None:
205
+ schema = {
206
+ "anyOf": [
207
+ {"$ref": "#/components/schemas/User"},
208
+ {"$ref": "#/components/schemas/Address"},
209
+ ]
210
+ }
211
+ result = deep_resolve_refs(schema, OPENAPI_DOC)
212
+ assert "name" in result["anyOf"][0]["properties"]
213
+ assert "street" in result["anyOf"][1]["properties"]
214
+
215
+ def test_ref_in_oneof(self) -> None:
216
+ schema = {
217
+ "oneOf": [
218
+ {"$ref": "#/components/schemas/User"},
219
+ {"$ref": "#/components/schemas/Address"},
220
+ ]
221
+ }
222
+ result = deep_resolve_refs(schema, OPENAPI_DOC)
223
+ assert result["oneOf"][0]["type"] == "object"
224
+ assert "name" in result["oneOf"][0]["properties"]
225
+ assert "city" in result["oneOf"][1]["properties"]
226
+
227
+ def test_ref_in_array_items(self) -> None:
228
+ schema = {"type": "array", "items": {"$ref": "#/components/schemas/User"}}
229
+ result = deep_resolve_refs(schema, OPENAPI_DOC)
230
+ assert result["items"]["type"] == "object"
231
+ assert "id" in result["items"]["properties"]
232
+
233
+ def test_deeply_nested_ref(self) -> None:
234
+ result = deep_resolve_refs({"$ref": "#/components/schemas/UserWithAddress"}, OPENAPI_DOC)
235
+ assert result["properties"]["address"]["type"] == "object"
236
+ assert "street" in result["properties"]["address"]["properties"]
237
+
238
+ def test_circular_ref_depth_limit(self) -> None:
239
+ result = deep_resolve_refs({"$ref": "#/components/schemas/SelfRef"}, OPENAPI_DOC)
240
+ assert result["type"] == "object"
241
+ assert "child" in result["properties"]
242
+
243
+ def test_no_mutation_of_original(self) -> None:
244
+ original_address = OPENAPI_DOC["components"]["schemas"]["Address"]
245
+ original_props = dict(original_address.get("properties", {}))
246
+ deep_resolve_refs({"$ref": "#/components/schemas/UserWithAddress"}, OPENAPI_DOC)
247
+ assert OPENAPI_DOC["components"]["schemas"]["Address"]["properties"] == original_props
248
+
249
+ def test_custom_depth_parameter(self) -> None:
250
+ """Passing depth=17 should short-circuit immediately."""
251
+ schema = {"$ref": "#/components/schemas/User"}
252
+ result = deep_resolve_refs(schema, OPENAPI_DOC, depth=17)
253
+ # Should return the unresolved schema since depth > 16
254
+ assert "$ref" in result
255
+
256
+ def test_plain_schema_no_refs(self) -> None:
257
+ schema = {"type": "string", "description": "A simple string"}
258
+ result = deep_resolve_refs(schema, OPENAPI_DOC)
259
+ assert result == {"type": "string", "description": "A simple string"}
260
+
261
+ def test_importable_from_package(self) -> None:
262
+ from apcore_toolkit import deep_resolve_refs as public_fn
263
+
264
+ assert callable(public_fn)
265
+
266
+
171
267
  class TestExtractInputSchema:
172
268
  def test_query_and_path_params(self) -> None:
173
269
  operation = {