Skip to content

Commit 7ac09da

Browse files
committed
feat: allow passing pre-populated registry and executor to create_cli to bypass filesystem discovery
1 parent 9aee0b5 commit 7ac09da

File tree

6 files changed

+162
-56
lines changed

6 files changed

+162
-56
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88

9+
## [0.5.1] - 2026-04-03
10+
11+
### Added
12+
- **Pre-populated registry support**`create_cli()` accepts optional `registry` and `executor` parameters. When a pre-populated `Registry` is provided, filesystem discovery is skipped entirely. This enables frameworks that register modules at runtime (e.g. apflow's bridge) to generate CLI commands from their existing registry without requiring an extensions directory.
13+
- Passing `registry` alone auto-builds an `Executor`; passing `executor` without `registry` raises `ValueError`.
14+
15+
---
16+
917
## [0.4.1] - 2026-03-30
1018

1119
### Fixed

README.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,14 +92,29 @@ All modules are auto-discovered. CLI flags are auto-generated from each module's
9292
### Programmatic approach (Python API)
9393

9494
```python
95-
from apcore import Registry, Executor
96-
from apcore_cli.__main__ import create_cli
95+
from apcore_cli import create_cli
9796

98-
# Build the CLI from your registry
97+
# Build the CLI from an extensions directory (auto-discovers modules)
9998
cli = create_cli(extensions_dir="./extensions")
10099
cli(standalone_mode=True)
101100
```
102101

102+
#### Pre-populated registry
103+
104+
Frameworks that register modules at runtime (e.g. apflow's bridge) can pass a pre-populated `Registry` directly, skipping filesystem discovery entirely:
105+
106+
```python
107+
from apcore_cli import create_cli
108+
109+
# registry is already populated by your framework
110+
cli = create_cli(registry=registry, prog_name="myapp")
111+
cli(standalone_mode=True)
112+
113+
# Executor is auto-built from the registry if omitted.
114+
# You can also provide your own:
115+
cli = create_cli(registry=registry, executor=executor, prog_name="myapp")
116+
```
117+
103118
Or use the `LazyModuleGroup` directly with Click:
104119

105120
```python

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "apcore-cli"
7-
version = "0.5.0"
7+
version = "0.5.1"
88
description = "Terminal adapter for apcore — execute AI-Perceivable modules from the command line"
99
readme = "README.md"
1010
license = "Apache-2.0"

src/apcore_cli/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,8 @@
2525
)
2626
except (ImportError, AttributeError):
2727
pass # apcore < 0.15.0 or not installed
28+
29+
# Public API re-exports
30+
from apcore_cli.__main__ import create_cli
31+
32+
__all__ = ["__version__", "create_cli"]

src/apcore_cli/__main__.py

Lines changed: 93 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
import os
77
import sys
8+
from typing import Any
89

910
import click
1011

@@ -61,6 +62,8 @@ def create_cli(
6162
prog_name: str | None = None,
6263
commands_dir: str | None = None,
6364
binding_path: str | None = None,
65+
registry: Any | None = None,
66+
executor: Any | None = None,
6467
) -> click.Group:
6568
"""Create the CLI application.
6669
@@ -77,6 +80,12 @@ def create_cli(
7780
binding_path: Path to binding.yaml file or directory for display resolution.
7881
When set, applies DisplayResolver to convention-scanned modules
7982
(requires apcore-toolkit).
83+
registry: Pre-populated apcore Registry instance. When provided, skips
84+
filesystem discovery entirely. Useful for frameworks that register
85+
modules at runtime (e.g. apflow's bridge).
86+
executor: Pre-built apcore Executor instance. When provided alongside
87+
registry, skips Executor construction. If omitted but registry
88+
is provided, an Executor is built from the given registry.
8089
"""
8190
if prog_name is None:
8291
prog_name = os.path.basename(sys.argv[0]) or "apcore-cli"
@@ -121,64 +130,91 @@ def create_cli(
121130
except (TypeError, ValueError):
122131
help_text_max_length = 1000
123132

124-
ext_dir_missing = not os.path.exists(ext_dir)
125-
ext_dir_unreadable = not ext_dir_missing and not os.access(ext_dir, os.R_OK)
133+
if executor is not None and registry is None:
134+
raise ValueError("executor requires registry — pass both or neither")
126135

127-
if ext_dir_missing:
128-
click.echo(
129-
f"Error: Extensions directory not found: '{ext_dir}'. Set APCORE_EXTENSIONS_ROOT or verify the path.",
130-
err=True,
131-
)
132-
sys.exit(EXIT_CONFIG_NOT_FOUND)
133-
134-
if ext_dir_unreadable:
135-
click.echo(
136-
f"Error: Cannot read extensions directory: '{ext_dir}'. Check permissions.",
137-
err=True,
138-
)
139-
sys.exit(EXIT_CONFIG_NOT_FOUND)
136+
if registry is not None:
137+
# Pre-populated registry provided — skip filesystem discovery.
138+
try:
139+
from apcore import Executor as _Executor
140140

141-
try:
142-
from apcore import Executor, Registry
141+
if executor is None:
142+
executor = _Executor(registry)
143+
logger.info("Using pre-populated registry (%d modules).", len(list(registry.list())))
144+
except Exception as e:
145+
click.echo(
146+
f"Error: Failed to initialize executor from provided registry: {e}",
147+
err=True,
148+
)
149+
sys.exit(EXIT_CONFIG_NOT_FOUND)
150+
else:
151+
# Standard path: discover modules from filesystem.
152+
ext_dir_missing = not os.path.exists(ext_dir)
153+
ext_dir_unreadable = not ext_dir_missing and not os.access(ext_dir, os.R_OK)
154+
155+
if ext_dir_missing:
156+
click.echo(
157+
f"Error: Extensions directory not found: '{ext_dir}'. Set APCORE_EXTENSIONS_ROOT or verify the path.",
158+
err=True,
159+
)
160+
sys.exit(EXIT_CONFIG_NOT_FOUND)
161+
162+
if ext_dir_unreadable:
163+
click.echo(
164+
f"Error: Cannot read extensions directory: '{ext_dir}'. Check permissions.",
165+
err=True,
166+
)
167+
sys.exit(EXIT_CONFIG_NOT_FOUND)
143168

144-
registry = Registry(extensions_dir=ext_dir)
145169
try:
146-
logger.debug("Loading extensions from %s", ext_dir)
147-
count = registry.discover()
148-
logger.info("Initialized apcore-cli with %d modules.", count)
149-
except Exception as e:
150-
logger.warning("Discovery failed: %s", e)
170+
from apcore import Executor as _Executor
171+
from apcore import Registry as _Registry
151172

152-
# Convention module discovery
153-
if commands_dir is not None:
173+
registry = _Registry(extensions_dir=ext_dir)
154174
try:
155-
from apcore_toolkit import RegistryWriter
156-
from apcore_toolkit.convention_scanner import ConventionScanner
157-
158-
conv_scanner = ConventionScanner()
159-
conv_modules = conv_scanner.scan(commands_dir)
160-
if conv_modules:
161-
if binding_path is not None:
162-
try:
163-
from apcore_toolkit import DisplayResolver
164-
165-
display_resolver = DisplayResolver()
166-
conv_modules = display_resolver.resolve(conv_modules, binding_path=binding_path)
167-
logger.info("DisplayResolver: applied binding from %s", binding_path)
168-
except ImportError:
169-
logger.warning("DisplayResolver not available in apcore-toolkit")
170-
writer = RegistryWriter()
171-
writer.write(conv_modules, registry)
172-
logger.info("Convention scanner: registered %d modules from %s", len(conv_modules), commands_dir)
173-
except ImportError:
174-
logger.warning("apcore-toolkit not installed — convention module scanning unavailable")
175+
logger.debug("Loading extensions from %s", ext_dir)
176+
count = registry.discover()
177+
logger.info("Initialized apcore-cli with %d modules.", count)
175178
except Exception as e:
176-
logger.warning("Convention module scanning failed: %s", e)
177-
178-
executor = Executor(registry)
179-
except Exception as e:
180-
click.echo(f"Error: Failed to initialize registry: {e}", err=True)
181-
sys.exit(EXIT_CONFIG_NOT_FOUND)
179+
logger.warning("Discovery failed: %s", e)
180+
181+
# Convention module discovery
182+
if commands_dir is not None:
183+
try:
184+
from apcore_toolkit import RegistryWriter
185+
from apcore_toolkit.convention_scanner import ConventionScanner
186+
187+
conv_scanner = ConventionScanner()
188+
conv_modules = conv_scanner.scan(commands_dir)
189+
if conv_modules:
190+
if binding_path is not None:
191+
try:
192+
from apcore_toolkit import DisplayResolver
193+
194+
display_resolver = DisplayResolver()
195+
conv_modules = display_resolver.resolve(conv_modules, binding_path=binding_path)
196+
logger.info(
197+
"DisplayResolver: applied binding from %s",
198+
binding_path,
199+
)
200+
except ImportError:
201+
logger.warning("DisplayResolver not available in apcore-toolkit")
202+
writer = RegistryWriter()
203+
writer.write(conv_modules, registry)
204+
logger.info(
205+
"Convention scanner: registered %d modules from %s",
206+
len(conv_modules),
207+
commands_dir,
208+
)
209+
except ImportError:
210+
logger.warning("apcore-toolkit not installed — convention module scanning unavailable")
211+
except Exception as e:
212+
logger.warning("Convention module scanning failed: %s", e)
213+
214+
executor = _Executor(registry)
215+
except Exception as e:
216+
click.echo(f"Error: Failed to initialize registry: {e}", err=True)
217+
sys.exit(EXIT_CONFIG_NOT_FOUND)
182218

183219
# Initialize audit logger
184220
try:
@@ -277,7 +313,12 @@ def main(prog_name: str | None = None) -> None:
277313
ext_dir = _extract_extensions_dir()
278314
cmd_dir = _extract_commands_dir()
279315
bind_path = _extract_binding_path()
280-
cli = create_cli(extensions_dir=ext_dir, prog_name=prog_name, commands_dir=cmd_dir, binding_path=bind_path)
316+
cli = create_cli(
317+
extensions_dir=ext_dir,
318+
prog_name=prog_name,
319+
commands_dir=cmd_dir,
320+
binding_path=bind_path,
321+
)
281322
cli(standalone_mode=True)
282323

283324

tests/test_integration.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,3 +310,40 @@ def test_create_cli_override_bypasses_config(self, tmp_path):
310310
with pytest.raises(SystemExit) as exc_info:
311311
create_cli(extensions_dir="/nonexistent")
312312
assert exc_info.value.code == 47
313+
314+
315+
class TestPrePopulatedRegistry:
316+
"""Tests for create_cli with pre-populated registry parameter."""
317+
318+
def test_create_cli_with_registry_skips_filesystem(self):
319+
"""When registry is provided, no filesystem discovery occurs."""
320+
from apcore_cli.__main__ import create_cli
321+
322+
registry = MagicMock()
323+
registry.list.return_value = ["test.module"]
324+
325+
executor = MagicMock()
326+
327+
# Should NOT exit 47 even though no extensions dir exists.
328+
cli_group = create_cli(registry=registry, executor=executor, prog_name="test-cli")
329+
assert cli_group is not None
330+
331+
def test_create_cli_with_registry_auto_builds_executor(self):
332+
"""When registry is provided without executor, an Executor is auto-built."""
333+
from apcore_cli.__main__ import create_cli
334+
335+
registry = MagicMock()
336+
registry.list.return_value = []
337+
338+
# Executor is omitted — should be auto-built from registry.
339+
cli_group = create_cli(registry=registry, prog_name="test-cli")
340+
assert cli_group is not None
341+
342+
def test_create_cli_executor_without_registry_raises(self):
343+
"""Passing executor without registry is a usage error."""
344+
from apcore_cli.__main__ import create_cli
345+
346+
executor = MagicMock()
347+
348+
with pytest.raises(ValueError, match="executor requires registry"):
349+
create_cli(executor=executor, prog_name="test-cli")

0 commit comments

Comments
 (0)