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.
- conduit_py-0.0.1/.gitignore +111 -0
- conduit_py-0.0.1/Dockerfile +24 -0
- conduit_py-0.0.1/PKG-INFO +31 -0
- conduit_py-0.0.1/README.md +13 -0
- conduit_py-0.0.1/pyproject.toml +33 -0
- conduit_py-0.0.1/src/conduit/__init__.py +68 -0
- conduit_py-0.0.1/src/conduit/cli/__init__.py +69 -0
- conduit_py-0.0.1/src/conduit/cli/_console.py +112 -0
- conduit_py-0.0.1/src/conduit/cli/_display.py +140 -0
- conduit_py-0.0.1/src/conduit/cli/_loader.py +69 -0
- conduit_py-0.0.1/src/conduit/cli/call.py +231 -0
- conduit_py-0.0.1/src/conduit/cli/list.py +127 -0
- conduit_py-0.0.1/src/conduit/cli/run.py +75 -0
- conduit_py-0.0.1/src/conduit/cli/server.py +245 -0
- conduit_py-0.0.1/src/conduit/config.py +57 -0
- conduit_py-0.0.1/src/conduit/engine/__init__.py +34 -0
- conduit_py-0.0.1/src/conduit/engine/cron.py +87 -0
- conduit_py-0.0.1/src/conduit/engine/event_router.py +149 -0
- conduit_py-0.0.1/src/conduit/engine/function_identity.py +45 -0
- conduit_py-0.0.1/src/conduit/engine/handlers/__init__.py +4 -0
- conduit_py-0.0.1/src/conduit/engine/handlers/event_handle.py +268 -0
- conduit_py-0.0.1/src/conduit/engine/handlers/task_handle.py +198 -0
- conduit_py-0.0.1/src/conduit/engine/job_processor.py +796 -0
- conduit_py-0.0.1/src/conduit/engine/queue/__init__.py +19 -0
- conduit_py-0.0.1/src/conduit/engine/queue/base.py +478 -0
- conduit_py-0.0.1/src/conduit/engine/queue/redis/__init__.py +6 -0
- conduit_py-0.0.1/src/conduit/engine/queue/redis/constants.py +28 -0
- conduit_py-0.0.1/src/conduit/engine/queue/redis/fetcher.py +90 -0
- conduit_py-0.0.1/src/conduit/engine/queue/redis/finisher.py +145 -0
- conduit_py-0.0.1/src/conduit/engine/queue/redis/queue.py +1215 -0
- conduit_py-0.0.1/src/conduit/engine/queue/redis/scripts/cancel.lua +47 -0
- conduit_py-0.0.1/src/conduit/engine/queue/redis/scripts/enqueue.lua +55 -0
- conduit_py-0.0.1/src/conduit/engine/queue/redis/scripts/finish.lua +59 -0
- conduit_py-0.0.1/src/conduit/engine/queue/redis/scripts/retry.lua +52 -0
- conduit_py-0.0.1/src/conduit/engine/queue/redis/scripts/sweep.lua +51 -0
- conduit_py-0.0.1/src/conduit/engine/queue/redis/sweeper.py +192 -0
- conduit_py-0.0.1/src/conduit/engine/redis.py +8 -0
- conduit_py-0.0.1/src/conduit/engine/registry.py +377 -0
- conduit_py-0.0.1/src/conduit/engine/runner.py +156 -0
- conduit_py-0.0.1/src/conduit/engine/status.py +278 -0
- conduit_py-0.0.1/src/conduit/sdk/__init__.py +23 -0
- conduit_py-0.0.1/src/conduit/sdk/api.py +473 -0
- conduit_py-0.0.1/src/conduit/sdk/artifacts.py +222 -0
- conduit_py-0.0.1/src/conduit/sdk/context.py +212 -0
- conduit_py-0.0.1/src/conduit/sdk/middleware.py +98 -0
- conduit_py-0.0.1/src/conduit/sdk/parallel.py +213 -0
- conduit_py-0.0.1/src/conduit/sdk/task.py +119 -0
- conduit_py-0.0.1/src/conduit/sdk/worker.py +740 -0
- conduit_py-0.0.1/src/conduit/types/__init__.py +5 -0
- conduit_py-0.0.1/src/conduit/types/executor.py +10 -0
- conduit_py-0.0.1/tests/conftest.py +15 -0
- conduit_py-0.0.1/tests/test_cli_contracts.py +342 -0
- conduit_py-0.0.1/tests/test_identity_and_models.py +90 -0
- conduit_py-0.0.1/tests/test_job_processor.py +315 -0
- conduit_py-0.0.1/tests/test_package_publish_smoke.py +141 -0
- conduit_py-0.0.1/tests/test_redis_queue.py +152 -0
- conduit_py-0.0.1/tests/test_redis_queue_components.py +152 -0
- conduit_py-0.0.1/tests/test_redis_queue_edge_paths.py +191 -0
- conduit_py-0.0.1/tests/test_registry_and_event_handle.py +185 -0
- conduit_py-0.0.1/tests/test_runner_engine.py +298 -0
- conduit_py-0.0.1/tests/test_runtime_contracts.py +313 -0
- conduit_py-0.0.1/tests/test_task_handle.py +94 -0
- conduit_py-0.0.1/tests/test_worker_parallel_status.py +194 -0
- 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
|