apcore-cli 0.12.1__tar.gz → 0.12.3__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 (109) hide show
  1. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/CHANGELOG.md +23 -0
  2. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/PKG-INFO +4 -4
  3. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/README.md +1 -1
  4. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/pyproject.toml +3 -3
  5. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/init_cmd.py +9 -1
  6. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_cli.py +37 -0
  7. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_init_cmd.py +8 -0
  8. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_openapi_cmd.py +96 -33
  9. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_openapi_source.py +4 -2
  10. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/.github/CODEOWNERS +0 -0
  11. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/.github/copilot-ignore +0 -0
  12. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/.github/workflows/ci.yml +0 -0
  13. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/.gitignore +0 -0
  14. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/.gitmessage +0 -0
  15. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/.pre-commit-config.yaml +0 -0
  16. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/CLAUDE.md +0 -0
  17. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/LICENSE +0 -0
  18. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/Makefile +0 -0
  19. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/examples/README.md +0 -0
  20. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/examples/extensions/math/add.py +0 -0
  21. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/examples/extensions/math/multiply.py +0 -0
  22. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/examples/extensions/sysutil/disk.py +0 -0
  23. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/examples/extensions/sysutil/env.py +0 -0
  24. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/examples/extensions/sysutil/info.py +0 -0
  25. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/examples/extensions/text/reverse.py +0 -0
  26. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/examples/extensions/text/upper.py +0 -0
  27. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/examples/extensions/text/wordcount.py +0 -0
  28. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/examples/run_examples.sh +0 -0
  29. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/planning/approval-gate.md +0 -0
  30. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/planning/config-resolver.md +0 -0
  31. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/planning/core-dispatcher.md +0 -0
  32. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/planning/discovery.md +0 -0
  33. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/planning/exposure-filtering.md +0 -0
  34. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/planning/grouped-commands.md +0 -0
  35. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/planning/output-formatter.md +0 -0
  36. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/planning/overview.md +0 -0
  37. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/planning/schema-parser.md +0 -0
  38. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/planning/security-manager.md +0 -0
  39. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/planning/shell-integration.md +0 -0
  40. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/planning/state.json +0 -0
  41. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/__init__.py +0 -0
  42. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/__main__.py +0 -0
  43. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/_sandbox_runner.py +0 -0
  44. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/acl_cmd.py +0 -0
  45. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/acl_loader.py +0 -0
  46. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/approval.py +0 -0
  47. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/builtin_group.py +0 -0
  48. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/cli.py +0 -0
  49. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/config.py +0 -0
  50. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/discovery.py +0 -0
  51. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/display_helpers.py +0 -0
  52. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/exit_codes.py +0 -0
  53. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/exposure.py +0 -0
  54. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/factory.py +0 -0
  55. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/openapi_cmd.py +0 -0
  56. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/openapi_source.py +0 -0
  57. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/output.py +0 -0
  58. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/ref_resolver.py +0 -0
  59. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/schema_parser.py +0 -0
  60. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/security/__init__.py +0 -0
  61. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/security/audit.py +0 -0
  62. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/security/auth.py +0 -0
  63. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/security/config_encryptor.py +0 -0
  64. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/security/sandbox.py +0 -0
  65. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/shell.py +0 -0
  66. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/strategy.py +0 -0
  67. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/system_cmd.py +0 -0
  68. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/system_usage.py +0 -0
  69. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/src/apcore_cli/validate.py +0 -0
  70. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/__init__.py +0 -0
  71. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/conformance/__init__.py +0 -0
  72. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/conformance/test_apcli_visibility.py +0 -0
  73. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/conformance/test_snake_case_kwargs.py +0 -0
  74. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/conftest.py +0 -0
  75. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/shell_test_utils.py +0 -0
  76. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_acl_cmd.py +0 -0
  77. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_acl_loader.py +0 -0
  78. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_apcli_integration.py +0 -0
  79. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_approval.py +0 -0
  80. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_bugfixes.py +0 -0
  81. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_builtin_group.py +0 -0
  82. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_config.py +0 -0
  83. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_discovery.py +0 -0
  84. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_discovery_fe13.py +0 -0
  85. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_display_helpers.py +0 -0
  86. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_e2e.py +0 -0
  87. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_exit_codes.py +0 -0
  88. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_exposure.py +0 -0
  89. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_factory_fe13.py +0 -0
  90. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_integration.py +0 -0
  91. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_list_command_filters.py +0 -0
  92. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_output.py +0 -0
  93. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_output_format_exec.py +0 -0
  94. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_output_format_markdown_skill.py +0 -0
  95. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_public_api.py +0 -0
  96. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_ref_resolver.py +0 -0
  97. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_sandbox_runner.py +0 -0
  98. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_schema_parser.py +0 -0
  99. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_security/__init__.py +0 -0
  100. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_security/test_audit.py +0 -0
  101. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_security/test_auth.py +0 -0
  102. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_security/test_config_encryptor.py +0 -0
  103. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_security/test_sandbox.py +0 -0
  104. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_shell.py +0 -0
  105. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_strategy.py +0 -0
  106. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_system_cmd.py +0 -0
  107. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_system_usage.py +0 -0
  108. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_toolkit_integration.py +0 -0
  109. {apcore_cli-0.12.1 → apcore_cli-0.12.3}/tests/test_validate.py +0 -0
@@ -5,6 +5,29 @@ All notable changes to apcore-cli (Python SDK) will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.12.3] - 2026-10-10
9
+
10
+ ### Changed
11
+
12
+ - Upgrade the required apcore floor to 0.32.0 and apcore-toolkit to 0.14.0. Public executor, loader, scanner, display and formatter interfaces consumed by this CLI remain compatible.
13
+ - Binding documents now inherit toolkit 0.14.0's stricter validation: unknown document/entry keys and one-sided inline schema pairs fail in both modes; non-list `tags` fail in strict mode. Move application-specific entry data into `metadata` and supply both inline schemas. Runtime-only `auto_schema` / `schema_ref` entries require apcore's runtime loader, which this CLI's toolkit integration does not use.
14
+
15
+ ### Fixed
16
+
17
+ - `apcli init module --style binding` emits paired inline schemas instead of `auto_schema: true`, making generated scaffolds load through toolkit 0.14.0 in loose and strict modes. The schemas describe the generated no-argument function and its `status: string` result.
18
+
19
+ ### Compatibility verification
20
+
21
+ - Native apcore validators exercise the canonical `details.errors` shape (`path`, `keyword`, `message`) through JSON and terminal error output, covering root `""`, nested-array `/items/0/count` and escaped `/a~1b~0c` pointers. CLI exit remains 45. The CLI never reads a legacy `field` member and its JSON output preserves details verbatim.
22
+ - Governance-provider/gate and logging fixes, immediate scoped timeouts, collect-all preflight, binding runtime validation, namespace dispatch, watcher/reload, task/store, nested-call and middleware/observability changes run inside apcore; no additional CLI call-site adaptation is required. MCP export-profile changes and the additive bare-name canonicalization API are not consumed here. OpenAPI generation already supplies both inline schemas.
23
+ - Full suite: **1024 passed, 0 failed, 6 xfailed** against published PyPI apcore 0.32.0 and apcore-toolkit 0.14.0; Ruff lint and changed-file formatting pass.
24
+
25
+ ## [0.12.2] - 2026-09-29
26
+
27
+ ### Changed — BREAKING
28
+
29
+ - **`apcli openapi scan` / `generate` module IDs follow apcore-toolkit 0.13.0's normalisation; the toolkit floor is now `apcore-toolkit[http-proxy]>=0.13.0`.** The CLI passes the scanner's IDs through unchanged, so it inherits the toolkit's new derivation: IDs derived from camelCase or hyphenated names are now snake_case — `createPets` → `create_pets`, `showPetById` → `show_pet_by_id` — and `generate`'s `.binding.yaml` file names change with them (`createPets.binding.yaml` → `create_pets.binding.yaml`). An `operationId`-derived camelCase ID used to be emitted verbatim and was rejected by apcore's registry when the generated binding was loaded; it is now loadable for the first time. Path-derived IDs with a camelCase parameter change too, although they were already loadable (`/pets/{petId}`: `pets.petid.get` → `pets.pet_id.get`). A legal ID is never rewritten, and a `--prefix` value is normalised with the rest of the ID (`Pet-Store` → `pet_store`). **Migration:** `--include` / `--exclude` patterns and scripts keyed on the old IDs or file names need updating; the filters match the emitted, normalised ID. A segment that still begins with a digit (`POST /v1/2fa` → `v1.2fa.post`) is not repaired: the module is still emitted and carries the toolkit's legality warning, which `scan` renders like any other scanner warning (exit stays `0`; pinned by new test T-OAPI-28). No CLI code changed; 23 tests re-pinned to the new IDs.
30
+
8
31
  ## [0.12.1] - 2026-09-24
9
32
 
10
33
  Patch release. Bumps the required `apcore` floor to `0.31.0` and `apcore-toolkit` to `[http-proxy]>=0.12.0`. All 1021 tests pass (6 xfailed).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: apcore-cli
3
- Version: 0.12.1
3
+ Version: 0.12.3
4
4
  Summary: Terminal adapter for apcore — execute AI-Perceivable modules from the command line
5
5
  Project-URL: Homepage, https://aiperceivable.com
6
6
  Project-URL: Repository, https://github.com/aiperceivable/apcore-cli-python
@@ -21,8 +21,8 @@ Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
22
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
23
  Requires-Python: >=3.11
24
- Requires-Dist: apcore-toolkit[http-proxy]>=0.12.0
25
- Requires-Dist: apcore>=0.31.0
24
+ Requires-Dist: apcore-toolkit[http-proxy]>=0.14.0
25
+ Requires-Dist: apcore>=0.32.0
26
26
  Requires-Dist: click>=8.1
27
27
  Requires-Dist: cryptography>=41.0
28
28
  Requires-Dist: jsonschema>=4.20
@@ -89,7 +89,7 @@ Terminal adapter for apcore. Execute AI-Perceivable modules from the command lin
89
89
  pip install apcore-cli
90
90
  ```
91
91
 
92
- Requires Python 3.11+, `apcore >= 0.21.0`, and `apcore-toolkit >= 0.10.0` (now a **required** runtime dependency as of v0.9.0 — previously optional; see the [tech-design ADR-09](https://github.com/aiperceivable/apcore-cli/blob/main/docs/tech-design.md) for the byte-equivalent toolkit-delegated tier rationale). The `[toolkit]` extras group is retained as a no-op for backward compat:
92
+ Requires Python 3.11+, `apcore >= 0.32.0`, and `apcore-toolkit >= 0.14.0` (now a **required** runtime dependency as of v0.9.0 — previously optional; see the [tech-design ADR-09](https://github.com/aiperceivable/apcore-cli/blob/main/docs/tech-design.md) for the byte-equivalent toolkit-delegated tier rationale). The `[toolkit]` extras group is retained as a no-op for backward compat:
93
93
 
94
94
  ```bash
95
95
  pip install "apcore-cli[toolkit]" # equivalent to plain `pip install apcore-cli`
@@ -48,7 +48,7 @@ Terminal adapter for apcore. Execute AI-Perceivable modules from the command lin
48
48
  pip install apcore-cli
49
49
  ```
50
50
 
51
- Requires Python 3.11+, `apcore >= 0.21.0`, and `apcore-toolkit >= 0.10.0` (now a **required** runtime dependency as of v0.9.0 — previously optional; see the [tech-design ADR-09](https://github.com/aiperceivable/apcore-cli/blob/main/docs/tech-design.md) for the byte-equivalent toolkit-delegated tier rationale). The `[toolkit]` extras group is retained as a no-op for backward compat:
51
+ Requires Python 3.11+, `apcore >= 0.32.0`, and `apcore-toolkit >= 0.14.0` (now a **required** runtime dependency as of v0.9.0 — previously optional; see the [tech-design ADR-09](https://github.com/aiperceivable/apcore-cli/blob/main/docs/tech-design.md) for the byte-equivalent toolkit-delegated tier rationale). The `[toolkit]` extras group is retained as a no-op for backward compat:
52
52
 
53
53
  ```bash
54
54
  pip install "apcore-cli[toolkit]" # equivalent to plain `pip install apcore-cli`
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "apcore-cli"
7
- version = "0.12.1"
7
+ version = "0.12.3"
8
8
  description = "Terminal adapter for apcore — execute AI-Perceivable modules from the command line"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -26,8 +26,8 @@ classifiers = [
26
26
  "Environment :: Console",
27
27
  ]
28
28
  dependencies = [
29
- "apcore>=0.31.0",
30
- "apcore-toolkit[http-proxy]>=0.12.0",
29
+ "apcore>=0.32.0",
30
+ "apcore-toolkit[http-proxy]>=0.14.0",
31
31
  "click>=8.1",
32
32
  "jsonschema>=4.20",
33
33
  "rich>=13.0",
@@ -39,7 +39,15 @@ _BINDING_TEMPLATE = """bindings:
39
39
  - module_id: "{module_id}"
40
40
  target: "{target}"
41
41
  description: "{description}"
42
- auto_schema: true
42
+ input_schema:
43
+ type: object
44
+ properties: {{}}
45
+ output_schema:
46
+ type: object
47
+ properties:
48
+ status:
49
+ type: string
50
+ required: [status]
43
51
  """
44
52
 
45
53
 
@@ -1351,3 +1351,40 @@ class TestBuildModuleCommandRefResolverErrorTranslation:
1351
1351
  with pytest.raises(SystemExit) as excinfo:
1352
1352
  build_module_command(module_def, executor)
1353
1353
  assert excinfo.value.code == 48
1354
+
1355
+
1356
+ @pytest.mark.parametrize("tty", [False, True])
1357
+ def test_native_validation_errors_preserve_json_pointers(tty, capsys):
1358
+ import json
1359
+
1360
+ from apcore.errors import SchemaValidationError
1361
+ from apcore.schema import SchemaValidator
1362
+ from pydantic import Field, create_model
1363
+
1364
+ from apcore_cli.cli import _emit_error_json, _emit_error_tty
1365
+
1366
+ item = create_model("ValidationItem", count=(int, ...))
1367
+ model = create_model(
1368
+ "ValidationInput",
1369
+ count=(int, ...),
1370
+ items=(list[item], ...),
1371
+ escaped=(int, Field(alias="a/b~c")),
1372
+ )
1373
+ with pytest.raises(SchemaValidationError) as caught:
1374
+ SchemaValidator().validate_input({"items": [{"count": "bad"}], "a/b~c": "bad"}, model)
1375
+ error = caught.value
1376
+ assert error.code == "SCHEMA_VALIDATION_ERROR"
1377
+ emit = _emit_error_tty if tty else _emit_error_json
1378
+ emit(error, 45)
1379
+ output = capsys.readouterr().err
1380
+ if tty:
1381
+ assert "/items/0/count" in output
1382
+ assert "/a~1b~0c" in output
1383
+ assert "unknown" not in output
1384
+ else:
1385
+ payload = json.loads(output)
1386
+ assert payload["exit_code"] == 45
1387
+ assert payload["details"] == error.details
1388
+ errors = payload["details"]["errors"]
1389
+ assert {entry["path"] for entry in errors} == {"", "/items/0/count", "/a~1b~0c"}
1390
+ assert all(set(entry) == {"path", "keyword", "message"} for entry in errors)
@@ -45,6 +45,14 @@ class TestInitModule:
45
45
  assert len(yaml_files) == 1
46
46
  content = yaml_files[0].read_text()
47
47
  assert "ops.deploy" in content
48
+ from apcore_toolkit import BindingLoader
49
+
50
+ for strict in (False, True):
51
+ modules = BindingLoader().load(str(yaml_files[0]), strict=strict)
52
+ assert len(modules) == 1
53
+ assert modules[0].module_id == "ops.deploy"
54
+ assert modules[0].input_schema == {"type": "object", "properties": {}}
55
+ assert modules[0].output_schema["properties"]["status"] == {"type": "string"}
48
56
 
49
57
  def test_init_convention_has_cli_group(self, cli, tmp_path):
50
58
  runner = CliRunner()
@@ -1,9 +1,11 @@
1
1
  """FE-15a ``apcli openapi`` subcommand group.
2
2
 
3
- Covers T-OAPI-01..14, 18..27 — everything the CLI layer owns. Module-ID
3
+ Covers T-OAPI-01..14, 18..28 — everything the CLI layer owns. Module-ID
4
4
  derivation itself is the toolkit's conformance corpus and is deliberately not
5
5
  re-tested here (§1.2): these assertions check that the CLI forwards options
6
- verbatim and renders what comes back.
6
+ verbatim and renders what comes back. The IDs pinned below are the ones
7
+ apcore-toolkit >= 0.13.0 derives, normalised into apcore's Canonical ID
8
+ alphabet (``createPets`` -> ``create_pets``, ``DELETE /pets/{petId}`` -> ``pets.pet_id.delete``).
7
9
  """
8
10
 
9
11
  from __future__ import annotations
@@ -63,6 +65,28 @@ info:
63
65
  paths: {}
64
66
  """
65
67
 
68
+ #: A path segment beginning with a digit: the one case apcore-toolkit 0.13.0's
69
+ #: normalisation does not repair. No operationId, so the ID is path-derived.
70
+ _TWO_FA = """\
71
+ openapi: "3.1.0"
72
+ info:
73
+ title: TwoFactor
74
+ version: "1.0.0"
75
+ paths:
76
+ /v1/2fa:
77
+ post:
78
+ summary: Enable 2FA
79
+ responses:
80
+ "200":
81
+ description: ok
82
+ """
83
+
84
+ #: The toolkit's pinned legality warning for ``POST /v1/2fa``, byte for byte.
85
+ _TWO_FA_WARNING = (
86
+ "module_id 'v1.2fa.post' is not a legal apcore module ID: segment '2fa' must match "
87
+ "^[a-z][a-z0-9_]*$; name this operation with a derive_module_id or transform_module hook"
88
+ )
89
+
66
90
 
67
91
  @pytest.fixture
68
92
  def cli() -> click.Group:
@@ -107,16 +131,26 @@ class TestOpenapiScan:
107
131
  json_path.write_text(json.dumps(doc), encoding="utf-8")
108
132
  assert _scan_json(cli, str(json_path))["modules"] == _scan_json(cli, petstore)["modules"]
109
133
 
110
- def test_operation_id_case_is_preserved(self, cli, petstore):
111
- """T-OAPI-03: the CLI never post-processes the toolkit's IDs."""
134
+ def test_ids_equal_toolkit_derive_module_id(self, cli, petstore):
135
+ """T-OAPI-03: IDs equal the toolkit's derive_module_id output, unmodified by the CLI."""
136
+ from apcore_toolkit import derive_module_id
137
+
112
138
  ids = [m["module_id"] for m in _scan_json(cli, petstore)["modules"]]
113
- assert "listPets" in ids
114
- assert "createPets" in ids
139
+ doc = yaml.safe_load(_PETSTORE)
140
+ expected = [
141
+ derive_module_id(path, method, operation)
142
+ for path, item in doc["paths"].items()
143
+ for method, operation in item.items()
144
+ ]
145
+ assert ids == expected
146
+ # operationId `listPets` / `createPets`, snake_cased by the toolkit.
147
+ assert "list_pets" in ids
148
+ assert "create_pets" in ids
115
149
 
116
150
  def test_path_and_method_algorithm_without_operation_id(self, cli, petstore):
117
151
  """T-OAPI-04."""
118
152
  ids = [m["module_id"] for m in _scan_json(cli, petstore)["modules"]]
119
- assert "pets.petid.delete" in ids
153
+ assert "pets.pet_id.delete" in ids
120
154
 
121
155
  def test_prefix_is_applied(self, cli, petstore):
122
156
  """T-OAPI-05."""
@@ -126,11 +160,12 @@ class TestOpenapiScan:
126
160
  def test_include_filter(self, cli, petstore):
127
161
  """T-OAPI-06."""
128
162
  ids = [m["module_id"] for m in _scan_json(cli, petstore, "--include", "^pets")["modules"]]
129
- assert ids == ["pets.petid.delete"]
163
+ assert ids == ["pets.pet_id.delete"]
130
164
 
131
165
  def test_exclude_filter(self, cli, petstore):
132
166
  ids = [m["module_id"] for m in _scan_json(cli, petstore, "--exclude", "^pets")["modules"]]
133
- assert "pets.petid.delete" not in ids
167
+ assert "pets.pet_id.delete" not in ids
168
+ assert ids == ["list_pets", "create_pets", "show_pet_by_id"]
134
169
 
135
170
  @pytest.mark.parametrize("flag", ["--include", "--exclude"])
136
171
  def test_invalid_regex_exits_2(self, cli, petstore, flag):
@@ -141,9 +176,12 @@ class TestOpenapiScan:
141
176
 
142
177
  def test_no_deprecated_omits_deprecated_operations(self, cli, petstore):
143
178
  """T-OAPI-08."""
179
+ # Without the flag the deprecated operation is present, so its absence
180
+ # below is the flag's doing rather than a mismatched name.
181
+ assert "show_pet_by_id" in [m["module_id"] for m in _scan_json(cli, petstore)["modules"]]
144
182
  ids = [m["module_id"] for m in _scan_json(cli, petstore, "--no-deprecated")["modules"]]
145
- assert "showPetById" not in ids
146
- assert "listPets" in ids
183
+ assert "show_pet_by_id" not in ids
184
+ assert "list_pets" in ids
147
185
 
148
186
  def test_string_false_deprecated_is_not_deprecated(self, cli, tmp_path):
149
187
  """T-OAPI-09: `deprecated: "false"` is a malformed boolean, not True."""
@@ -163,12 +201,12 @@ class TestOpenapiScan:
163
201
  path = tmp_path / "o.json"
164
202
  path.write_text(json.dumps(doc), encoding="utf-8")
165
203
  ids = [m["module_id"] for m in _scan_json(cli, str(path), "--no-deprecated")["modules"]]
166
- assert ids == ["opX"]
204
+ assert ids == ["op_x"]
167
205
 
168
206
  def test_no_2xx_response_warns_but_keeps_the_module(self, cli, petstore):
169
207
  """T-OAPI-10."""
170
208
  payload = _scan_json(cli, petstore)
171
- delete = next(m for m in payload["modules"] if m["module_id"] == "pets.petid.delete")
209
+ delete = next(m for m in payload["modules"] if m["module_id"] == "pets.pet_id.delete")
172
210
  assert any("no 2xx response" in w for w in delete["warnings"])
173
211
 
174
212
  def test_external_ref_warning_is_rendered_and_not_fetched(self, cli, tmp_path):
@@ -214,7 +252,7 @@ class TestOpenapiScan:
214
252
  assert all("warnings" in m for m in payload["modules"])
215
253
  assert payload["hazards"] == [
216
254
  {
217
- "module_id": "createPets",
255
+ "module_id": "create_pets",
218
256
  "http_method": "POST",
219
257
  "url_path": "/pets",
220
258
  "parameters": ["dryRun"],
@@ -226,13 +264,13 @@ class TestOpenapiScan:
226
264
  """T-OAPI-14: ScannedModule goes straight to format_modules."""
227
265
  result = CliRunner().invoke(cli, ["openapi", "scan", petstore, "--format", style])
228
266
  assert result.exit_code == 0
229
- assert "listPets" in result.output
267
+ assert "list_pets" in result.output
230
268
 
231
269
  @pytest.mark.parametrize("style", ["csv", "yaml", "jsonl"])
232
270
  def test_tabular_styles_render(self, cli, petstore, style):
233
271
  result = CliRunner().invoke(cli, ["openapi", "scan", petstore, "--format", style])
234
272
  assert result.exit_code == 0
235
- assert "listPets" in result.output
273
+ assert "list_pets" in result.output
236
274
 
237
275
  def test_table_renders_banner_warnings_and_hazards(self, cli, petstore):
238
276
  result = CliRunner().invoke(cli, ["openapi", "scan", petstore], env={"COLUMNS": "200"})
@@ -242,12 +280,31 @@ class TestOpenapiScan:
242
280
  assert "GET /pets" in result.output
243
281
  assert "1 warning" in result.output
244
282
  assert "cannot be proxied by FE-15b" in result.output
245
- assert "createPets" in result.output
283
+ assert "create_pets" in result.output
246
284
 
247
285
  def test_hazards_do_not_change_the_exit_code(self, cli, petstore):
248
286
  """A partially-understood document is still a successful scan."""
249
287
  assert CliRunner().invoke(cli, ["openapi", "scan", petstore]).exit_code == 0
250
288
 
289
+ def test_illegal_id_warning_is_rendered_verbatim(self, cli, tmp_path):
290
+ """T-OAPI-28: a segment beginning with a digit is not repaired by the
291
+ toolkit; the module is still listed and its legality warning is
292
+ rendered like any other scanner warning, byte for byte, exit 0."""
293
+ path = tmp_path / "openapi.yaml"
294
+ path.write_text(_TWO_FA, encoding="utf-8")
295
+
296
+ table = CliRunner().invoke(cli, ["openapi", "scan", str(path)], env={"COLUMNS": "200"})
297
+ assert table.exit_code == 0, table.output
298
+ listing, _, warnings_block = table.output.partition("\n1 warning:\n")
299
+ assert "v1.2fa.post" in listing
300
+ # The whole line, so a renderer that ate `[a-z]` as markup would fail.
301
+ assert warnings_block.splitlines()[0] == " v1.2fa.post " + _TWO_FA_WARNING
302
+
303
+ payload = _scan_json(cli, str(path))
304
+ assert payload["modules"][0]["module_id"] == "v1.2fa.post"
305
+ assert payload["modules"][0]["warnings"] == [_TWO_FA_WARNING]
306
+ assert payload["hazards"] == []
307
+
251
308
 
252
309
  # ---------------------------------------------------------------------------
253
310
  # apcli openapi generate (§4.4)
@@ -262,10 +319,10 @@ class TestOpenapiGenerate:
262
319
  assert result.exit_code == 0, result.output
263
320
  written = sorted(p.name for p in out.glob("*.binding.yaml"))
264
321
  assert written == [
265
- "createPets.binding.yaml",
266
- "listPets.binding.yaml",
267
- "pets.petid.delete.binding.yaml",
268
- "showPetById.binding.yaml",
322
+ "create_pets.binding.yaml",
323
+ "list_pets.binding.yaml",
324
+ "pets.pet_id.delete.binding.yaml",
325
+ "show_pet_by_id.binding.yaml",
269
326
  ]
270
327
 
271
328
  def test_dry_run_lists_paths_and_writes_nothing(self, cli, petstore, tmp_path):
@@ -273,7 +330,7 @@ class TestOpenapiGenerate:
273
330
  out = tmp_path / "out"
274
331
  result = CliRunner().invoke(cli, ["openapi", "generate", petstore, "-o", str(out), "--dry-run"])
275
332
  assert result.exit_code == 0
276
- assert "listPets.binding.yaml" in result.output
333
+ assert "list_pets.binding.yaml" in result.output
277
334
  assert not out.exists()
278
335
 
279
336
  def test_artifact_carries_an_intact_routing_contract(self, cli, petstore, tmp_path):
@@ -283,14 +340,15 @@ class TestOpenapiGenerate:
283
340
  out = tmp_path / "out"
284
341
  CliRunner().invoke(cli, ["openapi", "generate", petstore, "-o", str(out)])
285
342
 
286
- raw = yaml.safe_load((out / "createPets.binding.yaml").read_text(encoding="utf-8"))
343
+ raw = yaml.safe_load((out / "create_pets.binding.yaml").read_text(encoding="utf-8"))
287
344
  binding = raw["bindings"][0]
288
345
  assert binding["target"] == "POST /pets"
289
346
  assert binding["metadata"]["http_method"] == "POST"
290
347
  assert binding["metadata"]["url_path"] == "/pets"
348
+ # The document's raw operationId, verbatim — only the module ID is normalised.
291
349
  assert binding["metadata"]["openapi"]["operation_id"] == "createPets"
292
350
 
293
- reloaded = BindingLoader().load(str(out / "createPets.binding.yaml"))
351
+ reloaded = BindingLoader().load(str(out / "create_pets.binding.yaml"))
294
352
  assert reloaded[0].metadata["http_method"] == "POST"
295
353
  assert reloaded[0].metadata["url_path"] == "/pets"
296
354
 
@@ -304,19 +362,24 @@ class TestOpenapiGenerate:
304
362
  """T-OAPI-21: non-destructive default, matching `apcli init`."""
305
363
  out = tmp_path / "out"
306
364
  CliRunner().invoke(cli, ["openapi", "generate", petstore, "-o", str(out)])
307
- target = out / "listPets.binding.yaml"
365
+ target = out / "list_pets.binding.yaml"
366
+ # The first run wrote this very file, so the sentinel below sits on a
367
+ # name the second run will actually try to write.
368
+ assert target.is_file()
308
369
  target.write_text("SENTINEL", encoding="utf-8")
309
370
 
310
371
  result = CliRunner().invoke(cli, ["openapi", "generate", petstore, "-o", str(out)])
311
372
  assert result.exit_code == 0
312
373
  assert "already exists" in result.output
374
+ assert f"{target} already exists" in result.output
313
375
  assert target.read_text(encoding="utf-8") == "SENTINEL"
314
376
 
315
377
  def test_force_overwrites(self, cli, petstore, tmp_path):
316
378
  """T-OAPI-22."""
317
379
  out = tmp_path / "out"
318
380
  CliRunner().invoke(cli, ["openapi", "generate", petstore, "-o", str(out)])
319
- target = out / "listPets.binding.yaml"
381
+ target = out / "list_pets.binding.yaml"
382
+ assert target.is_file()
320
383
  target.write_text("SENTINEL", encoding="utf-8")
321
384
 
322
385
  result = CliRunner().invoke(cli, ["openapi", "generate", petstore, "-o", str(out), "--force"])
@@ -392,9 +455,9 @@ class TestOpenapiGenerate:
392
455
  gen = CliRunner().invoke(
393
456
  cli, ["openapi", "generate", petstore, "-o", str(tmp_path / "out")], env={"COLUMNS": "200"}
394
457
  )
395
- assert "createPets" in scan.output
458
+ assert "create_pets" in scan.output
396
459
  assert "cannot be proxied by FE-15b" in gen.output
397
- assert "createPets" in gen.output
460
+ assert "create_pets" in gen.output
398
461
 
399
462
  def test_missing_source_exits_47(self, cli, tmp_path):
400
463
  result = CliRunner().invoke(
@@ -531,18 +594,18 @@ class TestOpenapiGenerateDryRunAndVerification:
531
594
  def test_dry_run_does_not_list_a_preexisting_file_as_would_write(self, cli, petstore, tmp_path):
532
595
  out = tmp_path / "out"
533
596
  out.mkdir()
534
- (out / "listPets.binding.yaml").write_text("SENTINEL", encoding="utf-8")
597
+ (out / "list_pets.binding.yaml").write_text("SENTINEL", encoding="utf-8")
535
598
 
536
599
  result = CliRunner().invoke(cli, ["openapi", "generate", petstore, "-o", str(out), "--dry-run"])
537
600
  assert result.exit_code == 0, result.output
538
601
  would_write_lines = [line for line in result.output.splitlines() if line.startswith("Would write")]
539
- assert not any("listPets.binding.yaml" in line for line in would_write_lines)
540
- assert any("createPets.binding.yaml" in line for line in would_write_lines)
602
+ assert not any("list_pets.binding.yaml" in line for line in would_write_lines)
603
+ assert any("create_pets.binding.yaml" in line for line in would_write_lines)
541
604
  assert "3 file(s) would be written" in result.output
542
605
  assert "1 skipped" in result.output
543
606
  # --dry-run still writes nothing.
544
- assert (out / "listPets.binding.yaml").read_text(encoding="utf-8") == "SENTINEL"
545
- assert not (out / "createPets.binding.yaml").exists()
607
+ assert (out / "list_pets.binding.yaml").read_text(encoding="utf-8") == "SENTINEL"
608
+ assert not (out / "create_pets.binding.yaml").exists()
546
609
 
547
610
  def test_verification_failure_warns_and_fails_and_is_excluded_from_count(
548
611
  self, cli, petstore, tmp_path, monkeypatch
@@ -180,7 +180,7 @@ class TestDetectProxyHazards:
180
180
  hazards = detect_proxy_hazards(spec, modules)
181
181
  assert len(hazards) == 1
182
182
  hazard = hazards[0]
183
- assert hazard.module_id == "createPets"
183
+ assert hazard.module_id == "create_pets"
184
184
  assert hazard.http_method == "POST"
185
185
  assert hazard.url_path == "/pets"
186
186
  assert hazard.parameters == ("dryRun", "notify")
@@ -301,7 +301,9 @@ class TestDetectProxyHazards:
301
301
  def test_filtered_out_operations_produce_no_hazard(self, tmp_path):
302
302
  """An operation with no module is nothing FE-15b could misroute."""
303
303
  spec = load_openapi_source(_write(tmp_path, "o.yaml", _PETSTORE_YAML))
304
- modules = OpenAPIScanner().scan(spec, include="^listPets$")
304
+ modules = OpenAPIScanner().scan(spec, include="^list_pets$")
305
+ # The filter kept GET /pets and dropped POST /pets, the one hazard.
306
+ assert [m.module_id for m in modules] == ["list_pets"]
305
307
  assert detect_proxy_hazards(spec, modules) == []
306
308
 
307
309
  def test_empty_and_non_dict_specs_are_safe(self):
File without changes
File without changes
File without changes
File without changes
File without changes