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.
Files changed (65) hide show
  1. upnext-0.0.1/.gitignore +111 -0
  2. upnext-0.0.1/Dockerfile +29 -0
  3. upnext-0.0.1/PKG-INFO +32 -0
  4. upnext-0.0.1/README.md +13 -0
  5. upnext-0.0.1/pyproject.toml +39 -0
  6. upnext-0.0.1/src/upnext/__init__.py +68 -0
  7. upnext-0.0.1/src/upnext/cli/__init__.py +75 -0
  8. upnext-0.0.1/src/upnext/cli/_console.py +112 -0
  9. upnext-0.0.1/src/upnext/cli/_display.py +140 -0
  10. upnext-0.0.1/src/upnext/cli/_loader.py +68 -0
  11. upnext-0.0.1/src/upnext/cli/call.py +231 -0
  12. upnext-0.0.1/src/upnext/cli/list.py +126 -0
  13. upnext-0.0.1/src/upnext/cli/run.py +75 -0
  14. upnext-0.0.1/src/upnext/cli/server.py +405 -0
  15. upnext-0.0.1/src/upnext/config.py +65 -0
  16. upnext-0.0.1/src/upnext/engine/__init__.py +34 -0
  17. upnext-0.0.1/src/upnext/engine/cron.py +87 -0
  18. upnext-0.0.1/src/upnext/engine/event_router.py +149 -0
  19. upnext-0.0.1/src/upnext/engine/function_identity.py +45 -0
  20. upnext-0.0.1/src/upnext/engine/handlers/__init__.py +4 -0
  21. upnext-0.0.1/src/upnext/engine/handlers/event_handle.py +267 -0
  22. upnext-0.0.1/src/upnext/engine/handlers/task_handle.py +197 -0
  23. upnext-0.0.1/src/upnext/engine/job_processor.py +796 -0
  24. upnext-0.0.1/src/upnext/engine/queue/__init__.py +19 -0
  25. upnext-0.0.1/src/upnext/engine/queue/base.py +478 -0
  26. upnext-0.0.1/src/upnext/engine/queue/redis/__init__.py +6 -0
  27. upnext-0.0.1/src/upnext/engine/queue/redis/constants.py +28 -0
  28. upnext-0.0.1/src/upnext/engine/queue/redis/fetcher.py +90 -0
  29. upnext-0.0.1/src/upnext/engine/queue/redis/finisher.py +145 -0
  30. upnext-0.0.1/src/upnext/engine/queue/redis/queue.py +1215 -0
  31. upnext-0.0.1/src/upnext/engine/queue/redis/scripts/cancel.lua +47 -0
  32. upnext-0.0.1/src/upnext/engine/queue/redis/scripts/enqueue.lua +55 -0
  33. upnext-0.0.1/src/upnext/engine/queue/redis/scripts/finish.lua +59 -0
  34. upnext-0.0.1/src/upnext/engine/queue/redis/scripts/retry.lua +52 -0
  35. upnext-0.0.1/src/upnext/engine/queue/redis/scripts/sweep.lua +51 -0
  36. upnext-0.0.1/src/upnext/engine/queue/redis/sweeper.py +192 -0
  37. upnext-0.0.1/src/upnext/engine/redis.py +8 -0
  38. upnext-0.0.1/src/upnext/engine/registry.py +377 -0
  39. upnext-0.0.1/src/upnext/engine/runner.py +158 -0
  40. upnext-0.0.1/src/upnext/engine/status.py +289 -0
  41. upnext-0.0.1/src/upnext/sdk/__init__.py +23 -0
  42. upnext-0.0.1/src/upnext/sdk/api.py +485 -0
  43. upnext-0.0.1/src/upnext/sdk/artifacts.py +219 -0
  44. upnext-0.0.1/src/upnext/sdk/context.py +212 -0
  45. upnext-0.0.1/src/upnext/sdk/middleware.py +235 -0
  46. upnext-0.0.1/src/upnext/sdk/parallel.py +213 -0
  47. upnext-0.0.1/src/upnext/sdk/task.py +119 -0
  48. upnext-0.0.1/src/upnext/sdk/worker.py +779 -0
  49. upnext-0.0.1/src/upnext/types/__init__.py +5 -0
  50. upnext-0.0.1/src/upnext/types/executor.py +10 -0
  51. upnext-0.0.1/tests/conftest.py +15 -0
  52. upnext-0.0.1/tests/test_api_tracking_middleware.py +122 -0
  53. upnext-0.0.1/tests/test_cli_contracts.py +686 -0
  54. upnext-0.0.1/tests/test_identity_and_models.py +90 -0
  55. upnext-0.0.1/tests/test_job_processor.py +328 -0
  56. upnext-0.0.1/tests/test_package_publish_smoke.py +143 -0
  57. upnext-0.0.1/tests/test_redis_queue.py +159 -0
  58. upnext-0.0.1/tests/test_redis_queue_components.py +154 -0
  59. upnext-0.0.1/tests/test_redis_queue_edge_paths.py +196 -0
  60. upnext-0.0.1/tests/test_registry_and_event_handle.py +184 -0
  61. upnext-0.0.1/tests/test_runner_engine.py +307 -0
  62. upnext-0.0.1/tests/test_runtime_contracts.py +320 -0
  63. upnext-0.0.1/tests/test_task_handle.py +93 -0
  64. upnext-0.0.1/tests/test_worker_parallel_status.py +220 -0
  65. upnext-0.0.1/tests/test_worker_registration_and_execute.py +84 -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,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