conduit-py 0.0.1__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 (64) hide show
  1. conduit_py-0.0.1/.gitignore +111 -0
  2. conduit_py-0.0.1/Dockerfile +24 -0
  3. conduit_py-0.0.1/PKG-INFO +31 -0
  4. conduit_py-0.0.1/README.md +13 -0
  5. conduit_py-0.0.1/pyproject.toml +33 -0
  6. conduit_py-0.0.1/src/conduit/__init__.py +68 -0
  7. conduit_py-0.0.1/src/conduit/cli/__init__.py +69 -0
  8. conduit_py-0.0.1/src/conduit/cli/_console.py +112 -0
  9. conduit_py-0.0.1/src/conduit/cli/_display.py +140 -0
  10. conduit_py-0.0.1/src/conduit/cli/_loader.py +69 -0
  11. conduit_py-0.0.1/src/conduit/cli/call.py +231 -0
  12. conduit_py-0.0.1/src/conduit/cli/list.py +127 -0
  13. conduit_py-0.0.1/src/conduit/cli/run.py +75 -0
  14. conduit_py-0.0.1/src/conduit/cli/server.py +245 -0
  15. conduit_py-0.0.1/src/conduit/config.py +57 -0
  16. conduit_py-0.0.1/src/conduit/engine/__init__.py +34 -0
  17. conduit_py-0.0.1/src/conduit/engine/cron.py +87 -0
  18. conduit_py-0.0.1/src/conduit/engine/event_router.py +149 -0
  19. conduit_py-0.0.1/src/conduit/engine/function_identity.py +45 -0
  20. conduit_py-0.0.1/src/conduit/engine/handlers/__init__.py +4 -0
  21. conduit_py-0.0.1/src/conduit/engine/handlers/event_handle.py +268 -0
  22. conduit_py-0.0.1/src/conduit/engine/handlers/task_handle.py +198 -0
  23. conduit_py-0.0.1/src/conduit/engine/job_processor.py +796 -0
  24. conduit_py-0.0.1/src/conduit/engine/queue/__init__.py +19 -0
  25. conduit_py-0.0.1/src/conduit/engine/queue/base.py +478 -0
  26. conduit_py-0.0.1/src/conduit/engine/queue/redis/__init__.py +6 -0
  27. conduit_py-0.0.1/src/conduit/engine/queue/redis/constants.py +28 -0
  28. conduit_py-0.0.1/src/conduit/engine/queue/redis/fetcher.py +90 -0
  29. conduit_py-0.0.1/src/conduit/engine/queue/redis/finisher.py +145 -0
  30. conduit_py-0.0.1/src/conduit/engine/queue/redis/queue.py +1215 -0
  31. conduit_py-0.0.1/src/conduit/engine/queue/redis/scripts/cancel.lua +47 -0
  32. conduit_py-0.0.1/src/conduit/engine/queue/redis/scripts/enqueue.lua +55 -0
  33. conduit_py-0.0.1/src/conduit/engine/queue/redis/scripts/finish.lua +59 -0
  34. conduit_py-0.0.1/src/conduit/engine/queue/redis/scripts/retry.lua +52 -0
  35. conduit_py-0.0.1/src/conduit/engine/queue/redis/scripts/sweep.lua +51 -0
  36. conduit_py-0.0.1/src/conduit/engine/queue/redis/sweeper.py +192 -0
  37. conduit_py-0.0.1/src/conduit/engine/redis.py +8 -0
  38. conduit_py-0.0.1/src/conduit/engine/registry.py +377 -0
  39. conduit_py-0.0.1/src/conduit/engine/runner.py +156 -0
  40. conduit_py-0.0.1/src/conduit/engine/status.py +278 -0
  41. conduit_py-0.0.1/src/conduit/sdk/__init__.py +23 -0
  42. conduit_py-0.0.1/src/conduit/sdk/api.py +473 -0
  43. conduit_py-0.0.1/src/conduit/sdk/artifacts.py +222 -0
  44. conduit_py-0.0.1/src/conduit/sdk/context.py +212 -0
  45. conduit_py-0.0.1/src/conduit/sdk/middleware.py +98 -0
  46. conduit_py-0.0.1/src/conduit/sdk/parallel.py +213 -0
  47. conduit_py-0.0.1/src/conduit/sdk/task.py +119 -0
  48. conduit_py-0.0.1/src/conduit/sdk/worker.py +740 -0
  49. conduit_py-0.0.1/src/conduit/types/__init__.py +5 -0
  50. conduit_py-0.0.1/src/conduit/types/executor.py +10 -0
  51. conduit_py-0.0.1/tests/conftest.py +15 -0
  52. conduit_py-0.0.1/tests/test_cli_contracts.py +342 -0
  53. conduit_py-0.0.1/tests/test_identity_and_models.py +90 -0
  54. conduit_py-0.0.1/tests/test_job_processor.py +315 -0
  55. conduit_py-0.0.1/tests/test_package_publish_smoke.py +141 -0
  56. conduit_py-0.0.1/tests/test_redis_queue.py +152 -0
  57. conduit_py-0.0.1/tests/test_redis_queue_components.py +152 -0
  58. conduit_py-0.0.1/tests/test_redis_queue_edge_paths.py +191 -0
  59. conduit_py-0.0.1/tests/test_registry_and_event_handle.py +185 -0
  60. conduit_py-0.0.1/tests/test_runner_engine.py +298 -0
  61. conduit_py-0.0.1/tests/test_runtime_contracts.py +313 -0
  62. conduit_py-0.0.1/tests/test_task_handle.py +94 -0
  63. conduit_py-0.0.1/tests/test_worker_parallel_status.py +194 -0
  64. conduit_py-0.0.1/tests/test_worker_registration_and_execute.py +77 -0
@@ -0,0 +1,111 @@
1
+ # =============================================================================
2
+ # Python
3
+ # =============================================================================
4
+ __pycache__/
5
+ *.py[cod]
6
+ *$py.class
7
+ *.so
8
+ .Python
9
+ build/
10
+ develop-eggs/
11
+ dist/
12
+ downloads/
13
+ eggs/
14
+ .eggs/
15
+ *.egg-info/
16
+ .installed.cfg
17
+ *.egg
18
+ MANIFEST
19
+ *.manifest
20
+ pip-log.txt
21
+ pip-delete-this-directory.txt
22
+ htmlcov/
23
+ .tox/
24
+ .nox/
25
+ .coverage
26
+ .coverage.*
27
+ .cache
28
+ nosetests.xml
29
+ coverage.xml
30
+ *.cover
31
+ *.py,cover
32
+ .hypothesis/
33
+ .pytest_cache/
34
+ cover/
35
+ *.mo
36
+ *.pot
37
+ .venv/
38
+ venv/
39
+ ENV/
40
+ env/
41
+ .pdm.toml
42
+ .pdm-python
43
+ .pdm-build/
44
+ __pypackages__/
45
+ .mypy_cache/
46
+ .dmypy.json
47
+ dmypy.json
48
+ .pyre/
49
+ .pytype/
50
+ cython_debug/
51
+ .ruff_cache/
52
+ .pypirc
53
+
54
+ # =============================================================================
55
+ # Node / Next.js
56
+ # =============================================================================
57
+ node_modules/
58
+ .pnp
59
+ .pnp.js
60
+ .next/
61
+ out/
62
+ next-env.d.ts
63
+ .source/
64
+ npm-debug.log*
65
+ yarn-debug.log*
66
+ yarn-error.log*
67
+ .pnpm-debug.log*
68
+ *.tsbuildinfo
69
+ .vercel
70
+
71
+ # =============================================================================
72
+ # Database
73
+ # =============================================================================
74
+ *.sqlite
75
+ *.sqlite3
76
+ *.sqlite3-journal
77
+ db.sqlite
78
+ db.sqlite3
79
+
80
+ # =============================================================================
81
+ # Environment & Secrets
82
+ # =============================================================================
83
+ .env
84
+ .env*.local
85
+ .env.prod
86
+
87
+ # =============================================================================
88
+ # IDE & OS
89
+ # =============================================================================
90
+ .DS_Store
91
+ *.pem
92
+ .idea/
93
+ *.swp
94
+ *.swo
95
+ *~
96
+
97
+ # =============================================================================
98
+ # Testing & Coverage
99
+ # =============================================================================
100
+ coverage/
101
+ coverage_html/
102
+ test-results/
103
+ playwright-report/
104
+ blob-report/
105
+ .vitest/
106
+
107
+ # =============================================================================
108
+ # tanstack start
109
+ # =============================================================================
110
+ .output/
111
+ .tanstack/
@@ -0,0 +1,24 @@
1
+ # Conduit SDK + CLI
2
+ FROM python:3.12-slim
3
+
4
+ WORKDIR /app
5
+
6
+ # Install uv
7
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
8
+
9
+ # Copy workspace files
10
+ COPY pyproject.toml uv.lock ./
11
+ COPY packages/shared ./packages/shared
12
+ COPY packages/conduit ./packages/conduit
13
+
14
+ # Install dependencies
15
+ RUN uv sync --frozen --no-dev --package conduit-py
16
+
17
+ # Set working directory
18
+ WORKDIR /app/packages/conduit
19
+
20
+ # Ensure Python output is not buffered
21
+ ENV PYTHONUNBUFFERED=1
22
+
23
+ # Default: show CLI help (no examples are bundled into the image)
24
+ CMD ["uv", "run", "conduit", "--help"]
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: conduit-py
3
+ Version: 0.0.1
4
+ Summary: Background jobs and APIs for Python. Like Modal, but self-hostable.
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: aiohttp>=3.11.0
7
+ Requires-Dist: conduit-server
8
+ Requires-Dist: conduit-shared
9
+ Requires-Dist: croniter>=6.0.0
10
+ Requires-Dist: fastapi>=0.115.0
11
+ Requires-Dist: pydantic-settings>=2.12.0
12
+ Requires-Dist: pydantic>=2.10.0
13
+ Requires-Dist: redis>=5.2.0
14
+ Requires-Dist: rich>=13.9.0
15
+ Requires-Dist: typer>=0.15.0
16
+ Requires-Dist: uvicorn>=0.34.0
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Conduit Package
20
+
21
+ Core SDK/runtime package for workers, tasks, APIs, and queue processing.
22
+
23
+ ## Package Validation
24
+
25
+ From the workspace root:
26
+
27
+ ```bash
28
+ ./scripts/verify-conduit-package.sh
29
+ ```
30
+
31
+ This runs the conduit package test suite with branch coverage and enforces the current minimum coverage gate.
@@ -0,0 +1,13 @@
1
+ # Conduit Package
2
+
3
+ Core SDK/runtime package for workers, tasks, APIs, and queue processing.
4
+
5
+ ## Package Validation
6
+
7
+ From the workspace root:
8
+
9
+ ```bash
10
+ ./scripts/verify-conduit-package.sh
11
+ ```
12
+
13
+ This runs the conduit package test suite with branch coverage and enforces the current minimum coverage gate.
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "conduit-py"
3
+ version = "0.0.1"
4
+ description = "Background jobs and APIs for Python. Like Modal, but self-hostable."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "aiohttp>=3.11.0",
9
+ "croniter>=6.0.0",
10
+ "fastapi>=0.115.0",
11
+ "pydantic>=2.10.0",
12
+ "pydantic-settings>=2.12.0",
13
+ "redis>=5.2.0",
14
+ "rich>=13.9.0",
15
+ "conduit-server",
16
+ "conduit-shared",
17
+ "typer>=0.15.0",
18
+ "uvicorn>=0.34.0",
19
+ ]
20
+
21
+ [project.scripts]
22
+ conduit = "conduit.cli:app"
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/conduit"]
30
+
31
+ [tool.uv.sources]
32
+ "conduit-server" = { workspace = true }
33
+ "conduit-shared" = { workspace = true }
@@ -0,0 +1,68 @@
1
+ """Conduit - Background job processing framework."""
2
+
3
+ import asyncio
4
+
5
+ from shared.artifacts import ArtifactType
6
+
7
+ from conduit.engine import run_services
8
+ from conduit.sdk import (
9
+ Api,
10
+ Context,
11
+ Worker,
12
+ create_artifact,
13
+ create_artifact_sync,
14
+ get_current_context,
15
+ )
16
+ from conduit.types import SyncExecutor
17
+
18
+ __all__ = [
19
+ "Api",
20
+ "ArtifactType",
21
+ "Context",
22
+ "Worker",
23
+ "SyncExecutor",
24
+ "create_artifact",
25
+ "create_artifact_sync",
26
+ "get_current_context",
27
+ "run",
28
+ ]
29
+
30
+ __version__ = "0.0.1"
31
+
32
+
33
+ def run(
34
+ *components: Api | Worker,
35
+ worker_timeout: float = 3.0,
36
+ api_timeout: float = 1.5,
37
+ ) -> None:
38
+ """Run one or more Api and/or Worker instances.
39
+
40
+ Handles signal coordination so Ctrl+C gracefully stops all components.
41
+
42
+ Args:
43
+ *components: Api and/or Worker instances to run.
44
+ worker_timeout: Seconds to wait for workers to finish active jobs before force-stopping (default 3.0).
45
+ api_timeout: Seconds to wait for APIs to finish requests before force-stopping (default 1.5).
46
+
47
+ Example:
48
+ import conduit
49
+
50
+ api = conduit.Api("my-api")
51
+ worker = conduit.Worker("my-worker")
52
+
53
+ conduit.run(api, worker)
54
+
55
+ # With custom timeouts (e.g., for long-running jobs):
56
+ conduit.run(api, worker, worker_timeout=30.0)
57
+ """
58
+ apis = [c for c in components if isinstance(c, Api)]
59
+ workers = [c for c in components if isinstance(c, Worker)]
60
+
61
+ try:
62
+ asyncio.run(
63
+ run_services(
64
+ apis, workers, worker_timeout=worker_timeout, api_timeout=api_timeout
65
+ )
66
+ )
67
+ except KeyboardInterrupt:
68
+ pass
@@ -0,0 +1,69 @@
1
+ """Conduit CLI."""
2
+
3
+ import typer
4
+
5
+ from conduit.cli._console import console
6
+ from conduit.cli.call import call
7
+ from conduit.cli.list import list_cmd
8
+ from conduit.cli.run import run
9
+ from conduit.cli.server import (
10
+ db_current as server_db_current,
11
+ db_history as server_db_history,
12
+ db_upgrade as server_db_upgrade,
13
+ start as server_start,
14
+ )
15
+
16
+ app = typer.Typer(
17
+ name="conduit",
18
+ help="Easy background workers and APIs for Python.",
19
+ no_args_is_help=True,
20
+ rich_markup_mode="rich",
21
+ add_completion=False,
22
+ )
23
+
24
+
25
+ def _version_callback(value: bool) -> None:
26
+ if value:
27
+ from conduit import __version__
28
+
29
+ console.print(f"[bold]conduit[/bold] [dim]{__version__}[/dim]")
30
+ raise typer.Exit()
31
+
32
+
33
+ @app.callback()
34
+ def main(
35
+ version: bool = typer.Option(
36
+ False,
37
+ "--version",
38
+ "-v",
39
+ callback=_version_callback,
40
+ is_eager=True,
41
+ help="Show version",
42
+ ),
43
+ ) -> None:
44
+ """Background jobs and APIs for Python."""
45
+
46
+
47
+ # Register commands
48
+ app.command()(run)
49
+ app.command()(call)
50
+ app.command("list")(list_cmd)
51
+
52
+ server_app = typer.Typer(
53
+ help="Hosted Conduit server commands.",
54
+ no_args_is_help=True,
55
+ rich_markup_mode="rich",
56
+ )
57
+ server_app.command("start")(server_start)
58
+
59
+ server_db_app = typer.Typer(
60
+ help="Database migration commands for the hosted server.",
61
+ no_args_is_help=True,
62
+ rich_markup_mode="rich",
63
+ )
64
+ server_db_app.command("upgrade")(server_db_upgrade)
65
+ server_db_app.command("current")(server_db_current)
66
+ server_db_app.command("history")(server_db_history)
67
+ server_app.add_typer(server_db_app, name="db")
68
+
69
+ app.add_typer(server_app, name="server")
@@ -0,0 +1,112 @@
1
+ """Shared console and formatting utilities."""
2
+
3
+ import logging
4
+ import os
5
+
6
+ from rich.console import Console
7
+ from rich.logging import RichHandler
8
+
9
+ # Force colors unless explicitly disabled (NO_COLOR standard)
10
+ no_color = os.environ.get("NO_COLOR", "").lower() in ("1", "true", "yes")
11
+
12
+ console = Console(
13
+ highlight=False,
14
+ force_terminal=not no_color,
15
+ no_color=no_color,
16
+ )
17
+
18
+
19
+ def header(title: str) -> None:
20
+ """Print a minimal header."""
21
+ console.print()
22
+ console.print(f"[bold]{title}[/bold]")
23
+ console.print()
24
+
25
+
26
+ def success(msg: str) -> None:
27
+ """Print success message."""
28
+ console.print(f" [green]✓[/green] {msg}")
29
+
30
+
31
+ def error(msg: str) -> None:
32
+ """Print error message."""
33
+ console.print(f" [red]✗[/red] {msg}")
34
+
35
+
36
+ def warning(msg: str) -> None:
37
+ """Print warning message."""
38
+ console.print(f" [yellow]![/yellow] {msg}")
39
+
40
+
41
+ def info(msg: str) -> None:
42
+ """Print info message."""
43
+ console.print(f" [dim]→[/dim] {msg}")
44
+
45
+
46
+ def dim(msg: str) -> None:
47
+ """Print dimmed text."""
48
+ console.print(f" [dim]{msg}[/dim]")
49
+
50
+
51
+ def error_panel(msg: str, *, title: str = "Failed") -> None:
52
+ """Print a styled error panel."""
53
+ from rich.box import ROUNDED
54
+ from rich.panel import Panel
55
+ from rich.text import Text
56
+
57
+ lines: list[Text] = []
58
+ line = Text()
59
+ line.append("✗ ", style="red bold")
60
+ line.append(title, style="red")
61
+ lines.append(line)
62
+ lines.append(Text())
63
+ lines.append(Text(msg, style="dim"))
64
+
65
+ panel = Panel(
66
+ Text("\n").join(lines),
67
+ border_style="red dim",
68
+ box=ROUNDED,
69
+ padding=(0, 1),
70
+ expand=False,
71
+ )
72
+ console.print(panel)
73
+
74
+
75
+ def nl() -> None:
76
+ """Print newline."""
77
+ console.print()
78
+
79
+
80
+ def setup_logging(verbose: bool = False) -> None:
81
+ """Configure clean logging for conduit CLI."""
82
+ # Suppress noisy loggers
83
+ logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
84
+ logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
85
+ logging.getLogger("httpx").setLevel(logging.WARNING)
86
+ logging.getLogger("httpcore").setLevel(logging.WARNING)
87
+ logging.getLogger("aiohttp").setLevel(logging.WARNING)
88
+
89
+ # Set up rich handler for clean output
90
+ handler = RichHandler(
91
+ console=console,
92
+ show_time=False,
93
+ show_path=False,
94
+ rich_tracebacks=True,
95
+ markup=True,
96
+ keywords=[],
97
+ )
98
+
99
+ # Configure root logger
100
+ level = logging.DEBUG if verbose else logging.INFO
101
+ logging.basicConfig(
102
+ level=level,
103
+ format="%(message)s",
104
+ handlers=[handler],
105
+ force=True,
106
+ )
107
+
108
+ # Keep conduit loggers at appropriate level
109
+ logging.getLogger("conduit").setLevel(level)
110
+
111
+ # Allow engine worker logs during debugging
112
+ logging.getLogger("conduit.").setLevel(level)
@@ -0,0 +1,140 @@
1
+ """Shared display utilities for CLI commands."""
2
+
3
+ from collections.abc import Callable
4
+
5
+ import typer
6
+ from rich.box import ROUNDED
7
+ from rich.panel import Panel
8
+ from rich.table import Table
9
+ from rich.text import Text
10
+
11
+ from conduit.cli._console import console, error, nl
12
+ from conduit.engine.runner import run_services as run_services # re-export
13
+ from conduit.sdk.api import Api
14
+ from conduit.sdk.worker import Worker
15
+
16
+ # run_services was moved to conduit.engine.runner but re-exported here for backwards compatibility
17
+ __all__ = ["run_services", "worker_lines", "print_services_panel", "filter_components"]
18
+
19
+
20
+ def worker_lines(
21
+ worker: Worker,
22
+ ) -> list[Text]:
23
+ """Build display lines for a worker (name, handlers)."""
24
+ lines: list[Text] = []
25
+
26
+ # Worker name
27
+ line = Text(no_wrap=True, overflow="ellipsis")
28
+ line.append(f"{worker.name}", style="cyan bold")
29
+ lines.append(line)
30
+
31
+ # Handler lines
32
+ task_names = list(worker.tasks.keys())
33
+ cron_names = [c.display_name for c in worker.crons]
34
+ event_patterns = list(worker.events.keys())
35
+
36
+ def add_handler_line(label: str, count: int, names: list[str]) -> None:
37
+ line = Text(no_wrap=True, overflow="ellipsis")
38
+ label_text = f" {label} ({count})"
39
+ line.append(f"{label_text:<18} ", style="bold")
40
+ line.append(", ".join(names), style="dim")
41
+ lines.append(line)
42
+
43
+ if task_names:
44
+ add_handler_line("tasks", len(task_names), task_names)
45
+ if cron_names:
46
+ add_handler_line("crons", len(cron_names), cron_names)
47
+ if event_patterns:
48
+ add_handler_line("events", len(event_patterns), event_patterns)
49
+
50
+ if not any([task_names, cron_names, event_patterns]):
51
+ line = Text(no_wrap=True, overflow="ellipsis")
52
+ line.append(" no handlers", style="dim")
53
+ lines.append(line)
54
+
55
+ return lines
56
+
57
+
58
+ def print_services_panel(
59
+ apis: list[Api],
60
+ workers: list[Worker],
61
+ *,
62
+ title: str,
63
+ worker_line_fn: Callable[[Worker], list[Text]],
64
+ redis_url: str | None = None,
65
+ ) -> None:
66
+ """Print the startup panel showing APIs, workers, and Ctrl+C hint."""
67
+ lines: list[Text] = []
68
+
69
+ for api in apis:
70
+ line = Text(no_wrap=True, overflow="ellipsis")
71
+ line.append(f"{api.name}", style="cyan bold")
72
+ line.append(f" http://{api.host}:{api.port}", style="dim")
73
+ lines.append(line)
74
+
75
+ # Blank line between APIs and workers
76
+ if apis and workers:
77
+ lines.append(Text())
78
+
79
+ for worker in workers:
80
+ lines.extend(worker_line_fn(worker))
81
+
82
+ # Blank line at bottom for spacing before panel border
83
+ lines.append(Text())
84
+
85
+ table = Table.grid(padding=0)
86
+ table.add_column(overflow="ellipsis", no_wrap=True)
87
+ for line in lines:
88
+ table.add_row(line)
89
+
90
+ # Build subtitle with redis status and Ctrl+C hint
91
+ subtitle_parts: list[str] = []
92
+ if redis_url:
93
+ subtitle_parts.append(f"[green]✓[/green] [dim]redis ({redis_url})[/dim]")
94
+ subtitle_parts.append("[dim]Ctrl+C to stop[/dim]")
95
+ subtitle = " [dim]·[/dim] ".join(subtitle_parts)
96
+
97
+ panel = Panel(
98
+ table,
99
+ title=f"[bold]{title}[/bold]",
100
+ title_align="left",
101
+ subtitle=subtitle,
102
+ subtitle_align="right",
103
+ border_style="dim",
104
+ box=ROUNDED,
105
+ padding=(0, 1),
106
+ expand=False,
107
+ )
108
+ console.print(panel)
109
+ nl()
110
+
111
+
112
+ def filter_components(
113
+ apis: list[Api],
114
+ workers: list[Worker],
115
+ only: list[str] | None,
116
+ ) -> tuple[list[Api], list[Worker]]:
117
+ """Filter APIs and workers by name.
118
+
119
+ Raises typer.Exit(1) on unknown names or empty result.
120
+ """
121
+ if only:
122
+ only_set = set(only)
123
+ apis = [a for a in apis if a.name in only_set]
124
+ workers = [w for w in workers if w.name in only_set]
125
+
126
+ found_names = {a.name for a in apis} | {w.name for w in workers}
127
+ unknown = only_set - found_names
128
+ if unknown:
129
+ nl()
130
+ error(f"Unknown component(s): {', '.join(unknown)}")
131
+ nl()
132
+ raise typer.Exit(1)
133
+
134
+ if not apis and not workers:
135
+ nl()
136
+ error("No Api or Worker found in the specified files")
137
+ nl()
138
+ raise typer.Exit(1)
139
+
140
+ return apis, workers
@@ -0,0 +1,69 @@
1
+ """Module loader utilities."""
2
+
3
+ import importlib.util
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from conduit.cli._console import error
10
+ from conduit.sdk.api import Api
11
+ from conduit.sdk.worker import Worker
12
+
13
+
14
+ def import_file(file_path: str) -> object:
15
+ """Import a Python file and return the module."""
16
+ path = Path(file_path).resolve()
17
+
18
+ if not path.exists():
19
+ error(f"File not found: {file_path}")
20
+ raise typer.Exit(1)
21
+
22
+ if path.suffix != ".py":
23
+ error(f"Not a Python file: {file_path}")
24
+ raise typer.Exit(1)
25
+
26
+ # Add parent directory to path so imports work
27
+ parent_dir = str(path.parent)
28
+ if parent_dir not in sys.path:
29
+ sys.path.insert(0, parent_dir)
30
+
31
+ # Also add cwd if different
32
+ cwd = str(Path.cwd())
33
+ if cwd not in sys.path:
34
+ sys.path.insert(0, cwd)
35
+
36
+ # Import the file as a module
37
+ module_name = path.stem
38
+ spec = importlib.util.spec_from_file_location(module_name, path)
39
+ if spec is None or spec.loader is None:
40
+ error(f"Could not load: {file_path}")
41
+ raise typer.Exit(1)
42
+
43
+ module = importlib.util.module_from_spec(spec)
44
+ sys.modules[module_name] = module
45
+ spec.loader.exec_module(module)
46
+
47
+ return module
48
+
49
+
50
+ def discover_objects(files: list[str]) -> tuple[list[Api], list[Worker]]:
51
+ """Import files and discover Api/Worker objects."""
52
+
53
+ apis: list[Api] = []
54
+ workers: list[Worker] = []
55
+
56
+ for file_path in files:
57
+ module = import_file(file_path)
58
+
59
+ for name in dir(module):
60
+ if name.startswith("_"):
61
+ continue
62
+
63
+ obj = getattr(module, name)
64
+ if isinstance(obj, Api):
65
+ apis.append(obj)
66
+ elif isinstance(obj, Worker):
67
+ workers.append(obj)
68
+
69
+ return apis, workers