apcore-toolkit 0.3.0__tar.gz → 0.4.0__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.3.0 → apcore_toolkit-0.4.0}/CHANGELOG.md +38 -0
  2. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/PKG-INFO +85 -10
  3. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/README.md +79 -4
  4. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/pyproject.toml +6 -6
  5. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/src/apcore_toolkit/__init__.py +4 -0
  6. apcore_toolkit-0.4.0/src/apcore_toolkit/convention_scanner.py +215 -0
  7. apcore_toolkit-0.4.0/src/apcore_toolkit/display/__init__.py +10 -0
  8. apcore_toolkit-0.4.0/src/apcore_toolkit/display/resolver.py +227 -0
  9. apcore_toolkit-0.4.0/tests/test_convention_scanner.py +118 -0
  10. apcore_toolkit-0.4.0/tests/test_display_resolver.py +419 -0
  11. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/.github/CODEOWNERS +0 -0
  12. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/.github/copilot-ignore +0 -0
  13. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/.github/workflows/ci.yml +0 -0
  14. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/.gitignore +0 -0
  15. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/.gitmessage +0 -0
  16. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/.pre-commit-config.yaml +0 -0
  17. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/src/apcore_toolkit/ai_enhancer.py +0 -0
  18. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/src/apcore_toolkit/formatting/__init__.py +0 -0
  19. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/src/apcore_toolkit/formatting/markdown.py +0 -0
  20. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/src/apcore_toolkit/openapi.py +0 -0
  21. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/src/apcore_toolkit/output/__init__.py +0 -0
  22. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/src/apcore_toolkit/output/errors.py +0 -0
  23. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/src/apcore_toolkit/output/http_proxy_writer.py +0 -0
  24. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/src/apcore_toolkit/output/python_writer.py +0 -0
  25. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/src/apcore_toolkit/output/registry_writer.py +0 -0
  26. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/src/apcore_toolkit/output/types.py +0 -0
  27. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/src/apcore_toolkit/output/verifiers.py +0 -0
  28. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/src/apcore_toolkit/output/yaml_writer.py +0 -0
  29. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/src/apcore_toolkit/pydantic_utils.py +0 -0
  30. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/src/apcore_toolkit/scanner.py +0 -0
  31. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/src/apcore_toolkit/schema_utils.py +0 -0
  32. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/src/apcore_toolkit/serializers.py +0 -0
  33. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/src/apcore_toolkit/types.py +0 -0
  34. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/tests/conftest.py +0 -0
  35. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/tests/test_ai_enhancer.py +0 -0
  36. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/tests/test_http_proxy_writer.py +0 -0
  37. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/tests/test_markdown.py +0 -0
  38. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/tests/test_openapi.py +0 -0
  39. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/tests/test_output_factory.py +0 -0
  40. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/tests/test_pydantic_utils.py +0 -0
  41. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/tests/test_python_writer.py +0 -0
  42. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/tests/test_registry_writer.py +0 -0
  43. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/tests/test_scanner.py +0 -0
  44. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/tests/test_schema_utils.py +0 -0
  45. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/tests/test_serializers.py +0 -0
  46. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/tests/test_types.py +0 -0
  47. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/tests/test_verifiers.py +0 -0
  48. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/tests/test_write_error.py +0 -0
  49. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/tests/test_write_result.py +0 -0
  50. {apcore_toolkit-0.3.0 → apcore_toolkit-0.4.0}/tests/test_yaml_writer.py +0 -0
@@ -2,6 +2,44 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.4.0] - 2026-03-23
6
+
7
+ ### Added
8
+
9
+ - **`DisplayResolver`** (`apcore_toolkit.display`) — sparse binding.yaml display overlay (§5.13). Merges per-surface presentation fields (alias, description, guidance, tags, documentation) into `ScannedModule.metadata["display"]` for downstream CLI/MCP/A2A surfaces.
10
+ - Resolution chain per field: surface-specific override > `display` default > binding-level field > scanner value.
11
+ - `resolve(modules, *, binding_path=..., binding_data=...)` — accepts pre-parsed dict or a path to a `.binding.yaml` file / directory of `*.binding.yaml` files. `binding_data` takes precedence over `binding_path`.
12
+ - MCP alias auto-sanitization: replaces characters outside `[a-zA-Z0-9_-]` with `_`; prepends `_` if result starts with a digit.
13
+ - MCP alias hard limit: raises `ValueError` if sanitized alias exceeds 64 characters.
14
+ - CLI alias validation: warns and falls back to `display.alias` when user-explicitly-set alias does not match `^[a-z][a-z0-9_-]*$` (module_id fallback always accepted without warning).
15
+ - `suggested_alias` in `ScannedModule.metadata` (emitted by `simplify_ids=True` scanner) used as fallback when no `display.alias` is set.
16
+ - Match-count logging: `INFO` for match count, `WARNING` when binding map loaded but zero modules matched.
17
+ - **`DisplayResolver` public export** — available as `from apcore_toolkit.display import DisplayResolver` and `from apcore_toolkit import DisplayResolver`.
18
+
19
+ ### Tests
20
+
21
+ - 30 new tests in `tests/test_display_resolver.py` covering: no-binding fallthrough, alias-only overlay, surface-specific overrides, MCP sanitization, MCP 64-char limit, `suggested_alias` fallback, sparse overlay (10 modules / 1 binding), tags resolution, `binding_path` file and directory loading, guidance chain, CLI invalid alias warning and fallback, `binding_data` vs `binding_path` precedence.
22
+
23
+ ### Added (Convention Module Discovery — §5.14)
24
+
25
+ - **`ConventionScanner`** — scans a `commands/` directory of plain Python files for public functions and converts them to `ScannedModule` instances with schema inferred from type annotations.
26
+ - Module ID: `{file_prefix}.{function_name}` with `MODULE_PREFIX` override.
27
+ - Description from first line of docstring (`"(no description)"` fallback).
28
+ - `input_schema` / `output_schema` inferred from PEP 484 type hints.
29
+ - `CLI_GROUP` and `TAGS` module-level constants stored in metadata.
30
+ - `include` / `exclude` regex filters on module IDs.
31
+ - **`ConventionScanner` public export** — available as `from apcore_toolkit import ConventionScanner`.
32
+
33
+ ### Tests (Convention Module Discovery)
34
+
35
+ - 15 new tests in `tests/test_convention_scanner.py`.
36
+
37
+ ---
38
+ ## [0.3.1] - 2026-03-22
39
+
40
+ ### Changed
41
+ - Rebrand: aipartnerup → aiperceivable
42
+
5
43
  ## [0.3.0] - 2026-03-19
6
44
 
7
45
  ### Added
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apcore-toolkit
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Shared scanner, schema extraction, and output toolkit for apcore framework adapters
5
- Project-URL: Homepage, https://aipartnerup.com
6
- Project-URL: Repository, https://github.com/aipartnerup/apcore-toolkit-python
7
- Project-URL: Documentation, https://github.com/aipartnerup/apcore-toolkit-python#readme
8
- Project-URL: Issues, https://github.com/aipartnerup/apcore-toolkit-python/issues
9
- Author-email: aipartnerup <tercel.yi@gmail.com>
5
+ Project-URL: Homepage, https://aiperceivable.com
6
+ Project-URL: Repository, https://github.com/aiperceivable/apcore-toolkit-python
7
+ Project-URL: Documentation, https://github.com/aiperceivable/apcore-toolkit-python#readme
8
+ Project-URL: Issues, https://github.com/aiperceivable/apcore-toolkit-python/issues
9
+ 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
@@ -25,12 +25,12 @@ Requires-Dist: httpx>=0.24; extra == 'http-proxy'
25
25
  Description-Content-Type: text/markdown
26
26
 
27
27
  <div align="center">
28
- <img src="https://raw.githubusercontent.com/aipartnerup/apcore-toolkit/main/apcore-toolkit-logo.svg" alt="apcore-toolkit logo" width="200"/>
28
+ <img src="https://raw.githubusercontent.com/aiperceivable/apcore-toolkit/main/apcore-toolkit-logo.svg" alt="apcore-toolkit logo" width="200"/>
29
29
  </div>
30
30
 
31
31
  # apcore-toolkit-python
32
32
 
33
- Python implementation of the [apcore-toolkit](https://github.com/aipartnerup/apcore-toolkit).
33
+ Python implementation of the [apcore-toolkit](https://github.com/aiperceivable/apcore-toolkit).
34
34
 
35
35
  Extracts ~1,400 lines of duplicated framework-agnostic logic from `django-apcore` and `flask-apcore` into a standalone Python package.
36
36
 
@@ -67,6 +67,8 @@ pip install apcore-toolkit
67
67
  | `resolve_target` | Resolves "module.path:function_name" to callable |
68
68
  | `enrich_schema_descriptions` | Merges descriptions into JSON Schema properties |
69
69
  | `get_writer` | Factory function for writer instances |
70
+ | `DisplayResolver` | Sparse binding.yaml display overlay — resolves surface-facing alias, description, guidance, tags into `metadata["display"]` (§5.13) |
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) |
70
72
 
71
73
  ## Usage
72
74
 
@@ -162,16 +164,89 @@ from apcore_toolkit import to_markdown
162
164
  md = to_markdown({"name": "Alice", "role": "admin"}, title="User Info")
163
165
  ```
164
166
 
167
+ ### Display Overlay (§5.13)
168
+
169
+ `DisplayResolver` applies a sparse `binding.yaml` display overlay to a list of `ScannedModule` instances, populating `metadata["display"]` with surface-facing presentation fields (alias, description, guidance, tags) for CLI, MCP, and A2A surfaces.
170
+
171
+ ```python
172
+ from apcore_toolkit.display import DisplayResolver
173
+
174
+ resolver = DisplayResolver()
175
+
176
+ # Apply overlay from a directory of *.binding.yaml files
177
+ modules = resolver.resolve(scanned_modules, binding_path="bindings/")
178
+
179
+ # Or from a pre-parsed dict
180
+ modules = resolver.resolve(
181
+ scanned_modules,
182
+ binding_data={
183
+ "bindings": [
184
+ {
185
+ "module_id": "product.get",
186
+ "display": {
187
+ "alias": "product-get",
188
+ "description": "Get a product by ID",
189
+ "cli": {"alias": "get-product"},
190
+ "mcp": {"alias": "get_product"},
191
+ },
192
+ }
193
+ ]
194
+ },
195
+ )
196
+
197
+ # Resolved fields are in metadata["display"]
198
+ mod = modules[0]
199
+ print(mod.metadata["display"]["cli"]["alias"]) # "get-product"
200
+ print(mod.metadata["display"]["mcp"]["alias"]) # "get_product"
201
+ print(mod.metadata["display"]["a2a"]["alias"]) # "product-get"
202
+ ```
203
+
204
+ **Resolution chain** (per field): surface-specific override > `display` default > binding-level field > scanner value.
205
+
206
+ **MCP alias constraints**: automatically sanitized (non-`[a-zA-Z0-9_-]` chars replaced with `_`; leading digit prefixed with `_`); raises `ValueError` if result exceeds 64 characters.
207
+
208
+ **CLI alias validation**: warns and falls back to `display.alias` when a user-explicitly-set alias does not match `^[a-z][a-z0-9_-]*$`.
209
+
210
+ ### Convention Module Discovery (§5.14)
211
+
212
+ `ConventionScanner` scans a directory of plain Python files for public functions and converts them to `ScannedModule` instances. No decorators, no base classes, no imports from apcore -- just functions with type hints.
213
+
214
+ ```python
215
+ from apcore_toolkit import ConventionScanner
216
+
217
+ scanner = ConventionScanner()
218
+ modules = scanner.scan(commands_dir="commands/")
219
+
220
+ # Each public function becomes a module:
221
+ # commands/deploy.py -> deploy.deploy
222
+ # commands/deploy.py -> deploy.rollback (if rollback() exists)
223
+ ```
224
+
225
+ Module-level constants customize behavior:
226
+
227
+ ```python
228
+ # commands/deploy.py
229
+ MODULE_PREFIX = "ops" # override file-based prefix -> ops.deploy
230
+ CLI_GROUP = "operations" # group hint for CLI surface
231
+ TAGS = ["infra", "deploy"] # tags stored in metadata
232
+
233
+ def deploy(env: str, tag: str = "latest") -> dict:
234
+ """Deploy the app to the given environment."""
235
+ return {"status": "deployed", "env": env}
236
+ ```
237
+
238
+ Input and output schemas are inferred from PEP 484 type annotations. Use `include` / `exclude` regex filters to control which module IDs are registered.
239
+
165
240
  ## Requirements
166
241
 
167
242
  - Python >= 3.11
168
- - apcore >= 0.13.0
243
+ - apcore >= 0.13.1
169
244
  - pydantic >= 2.0
170
245
  - PyYAML >= 6.0
171
246
 
172
247
  ## Documentation
173
248
 
174
- Full documentation is available at [https://github.com/aipartnerup/apcore-toolkit](https://github.com/aipartnerup/apcore-toolkit).
249
+ Full documentation is available at [https://github.com/aiperceivable/apcore-toolkit](https://github.com/aiperceivable/apcore-toolkit).
175
250
 
176
251
  ## License
177
252
 
@@ -1,10 +1,10 @@
1
1
  <div align="center">
2
- <img src="https://raw.githubusercontent.com/aipartnerup/apcore-toolkit/main/apcore-toolkit-logo.svg" alt="apcore-toolkit logo" width="200"/>
2
+ <img src="https://raw.githubusercontent.com/aiperceivable/apcore-toolkit/main/apcore-toolkit-logo.svg" alt="apcore-toolkit logo" width="200"/>
3
3
  </div>
4
4
 
5
5
  # apcore-toolkit-python
6
6
 
7
- Python implementation of the [apcore-toolkit](https://github.com/aipartnerup/apcore-toolkit).
7
+ Python implementation of the [apcore-toolkit](https://github.com/aiperceivable/apcore-toolkit).
8
8
 
9
9
  Extracts ~1,400 lines of duplicated framework-agnostic logic from `django-apcore` and `flask-apcore` into a standalone Python package.
10
10
 
@@ -41,6 +41,8 @@ pip install apcore-toolkit
41
41
  | `resolve_target` | Resolves "module.path:function_name" to callable |
42
42
  | `enrich_schema_descriptions` | Merges descriptions into JSON Schema properties |
43
43
  | `get_writer` | Factory function for writer instances |
44
+ | `DisplayResolver` | Sparse binding.yaml display overlay — resolves surface-facing alias, description, guidance, tags into `metadata["display"]` (§5.13) |
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) |
44
46
 
45
47
  ## Usage
46
48
 
@@ -136,16 +138,89 @@ from apcore_toolkit import to_markdown
136
138
  md = to_markdown({"name": "Alice", "role": "admin"}, title="User Info")
137
139
  ```
138
140
 
141
+ ### Display Overlay (§5.13)
142
+
143
+ `DisplayResolver` applies a sparse `binding.yaml` display overlay to a list of `ScannedModule` instances, populating `metadata["display"]` with surface-facing presentation fields (alias, description, guidance, tags) for CLI, MCP, and A2A surfaces.
144
+
145
+ ```python
146
+ from apcore_toolkit.display import DisplayResolver
147
+
148
+ resolver = DisplayResolver()
149
+
150
+ # Apply overlay from a directory of *.binding.yaml files
151
+ modules = resolver.resolve(scanned_modules, binding_path="bindings/")
152
+
153
+ # Or from a pre-parsed dict
154
+ modules = resolver.resolve(
155
+ scanned_modules,
156
+ binding_data={
157
+ "bindings": [
158
+ {
159
+ "module_id": "product.get",
160
+ "display": {
161
+ "alias": "product-get",
162
+ "description": "Get a product by ID",
163
+ "cli": {"alias": "get-product"},
164
+ "mcp": {"alias": "get_product"},
165
+ },
166
+ }
167
+ ]
168
+ },
169
+ )
170
+
171
+ # Resolved fields are in metadata["display"]
172
+ mod = modules[0]
173
+ print(mod.metadata["display"]["cli"]["alias"]) # "get-product"
174
+ print(mod.metadata["display"]["mcp"]["alias"]) # "get_product"
175
+ print(mod.metadata["display"]["a2a"]["alias"]) # "product-get"
176
+ ```
177
+
178
+ **Resolution chain** (per field): surface-specific override > `display` default > binding-level field > scanner value.
179
+
180
+ **MCP alias constraints**: automatically sanitized (non-`[a-zA-Z0-9_-]` chars replaced with `_`; leading digit prefixed with `_`); raises `ValueError` if result exceeds 64 characters.
181
+
182
+ **CLI alias validation**: warns and falls back to `display.alias` when a user-explicitly-set alias does not match `^[a-z][a-z0-9_-]*$`.
183
+
184
+ ### Convention Module Discovery (§5.14)
185
+
186
+ `ConventionScanner` scans a directory of plain Python files for public functions and converts them to `ScannedModule` instances. No decorators, no base classes, no imports from apcore -- just functions with type hints.
187
+
188
+ ```python
189
+ from apcore_toolkit import ConventionScanner
190
+
191
+ scanner = ConventionScanner()
192
+ modules = scanner.scan(commands_dir="commands/")
193
+
194
+ # Each public function becomes a module:
195
+ # commands/deploy.py -> deploy.deploy
196
+ # commands/deploy.py -> deploy.rollback (if rollback() exists)
197
+ ```
198
+
199
+ Module-level constants customize behavior:
200
+
201
+ ```python
202
+ # commands/deploy.py
203
+ MODULE_PREFIX = "ops" # override file-based prefix -> ops.deploy
204
+ CLI_GROUP = "operations" # group hint for CLI surface
205
+ TAGS = ["infra", "deploy"] # tags stored in metadata
206
+
207
+ def deploy(env: str, tag: str = "latest") -> dict:
208
+ """Deploy the app to the given environment."""
209
+ return {"status": "deployed", "env": env}
210
+ ```
211
+
212
+ Input and output schemas are inferred from PEP 484 type annotations. Use `include` / `exclude` regex filters to control which module IDs are registered.
213
+
139
214
  ## Requirements
140
215
 
141
216
  - Python >= 3.11
142
- - apcore >= 0.13.0
217
+ - apcore >= 0.13.1
143
218
  - pydantic >= 2.0
144
219
  - PyYAML >= 6.0
145
220
 
146
221
  ## Documentation
147
222
 
148
- Full documentation is available at [https://github.com/aipartnerup/apcore-toolkit](https://github.com/aipartnerup/apcore-toolkit).
223
+ Full documentation is available at [https://github.com/aiperceivable/apcore-toolkit](https://github.com/aiperceivable/apcore-toolkit).
149
224
 
150
225
  ## License
151
226
 
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "apcore-toolkit"
7
- version = "0.3.0"
7
+ version = "0.4.0"
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"
11
11
  license = "Apache-2.0"
12
12
  authors = [
13
- { name = "aipartnerup", email = "tercel.yi@gmail.com" },
13
+ { name = "aiperceivable", email = "tercel.yi@gmail.com" },
14
14
  ]
15
15
  keywords = [
16
16
  "apcore",
@@ -40,10 +40,10 @@ dev = [
40
40
  ]
41
41
 
42
42
  [project.urls]
43
- Homepage = "https://aipartnerup.com"
44
- Repository = "https://github.com/aipartnerup/apcore-toolkit-python"
45
- Documentation = "https://github.com/aipartnerup/apcore-toolkit-python#readme"
46
- Issues = "https://github.com/aipartnerup/apcore-toolkit-python/issues"
43
+ Homepage = "https://aiperceivable.com"
44
+ Repository = "https://github.com/aiperceivable/apcore-toolkit-python"
45
+ Documentation = "https://github.com/aiperceivable/apcore-toolkit-python#readme"
46
+ Issues = "https://github.com/aiperceivable/apcore-toolkit-python/issues"
47
47
 
48
48
  [tool.hatch.build.targets.wheel]
49
49
  packages = ["src/apcore_toolkit"]
@@ -6,6 +6,7 @@ Public API re-exports for convenient access to core types and utilities.
6
6
  from importlib.metadata import PackageNotFoundError
7
7
  from importlib.metadata import version as _get_version
8
8
  from apcore_toolkit.ai_enhancer import AIEnhancer, Enhancer
9
+ from apcore_toolkit.display import DisplayResolver
9
10
  from apcore_toolkit.formatting import to_markdown
10
11
  from apcore_toolkit.openapi import (
11
12
  extract_input_schema,
@@ -29,6 +30,7 @@ from apcore_toolkit.output.verifiers import (
29
30
  )
30
31
  from apcore_toolkit.output.yaml_writer import YAMLWriter
31
32
  from apcore_toolkit.pydantic_utils import flatten_pydantic_params, resolve_target
33
+ from apcore_toolkit.convention_scanner import ConventionScanner
32
34
  from apcore_toolkit.scanner import BaseScanner
33
35
  from apcore_toolkit.schema_utils import enrich_schema_descriptions
34
36
  from apcore_toolkit.serializers import annotations_to_dict, module_to_dict, modules_to_dicts
@@ -41,7 +43,9 @@ except PackageNotFoundError:
41
43
 
42
44
  __all__ = [
43
45
  "AIEnhancer",
46
+ "DisplayResolver",
44
47
  "BaseScanner",
48
+ "ConventionScanner",
45
49
  "HTTPProxyRegistryWriter",
46
50
  "Enhancer",
47
51
  "JSONVerifier",
@@ -0,0 +1,215 @@
1
+ """Convention Module Scanner — discovers plain functions as apcore modules (§5.14)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import inspect
7
+ import logging
8
+ import re
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any, get_type_hints
12
+
13
+ from apcore_toolkit.types import ScannedModule
14
+
15
+ logger = logging.getLogger("apcore_toolkit")
16
+
17
+ # Type hint → JSON Schema mapping (subset of §5.11.5)
18
+ _TYPE_MAP: dict[type, dict[str, Any]] = {
19
+ str: {"type": "string"},
20
+ int: {"type": "integer"},
21
+ float: {"type": "number"},
22
+ bool: {"type": "boolean"},
23
+ list: {"type": "array"},
24
+ dict: {"type": "object"},
25
+ }
26
+
27
+
28
+ class ConventionScanner:
29
+ """Scan a directory of plain Python files for public functions.
30
+
31
+ Converts each discovered function into a ScannedModule with
32
+ schema inferred from type annotations and description from docstrings.
33
+
34
+ Usage::
35
+
36
+ scanner = ConventionScanner()
37
+ modules = scanner.scan("commands/")
38
+ """
39
+
40
+ def scan(
41
+ self,
42
+ commands_dir: str | Path,
43
+ *,
44
+ include: str | None = None,
45
+ exclude: str | None = None,
46
+ ) -> list[ScannedModule]:
47
+ """Scan a commands directory for convention modules.
48
+
49
+ Args:
50
+ commands_dir: Path to the commands directory.
51
+ include: Regex pattern to include module IDs.
52
+ exclude: Regex pattern to exclude module IDs.
53
+
54
+ Returns:
55
+ List of ScannedModule instances.
56
+ """
57
+ commands_path = Path(commands_dir)
58
+ if not commands_path.is_dir():
59
+ logger.warning("ConventionScanner: commands directory not found: %s", commands_path)
60
+ return []
61
+
62
+ modules: list[ScannedModule] = []
63
+ for py_file in sorted(commands_path.rglob("*.py")):
64
+ if py_file.name.startswith("_"):
65
+ continue
66
+ try:
67
+ file_modules = self._scan_file(py_file, commands_path)
68
+ modules.extend(file_modules)
69
+ except Exception as exc:
70
+ logger.warning("ConventionScanner: failed to scan %s: %s", py_file, exc)
71
+
72
+ # Apply include/exclude filters
73
+ if include:
74
+ pattern = re.compile(include)
75
+ modules = [m for m in modules if pattern.search(m.module_id)]
76
+ if exclude:
77
+ pattern = re.compile(exclude)
78
+ modules = [m for m in modules if not pattern.search(m.module_id)]
79
+
80
+ logger.info("ConventionScanner: discovered %d modules from %s", len(modules), commands_path)
81
+ return modules
82
+
83
+ def _scan_file(self, py_file: Path, base_dir: Path) -> list[ScannedModule]:
84
+ """Scan a single Python file for public functions."""
85
+ # Derive prefix from relative path
86
+ rel = py_file.relative_to(base_dir)
87
+ parts = list(rel.with_suffix("").parts)
88
+ file_prefix = ".".join(parts)
89
+
90
+ # Load the module
91
+ spec = importlib.util.spec_from_file_location(f"_convention_{file_prefix}", py_file)
92
+ if spec is None or spec.loader is None:
93
+ return []
94
+
95
+ mod = importlib.util.module_from_spec(spec)
96
+ # Add parent directory to sys.path temporarily for imports
97
+ parent = str(py_file.parent)
98
+ added_path = parent not in sys.path
99
+ if added_path:
100
+ sys.path.insert(0, parent)
101
+ try:
102
+ spec.loader.exec_module(mod)
103
+ finally:
104
+ if added_path and parent in sys.path:
105
+ sys.path.remove(parent)
106
+
107
+ # Read module-level constants
108
+ module_prefix = getattr(mod, "MODULE_PREFIX", None)
109
+ cli_group = getattr(mod, "CLI_GROUP", None)
110
+ file_tags = getattr(mod, "TAGS", None) or []
111
+
112
+ prefix = module_prefix if isinstance(module_prefix, str) else file_prefix
113
+
114
+ # Discover public functions
115
+ results: list[ScannedModule] = []
116
+ for name, obj in inspect.getmembers(mod, inspect.isfunction):
117
+ # Skip private functions and imported functions
118
+ if name.startswith("_"):
119
+ continue
120
+ if getattr(obj, "__module__", None) != mod.__name__:
121
+ continue
122
+
123
+ module_id = f"{prefix}.{name}"
124
+ description = self._extract_description(obj)
125
+ input_schema = self._build_input_schema(obj)
126
+ output_schema = self._build_output_schema(obj)
127
+
128
+ metadata: dict[str, Any] = {}
129
+ if isinstance(cli_group, str):
130
+ metadata.setdefault("display", {}).setdefault("cli", {})["group"] = cli_group
131
+
132
+ results.append(
133
+ ScannedModule(
134
+ module_id=module_id,
135
+ description=description,
136
+ input_schema=input_schema,
137
+ output_schema=output_schema,
138
+ tags=list(file_tags),
139
+ target=f"{py_file}:{name}",
140
+ metadata=metadata,
141
+ )
142
+ )
143
+
144
+ return results
145
+
146
+ def _extract_description(self, func: Any) -> str:
147
+ """Extract first line of docstring as description."""
148
+ doc = inspect.getdoc(func)
149
+ if not doc:
150
+ return "(no description)"
151
+ return doc.split("\n")[0].strip()
152
+
153
+ def _build_input_schema(self, func: Any) -> dict[str, Any]:
154
+ """Build JSON Schema from function parameter type hints."""
155
+ try:
156
+ hints = get_type_hints(func)
157
+ except Exception:
158
+ hints = {}
159
+
160
+ sig = inspect.signature(func)
161
+ properties: dict[str, Any] = {}
162
+ required: list[str] = []
163
+
164
+ for param_name, param in sig.parameters.items():
165
+ if param_name in ("self", "cls", "ctx", "context"):
166
+ continue
167
+
168
+ type_hint = hints.get(param_name)
169
+ prop = self._type_to_schema(type_hint)
170
+
171
+ if param.default is inspect.Parameter.empty:
172
+ required.append(param_name)
173
+ else:
174
+ if param.default is not None:
175
+ prop["default"] = param.default
176
+
177
+ properties[param_name] = prop
178
+
179
+ schema: dict[str, Any] = {"type": "object", "properties": properties}
180
+ if required:
181
+ schema["required"] = required
182
+ return schema
183
+
184
+ def _build_output_schema(self, func: Any) -> dict[str, Any]:
185
+ """Build output schema from return type hint."""
186
+ try:
187
+ hints = get_type_hints(func)
188
+ except Exception:
189
+ return {}
190
+
191
+ ret = hints.get("return")
192
+ if ret is None or ret is type(None):
193
+ return {}
194
+ return self._type_to_schema(ret)
195
+
196
+ def _type_to_schema(self, type_hint: Any) -> dict[str, Any]:
197
+ """Convert a Python type hint to JSON Schema."""
198
+ if type_hint is None:
199
+ return {"type": "string"}
200
+
201
+ # Direct type mapping
202
+ if type_hint in _TYPE_MAP:
203
+ return dict(_TYPE_MAP[type_hint])
204
+
205
+ # Handle Optional[X] / X | None
206
+ origin = getattr(type_hint, "__origin__", None)
207
+ args = getattr(type_hint, "__args__", None)
208
+
209
+ if origin is list and args:
210
+ return {"type": "array", "items": self._type_to_schema(args[0])}
211
+ if origin is dict:
212
+ return {"type": "object"}
213
+
214
+ # Fallback
215
+ return {"type": "string"}
@@ -0,0 +1,10 @@
1
+ """Display overlay resolver for surface-facing presentation.
2
+
3
+ Implements §5.13 of the apcore PROTOCOL_SPEC: sparse binding.yaml display
4
+ section that controls how modules appear in CLI, MCP, and A2A surfaces
5
+ without changing the canonical module_id.
6
+ """
7
+
8
+ from apcore_toolkit.display.resolver import DisplayResolver
9
+
10
+ __all__ = ["DisplayResolver"]