upnext 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.
- upnext-0.0.1/.gitignore +111 -0
- upnext-0.0.1/Dockerfile +29 -0
- upnext-0.0.1/PKG-INFO +32 -0
- upnext-0.0.1/README.md +13 -0
- upnext-0.0.1/pyproject.toml +39 -0
- upnext-0.0.1/src/upnext/__init__.py +68 -0
- upnext-0.0.1/src/upnext/cli/__init__.py +75 -0
- upnext-0.0.1/src/upnext/cli/_console.py +112 -0
- upnext-0.0.1/src/upnext/cli/_display.py +140 -0
- upnext-0.0.1/src/upnext/cli/_loader.py +68 -0
- upnext-0.0.1/src/upnext/cli/call.py +231 -0
- upnext-0.0.1/src/upnext/cli/list.py +126 -0
- upnext-0.0.1/src/upnext/cli/run.py +75 -0
- upnext-0.0.1/src/upnext/cli/server.py +405 -0
- upnext-0.0.1/src/upnext/config.py +65 -0
- upnext-0.0.1/src/upnext/engine/__init__.py +34 -0
- upnext-0.0.1/src/upnext/engine/cron.py +87 -0
- upnext-0.0.1/src/upnext/engine/event_router.py +149 -0
- upnext-0.0.1/src/upnext/engine/function_identity.py +45 -0
- upnext-0.0.1/src/upnext/engine/handlers/__init__.py +4 -0
- upnext-0.0.1/src/upnext/engine/handlers/event_handle.py +267 -0
- upnext-0.0.1/src/upnext/engine/handlers/task_handle.py +197 -0
- upnext-0.0.1/src/upnext/engine/job_processor.py +796 -0
- upnext-0.0.1/src/upnext/engine/queue/__init__.py +19 -0
- upnext-0.0.1/src/upnext/engine/queue/base.py +478 -0
- upnext-0.0.1/src/upnext/engine/queue/redis/__init__.py +6 -0
- upnext-0.0.1/src/upnext/engine/queue/redis/constants.py +28 -0
- upnext-0.0.1/src/upnext/engine/queue/redis/fetcher.py +90 -0
- upnext-0.0.1/src/upnext/engine/queue/redis/finisher.py +145 -0
- upnext-0.0.1/src/upnext/engine/queue/redis/queue.py +1215 -0
- upnext-0.0.1/src/upnext/engine/queue/redis/scripts/cancel.lua +47 -0
- upnext-0.0.1/src/upnext/engine/queue/redis/scripts/enqueue.lua +55 -0
- upnext-0.0.1/src/upnext/engine/queue/redis/scripts/finish.lua +59 -0
- upnext-0.0.1/src/upnext/engine/queue/redis/scripts/retry.lua +52 -0
- upnext-0.0.1/src/upnext/engine/queue/redis/scripts/sweep.lua +51 -0
- upnext-0.0.1/src/upnext/engine/queue/redis/sweeper.py +192 -0
- upnext-0.0.1/src/upnext/engine/redis.py +8 -0
- upnext-0.0.1/src/upnext/engine/registry.py +377 -0
- upnext-0.0.1/src/upnext/engine/runner.py +158 -0
- upnext-0.0.1/src/upnext/engine/status.py +289 -0
- upnext-0.0.1/src/upnext/sdk/__init__.py +23 -0
- upnext-0.0.1/src/upnext/sdk/api.py +485 -0
- upnext-0.0.1/src/upnext/sdk/artifacts.py +219 -0
- upnext-0.0.1/src/upnext/sdk/context.py +212 -0
- upnext-0.0.1/src/upnext/sdk/middleware.py +235 -0
- upnext-0.0.1/src/upnext/sdk/parallel.py +213 -0
- upnext-0.0.1/src/upnext/sdk/task.py +119 -0
- upnext-0.0.1/src/upnext/sdk/worker.py +779 -0
- upnext-0.0.1/src/upnext/types/__init__.py +5 -0
- upnext-0.0.1/src/upnext/types/executor.py +10 -0
- upnext-0.0.1/tests/conftest.py +15 -0
- upnext-0.0.1/tests/test_api_tracking_middleware.py +122 -0
- upnext-0.0.1/tests/test_cli_contracts.py +686 -0
- upnext-0.0.1/tests/test_identity_and_models.py +90 -0
- upnext-0.0.1/tests/test_job_processor.py +328 -0
- upnext-0.0.1/tests/test_package_publish_smoke.py +143 -0
- upnext-0.0.1/tests/test_redis_queue.py +159 -0
- upnext-0.0.1/tests/test_redis_queue_components.py +154 -0
- upnext-0.0.1/tests/test_redis_queue_edge_paths.py +196 -0
- upnext-0.0.1/tests/test_registry_and_event_handle.py +184 -0
- upnext-0.0.1/tests/test_runner_engine.py +307 -0
- upnext-0.0.1/tests/test_runtime_contracts.py +320 -0
- upnext-0.0.1/tests/test_task_handle.py +93 -0
- upnext-0.0.1/tests/test_worker_parallel_status.py +220 -0
- upnext-0.0.1/tests/test_worker_registration_and_execute.py +84 -0
upnext-0.0.1/.gitignore
ADDED
|
@@ -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/
|
upnext-0.0.1/Dockerfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# UpNext 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/upnext ./packages/upnext
|
|
13
|
+
COPY packages/server/pyproject.toml packages/server/README.md ./packages/server/
|
|
14
|
+
COPY packages/server/src ./packages/server/src
|
|
15
|
+
COPY packages/server/alembic.ini ./packages/server/alembic.ini
|
|
16
|
+
COPY packages/server/alembic ./packages/server/alembic
|
|
17
|
+
COPY packages/server/static ./packages/server/static
|
|
18
|
+
|
|
19
|
+
# Install dependencies
|
|
20
|
+
RUN uv sync --frozen --no-dev --package upnext
|
|
21
|
+
|
|
22
|
+
# Set working directory
|
|
23
|
+
WORKDIR /app/packages/upnext
|
|
24
|
+
|
|
25
|
+
# Ensure Python output is not buffered
|
|
26
|
+
ENV PYTHONUNBUFFERED=1
|
|
27
|
+
|
|
28
|
+
# Default: show CLI help (no examples are bundled into the image)
|
|
29
|
+
CMD ["uv", "run", "upnext", "--help"]
|
upnext-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: upnext
|
|
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: croniter>=6.0.0
|
|
8
|
+
Requires-Dist: fastapi>=0.115.0
|
|
9
|
+
Requires-Dist: pydantic-settings>=2.12.0
|
|
10
|
+
Requires-Dist: pydantic>=2.10.0
|
|
11
|
+
Requires-Dist: redis>=5.2.0
|
|
12
|
+
Requires-Dist: rich>=13.9.0
|
|
13
|
+
Requires-Dist: typer>=0.15.0
|
|
14
|
+
Requires-Dist: upnext-server
|
|
15
|
+
Requires-Dist: upnext-shared
|
|
16
|
+
Requires-Dist: uvicorn>=0.34.0
|
|
17
|
+
Provides-Extra: server
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# UpNext Package
|
|
21
|
+
|
|
22
|
+
Core SDK/runtime package for workers, tasks, APIs, and queue processing.
|
|
23
|
+
|
|
24
|
+
## Package Validation
|
|
25
|
+
|
|
26
|
+
From the workspace root:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
./scripts/verify-upnext-package.sh
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
This runs the upnext package test suite with branch coverage and enforces the current minimum coverage gate.
|
upnext-0.0.1/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# UpNext 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-upnext-package.sh
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
This runs the upnext package test suite with branch coverage and enforces the current minimum coverage gate.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "upnext"
|
|
3
|
+
dynamic = ["version"]
|
|
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
|
+
"upnext-server",
|
|
16
|
+
"upnext-shared",
|
|
17
|
+
"typer>=0.15.0",
|
|
18
|
+
"uvicorn>=0.34.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
server = []
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
upnext = "upnext.cli:app"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["hatchling"]
|
|
29
|
+
build-backend = "hatchling.build"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/upnext"]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.version]
|
|
35
|
+
path = "../shared/src/shared/_version.py"
|
|
36
|
+
|
|
37
|
+
[tool.uv.sources]
|
|
38
|
+
"upnext-server" = { workspace = true }
|
|
39
|
+
"upnext-shared" = { workspace = true }
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""UpNext - Background job processing framework."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from shared import __version__
|
|
6
|
+
from shared.artifacts import ArtifactType
|
|
7
|
+
|
|
8
|
+
from upnext.engine import run_services
|
|
9
|
+
from upnext.sdk import (
|
|
10
|
+
Api,
|
|
11
|
+
Context,
|
|
12
|
+
Worker,
|
|
13
|
+
create_artifact,
|
|
14
|
+
create_artifact_sync,
|
|
15
|
+
get_current_context,
|
|
16
|
+
)
|
|
17
|
+
from upnext.types import SyncExecutor
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Api",
|
|
21
|
+
"ArtifactType",
|
|
22
|
+
"Context",
|
|
23
|
+
"Worker",
|
|
24
|
+
"SyncExecutor",
|
|
25
|
+
"__version__",
|
|
26
|
+
"create_artifact",
|
|
27
|
+
"create_artifact_sync",
|
|
28
|
+
"get_current_context",
|
|
29
|
+
"run",
|
|
30
|
+
]
|
|
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 upnext
|
|
49
|
+
|
|
50
|
+
api = upnext.Api("my-api")
|
|
51
|
+
worker = upnext.Worker("my-worker")
|
|
52
|
+
|
|
53
|
+
upnext.run(api, worker)
|
|
54
|
+
|
|
55
|
+
# With custom timeouts (e.g., for long-running jobs):
|
|
56
|
+
upnext.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,75 @@
|
|
|
1
|
+
"""UpNext CLI."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from upnext.cli._console import console
|
|
6
|
+
from upnext.cli.call import call
|
|
7
|
+
from upnext.cli.list import list_cmd
|
|
8
|
+
from upnext.cli.run import run
|
|
9
|
+
from upnext.cli.server import (
|
|
10
|
+
db_current as server_db_current,
|
|
11
|
+
)
|
|
12
|
+
from upnext.cli.server import (
|
|
13
|
+
db_history as server_db_history,
|
|
14
|
+
)
|
|
15
|
+
from upnext.cli.server import (
|
|
16
|
+
db_upgrade as server_db_upgrade,
|
|
17
|
+
)
|
|
18
|
+
from upnext.cli.server import (
|
|
19
|
+
start as server_start,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
name="upnext",
|
|
24
|
+
help="Easy background workers and APIs for Python.",
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
rich_markup_mode="rich",
|
|
27
|
+
add_completion=False,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _version_callback(value: bool) -> None:
|
|
32
|
+
if value:
|
|
33
|
+
from upnext import __version__
|
|
34
|
+
|
|
35
|
+
console.print(f"[bold]upnext[/bold] [dim]{__version__}[/dim]")
|
|
36
|
+
raise typer.Exit()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.callback()
|
|
40
|
+
def main(
|
|
41
|
+
version: bool = typer.Option(
|
|
42
|
+
False,
|
|
43
|
+
"--version",
|
|
44
|
+
"-v",
|
|
45
|
+
callback=_version_callback,
|
|
46
|
+
is_eager=True,
|
|
47
|
+
help="Show version",
|
|
48
|
+
),
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Background jobs and APIs for Python."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Register commands
|
|
54
|
+
app.command()(run)
|
|
55
|
+
app.command()(call)
|
|
56
|
+
app.command("list")(list_cmd)
|
|
57
|
+
|
|
58
|
+
server_app = typer.Typer(
|
|
59
|
+
help="Hosted UpNext server commands.",
|
|
60
|
+
no_args_is_help=True,
|
|
61
|
+
rich_markup_mode="rich",
|
|
62
|
+
)
|
|
63
|
+
server_app.command("start")(server_start)
|
|
64
|
+
|
|
65
|
+
server_db_app = typer.Typer(
|
|
66
|
+
help="Database migration commands for the hosted server.",
|
|
67
|
+
no_args_is_help=True,
|
|
68
|
+
rich_markup_mode="rich",
|
|
69
|
+
)
|
|
70
|
+
server_db_app.command("upgrade")(server_db_upgrade)
|
|
71
|
+
server_db_app.command("current")(server_db_current)
|
|
72
|
+
server_db_app.command("history")(server_db_history)
|
|
73
|
+
server_app.add_typer(server_db_app, name="db")
|
|
74
|
+
|
|
75
|
+
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 upnext 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 upnext loggers at appropriate level
|
|
109
|
+
logging.getLogger("upnext").setLevel(level)
|
|
110
|
+
|
|
111
|
+
# Allow engine worker logs during debugging
|
|
112
|
+
logging.getLogger("upnext.").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 upnext.cli._console import console, error, nl
|
|
12
|
+
from upnext.engine.runner import run_services as run_services # re-export
|
|
13
|
+
from upnext.sdk.api import Api
|
|
14
|
+
from upnext.sdk.worker import Worker
|
|
15
|
+
|
|
16
|
+
# run_services was moved to upnext.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,68 @@
|
|
|
1
|
+
"""Module loader utilities."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from upnext.cli._console import error
|
|
9
|
+
from upnext.sdk.api import Api
|
|
10
|
+
from upnext.sdk.worker import Worker
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def import_file(file_path: str) -> object:
|
|
14
|
+
"""Import a Python file and return the module."""
|
|
15
|
+
path = Path(file_path).resolve()
|
|
16
|
+
|
|
17
|
+
if not path.exists():
|
|
18
|
+
error(f"File not found: {file_path}")
|
|
19
|
+
raise typer.Exit(1)
|
|
20
|
+
|
|
21
|
+
if path.suffix != ".py":
|
|
22
|
+
error(f"Not a Python file: {file_path}")
|
|
23
|
+
raise typer.Exit(1)
|
|
24
|
+
|
|
25
|
+
# Add parent directory to path so imports work
|
|
26
|
+
parent_dir = str(path.parent)
|
|
27
|
+
if parent_dir not in sys.path:
|
|
28
|
+
sys.path.insert(0, parent_dir)
|
|
29
|
+
|
|
30
|
+
# Also add cwd if different
|
|
31
|
+
cwd = str(Path.cwd())
|
|
32
|
+
if cwd not in sys.path:
|
|
33
|
+
sys.path.insert(0, cwd)
|
|
34
|
+
|
|
35
|
+
# Import the file as a module
|
|
36
|
+
module_name = path.stem
|
|
37
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
38
|
+
if spec is None or spec.loader is None:
|
|
39
|
+
error(f"Could not load: {file_path}")
|
|
40
|
+
raise typer.Exit(1)
|
|
41
|
+
|
|
42
|
+
module = importlib.util.module_from_spec(spec)
|
|
43
|
+
sys.modules[module_name] = module
|
|
44
|
+
spec.loader.exec_module(module)
|
|
45
|
+
|
|
46
|
+
return module
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def discover_objects(files: list[str]) -> tuple[list[Api], list[Worker]]:
|
|
50
|
+
"""Import files and discover Api/Worker objects."""
|
|
51
|
+
|
|
52
|
+
apis: list[Api] = []
|
|
53
|
+
workers: list[Worker] = []
|
|
54
|
+
|
|
55
|
+
for file_path in files:
|
|
56
|
+
module = import_file(file_path)
|
|
57
|
+
|
|
58
|
+
for name in dir(module):
|
|
59
|
+
if name.startswith("_"):
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
obj = getattr(module, name)
|
|
63
|
+
if isinstance(obj, Api):
|
|
64
|
+
apis.append(obj)
|
|
65
|
+
elif isinstance(obj, Worker):
|
|
66
|
+
workers.append(obj)
|
|
67
|
+
|
|
68
|
+
return apis, workers
|