tempokat 0.1.0__py3-none-any.whl
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.
- tempokat/__init__.py +16 -0
- tempokat/api/__init__.py +1 -0
- tempokat/api/deps.py +30 -0
- tempokat/api/routers/__init__.py +1 -0
- tempokat/api/routers/status_route.py +29 -0
- tempokat/cli/__init__.py +4 -0
- tempokat/cli/schedule.py +73 -0
- tempokat/cli/worker.py +116 -0
- tempokat/config.py +179 -0
- tempokat/converters/__init__.py +5 -0
- tempokat/converters/pydantic.py +47 -0
- tempokat/discovery.py +79 -0
- tempokat/factory.py +127 -0
- tempokat/init.py +55 -0
- tempokat/interceptors/__init__.py +5 -0
- tempokat/interceptors/sentry.py +79 -0
- tempokat/main.py +25 -0
- tempokat/models/__init__.py +9 -0
- tempokat/models/job.py +47 -0
- tempokat/models/visibility.py +71 -0
- tempokat/schedule.py +153 -0
- tempokat/utils.py +196 -0
- tempokat/version.py +14 -0
- tempokat/worker.py +233 -0
- tempokat-0.1.0.dist-info/METADATA +351 -0
- tempokat-0.1.0.dist-info/RECORD +28 -0
- tempokat-0.1.0.dist-info/WHEEL +4 -0
- tempokat-0.1.0.dist-info/entry_points.txt +2 -0
tempokat/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""tempokat — a VelociKat library."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from tempokat.factory import get_client, get_looper, get_worker
|
|
6
|
+
from tempokat.schedule import TemporalScheduler
|
|
7
|
+
from tempokat.worker import Looper, WorkerFactory
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Looper",
|
|
11
|
+
"TemporalScheduler",
|
|
12
|
+
"WorkerFactory",
|
|
13
|
+
"get_client",
|
|
14
|
+
"get_looper",
|
|
15
|
+
"get_worker",
|
|
16
|
+
]
|
tempokat/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""FastAPI integration for tempokat."""
|
tempokat/api/deps.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""FastAPI dependencies for Temporal integration."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
from fastapi import Depends, Request
|
|
7
|
+
from temporalio.client import Client
|
|
8
|
+
|
|
9
|
+
from tempokat.factory import get_client
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def get_temporal_client(request: Request) -> Client:
|
|
15
|
+
"""
|
|
16
|
+
FastAPI dependency that provides a cached Temporal client.
|
|
17
|
+
|
|
18
|
+
Expects `request.app.state.config` to be populated with a valid
|
|
19
|
+
configuration object containing `tempokat.temporalio`.
|
|
20
|
+
"""
|
|
21
|
+
if not hasattr(request.app.state, "temporal_client"):
|
|
22
|
+
logger.info("Initializing Temporal client for FastAPI app")
|
|
23
|
+
cfg = request.app.state.config
|
|
24
|
+
client = await get_client(cfg.tempokat.temporalio)
|
|
25
|
+
request.app.state.temporal_client = client
|
|
26
|
+
|
|
27
|
+
return request.app.state.temporal_client
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
TemporalClientDep = Annotated[Client, Depends(get_temporal_client)]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""FastAPI routers for tempokat."""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Agnostic Temporal workflow status router."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends
|
|
6
|
+
from temporalio.client import Client
|
|
7
|
+
|
|
8
|
+
from corekat.exceptions import UnauthorizedError
|
|
9
|
+
from tempokat.api.deps import get_temporal_client
|
|
10
|
+
from tempokat.models import AsyncResponse
|
|
11
|
+
from tempokat.utils import wait_for_result_from_async_response
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/api/workflows", tags=["Temporal", "Status"])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.post("/status", response_model=AsyncResponse)
|
|
17
|
+
async def workflow_status(
|
|
18
|
+
client: Annotated[Client, Depends(get_temporal_client)],
|
|
19
|
+
data: AsyncResponse,
|
|
20
|
+
wait_for: int = 0,
|
|
21
|
+
) -> AsyncResponse:
|
|
22
|
+
"""
|
|
23
|
+
Retrieves the status of an asynchronous Temporal workflow.
|
|
24
|
+
Can optionally block and wait for the result if wait_for > 0.
|
|
25
|
+
"""
|
|
26
|
+
if not data.check_signature():
|
|
27
|
+
raise UnauthorizedError("Callback signature mismatch")
|
|
28
|
+
|
|
29
|
+
return await wait_for_result_from_async_response(client=client, data=data, timeout=wait_for)
|
tempokat/cli/__init__.py
ADDED
tempokat/cli/schedule.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Temporal schedule sync CLI commands."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from corekat.config import LoggingConfigSchema
|
|
11
|
+
from tempokat.config import config as confload
|
|
12
|
+
from tempokat.factory import get_client
|
|
13
|
+
from tempokat.init import init
|
|
14
|
+
from tempokat.schedule import TemporalScheduler
|
|
15
|
+
|
|
16
|
+
app = typer.Typer()
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command(context_settings={"auto_envvar_prefix": "TEMPOKAT"})
|
|
21
|
+
def sync(
|
|
22
|
+
config: Annotated[
|
|
23
|
+
Path,
|
|
24
|
+
typer.Option(
|
|
25
|
+
"--config", "-c", exists=True, help="Configuration file in YAML format.", envvar="TEMPOKAT_CONFIG"
|
|
26
|
+
),
|
|
27
|
+
],
|
|
28
|
+
host: Annotated[
|
|
29
|
+
str | None,
|
|
30
|
+
typer.Option("--host", help="Temporal server host.", envvar="TEMPOKAT_HOST"),
|
|
31
|
+
] = None,
|
|
32
|
+
namespace: Annotated[
|
|
33
|
+
str | None,
|
|
34
|
+
typer.Option("--namespace", "-n", help="Temporal namespace.", envvar="TEMPOKAT_NAMESPACE"),
|
|
35
|
+
] = None,
|
|
36
|
+
log_level: Annotated[
|
|
37
|
+
str | None,
|
|
38
|
+
typer.Option("--log-level", help="Log level.", case_sensitive=False, envvar="TEMPOKAT_LOG_LEVEL"),
|
|
39
|
+
] = None,
|
|
40
|
+
use_colors: Annotated[
|
|
41
|
+
bool | None,
|
|
42
|
+
typer.Option("--use-colors/--no-use-colors", help="Colorized logging.", envvar="TEMPOKAT_USE_COLORS"),
|
|
43
|
+
] = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Synchronize schedule definitions with the Temporal server."""
|
|
46
|
+
_config = confload(str(config))
|
|
47
|
+
|
|
48
|
+
if host is not None:
|
|
49
|
+
_config.tempokat.temporalio.host = host
|
|
50
|
+
if namespace is not None:
|
|
51
|
+
_config.tempokat.temporalio.namespace = namespace
|
|
52
|
+
|
|
53
|
+
if log_level is not None or use_colors is not None:
|
|
54
|
+
current = _config.schema.logging
|
|
55
|
+
_config.schema.logging = LoggingConfigSchema(
|
|
56
|
+
level=log_level if log_level else current.level,
|
|
57
|
+
log_config=current.log_config,
|
|
58
|
+
use_colors=use_colors if use_colors is not None else current.use_colors,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
init(_config)
|
|
62
|
+
|
|
63
|
+
async def _run() -> None:
|
|
64
|
+
client = await get_client(_config.tempokat.temporalio)
|
|
65
|
+
scheduler = TemporalScheduler(client, _config.tempokat.schedules)
|
|
66
|
+
try:
|
|
67
|
+
await scheduler.sync_schedules()
|
|
68
|
+
logger.info("Schedule sync completed successfully")
|
|
69
|
+
except RuntimeError as e:
|
|
70
|
+
logger.error("Schedule sync failed: %s", e)
|
|
71
|
+
raise typer.Exit(code=1) from e
|
|
72
|
+
|
|
73
|
+
asyncio.run(_run())
|
tempokat/cli/worker.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Temporal worker CLI commands."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from corekat.config import LoggingConfigSchema
|
|
12
|
+
from tempokat.config import WorkerConfigSchema
|
|
13
|
+
from tempokat.factory import get_looper
|
|
14
|
+
from tempokat.init import init
|
|
15
|
+
|
|
16
|
+
app = typer.Typer()
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def worker_command(
|
|
21
|
+
confload: Callable,
|
|
22
|
+
) -> typer.models.CommandFunctionType:
|
|
23
|
+
"""Creates a typer command to display default config."""
|
|
24
|
+
|
|
25
|
+
app = typer.Typer()
|
|
26
|
+
|
|
27
|
+
@app.command(context_settings={"auto_envvar_prefix": "TEMPOKAT"})
|
|
28
|
+
def looper(
|
|
29
|
+
config: Annotated[
|
|
30
|
+
Path | None,
|
|
31
|
+
typer.Option(
|
|
32
|
+
"--config", "-c", exists=True, help="Configuration file in YAML format.", envvar="TEMPOKAT_CONFIG"
|
|
33
|
+
),
|
|
34
|
+
] = None,
|
|
35
|
+
host: Annotated[
|
|
36
|
+
str | None,
|
|
37
|
+
typer.Option("--host", help="Temporal server host.", envvar="TEMPOKAT_HOST"),
|
|
38
|
+
] = None,
|
|
39
|
+
namespace: Annotated[
|
|
40
|
+
str | None,
|
|
41
|
+
typer.Option("--namespace", "-n", help="Temporal namespace.", envvar="TEMPOKAT_NAMESPACE"),
|
|
42
|
+
] = None,
|
|
43
|
+
worker_name: Annotated[
|
|
44
|
+
str | None,
|
|
45
|
+
typer.Option("--worker", "-w", help="Worker name to run (defaults to first/only worker)."),
|
|
46
|
+
] = None,
|
|
47
|
+
queue: Annotated[
|
|
48
|
+
str | None,
|
|
49
|
+
typer.Option("--queue", "-q", help="Override task queue for ad-hoc single-worker run."),
|
|
50
|
+
] = None,
|
|
51
|
+
workflow: Annotated[
|
|
52
|
+
list[str] | None,
|
|
53
|
+
typer.Option("--workflow", help="Workflow import path (module:Class). Repeatable."),
|
|
54
|
+
] = None,
|
|
55
|
+
activity: Annotated[
|
|
56
|
+
list[str] | None,
|
|
57
|
+
typer.Option("--activity", "-a", help="Activity import path (module:fn). Repeatable."),
|
|
58
|
+
] = None,
|
|
59
|
+
interceptor: Annotated[
|
|
60
|
+
list[str] | None,
|
|
61
|
+
typer.Option("--interceptor", "-i", help="Interceptor import path. Repeatable."),
|
|
62
|
+
] = None,
|
|
63
|
+
log_level: Annotated[
|
|
64
|
+
str | None,
|
|
65
|
+
typer.Option("--log-level", help="Log level.", case_sensitive=False, envvar="TEMPOKAT_LOG_LEVEL"),
|
|
66
|
+
] = None,
|
|
67
|
+
log_config: Annotated[
|
|
68
|
+
str | None,
|
|
69
|
+
typer.Option("--log-config", exists=True, help="Logging config file (.ini/.json/.yaml)."),
|
|
70
|
+
] = None,
|
|
71
|
+
use_colors: Annotated[
|
|
72
|
+
bool | None,
|
|
73
|
+
typer.Option("--use-colors/--no-use-colors", help="Colorized logging.", envvar="TEMPOKAT_USE_COLORS"),
|
|
74
|
+
] = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Run Temporal worker(s) from configuration."""
|
|
77
|
+
_config = confload(str(config) if config else None)
|
|
78
|
+
|
|
79
|
+
# Apply CLI overrides to temporalio settings
|
|
80
|
+
if host is not None:
|
|
81
|
+
_config.tempokat.temporalio.host = host
|
|
82
|
+
if namespace is not None:
|
|
83
|
+
_config.tempokat.temporalio.namespace = namespace
|
|
84
|
+
for w in _config.tempokat.temporalio.workers:
|
|
85
|
+
w.namespace = namespace
|
|
86
|
+
# Re-propagate inheritance after overrides
|
|
87
|
+
_config.tempokat.temporalio.inherit_worker_settings()
|
|
88
|
+
|
|
89
|
+
# Ad-hoc single worker from CLI flags (no config file workers)
|
|
90
|
+
if not _config.tempokat.temporalio.workers and (queue or workflow):
|
|
91
|
+
_config.schema.tempokat.temporalio.workers = [
|
|
92
|
+
WorkerConfigSchema(
|
|
93
|
+
name="cli-worker",
|
|
94
|
+
queue=queue or "default-queue",
|
|
95
|
+
workflows=workflow or [],
|
|
96
|
+
activities=activity or [],
|
|
97
|
+
interceptors=interceptor or [],
|
|
98
|
+
)
|
|
99
|
+
]
|
|
100
|
+
_config.tempokat.temporalio.inherit_worker_settings()
|
|
101
|
+
|
|
102
|
+
# Re-initialize logging with CLI overrides (same pattern as servekat)
|
|
103
|
+
if log_level is not None or log_config is not None or use_colors is not None:
|
|
104
|
+
current = _config.schema.logging
|
|
105
|
+
_config.schema.logging = LoggingConfigSchema(
|
|
106
|
+
level=log_level if log_level else current.level,
|
|
107
|
+
log_config=log_config if log_config is not None else current.log_config,
|
|
108
|
+
use_colors=use_colors if use_colors is not None else current.use_colors,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
init(_config)
|
|
112
|
+
logger.info("Starting tempokat worker(s)")
|
|
113
|
+
looper = asyncio.run(get_looper(_config))
|
|
114
|
+
asyncio.run(looper.run())
|
|
115
|
+
|
|
116
|
+
return looper
|
tempokat/config.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, model_validator
|
|
6
|
+
from pydantic_settings import SettingsConfigDict
|
|
7
|
+
|
|
8
|
+
from corekat.config import BaseConfig
|
|
9
|
+
from corekat.config import Config as CoreKatConfig
|
|
10
|
+
from corekat.config import ConfigSchema as CoreKatConfigSchema
|
|
11
|
+
|
|
12
|
+
ENVPREFIX = "TEMPOKAT"
|
|
13
|
+
|
|
14
|
+
TIMEINTERVAL_REGEX = re.compile(r"((?P<hours>\d+?)h)?((?P<minutes>\d+?)m)?((?P<seconds>\d+?)s)?$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def time_interval(time_str: str) -> timedelta:
|
|
18
|
+
"""Parse time interval string like '1h30m45s' into timedelta."""
|
|
19
|
+
if not time_str:
|
|
20
|
+
raise ValueError(f"Invalid time string {time_str}")
|
|
21
|
+
parts = TIMEINTERVAL_REGEX.match(time_str)
|
|
22
|
+
if not parts:
|
|
23
|
+
raise ValueError(f"Invalid time string {time_str}")
|
|
24
|
+
parts = parts.groupdict()
|
|
25
|
+
time_params = {}
|
|
26
|
+
for name, param in parts.items():
|
|
27
|
+
if param:
|
|
28
|
+
time_params[name] = int(param)
|
|
29
|
+
return timedelta(**time_params)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TemporalIntervalSchema(BaseModel):
|
|
33
|
+
"""Temporal schedule interval configuration."""
|
|
34
|
+
|
|
35
|
+
every: str = Field(default="86400s", description="Interval duration (e.g., '1h', '30m', '86400s').")
|
|
36
|
+
offset: str | None = Field(default=None, description="Offset from interval start.")
|
|
37
|
+
|
|
38
|
+
def every_timedelta(self) -> timedelta:
|
|
39
|
+
"""Convert every field to timedelta."""
|
|
40
|
+
return time_interval(self.every)
|
|
41
|
+
|
|
42
|
+
def offset_timedelta(self) -> timedelta | None:
|
|
43
|
+
"""Convert offset field to timedelta."""
|
|
44
|
+
if self.offset is None:
|
|
45
|
+
return None
|
|
46
|
+
return time_interval(self.offset)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TemporalScheduleSchema(BaseModel):
|
|
50
|
+
"""Temporal schedule definition."""
|
|
51
|
+
|
|
52
|
+
workflow_id: str = Field(..., description="Unique workflow identifier.")
|
|
53
|
+
workflow: str = Field(..., description="Workflow import path (module:Class).")
|
|
54
|
+
input_schema: str = Field(default="", description="Input schema import path for validation.")
|
|
55
|
+
task_queue: str = Field(default="default-queue", description="Task queue name.")
|
|
56
|
+
interval: TemporalIntervalSchema = Field(default_factory=TemporalIntervalSchema, description="Schedule interval.")
|
|
57
|
+
comment: str = Field(default="", description="Schedule description/comment.")
|
|
58
|
+
payload: dict[str, Any] = Field(default_factory=dict, description="Workflow input payload.")
|
|
59
|
+
state: Literal["created", "paused", "deleted"] = Field(default="created", description="Schedule state.")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class WorkerConfigSchema(BaseModel):
|
|
63
|
+
"""Temporal worker configuration."""
|
|
64
|
+
|
|
65
|
+
name: str = Field(..., description="Worker name.")
|
|
66
|
+
queue: str = Field(..., description="Task queue to process.")
|
|
67
|
+
host: str | None = Field(default=None, description="Temporal server host (inherits from parent if not set).")
|
|
68
|
+
namespace: str | None = Field(default=None, description="Temporal namespace (inherits from parent if not set).")
|
|
69
|
+
factory: str | None = Field(default=None, description="Worker factory class path.")
|
|
70
|
+
workflows: list[str] = Field(default_factory=list, description="Workflow import paths or packages to discover.")
|
|
71
|
+
activities: list[str] = Field(default_factory=list, description="Activity import paths or packages to discover.")
|
|
72
|
+
discovery: bool = Field(default=True, description="Enable auto-discovery for module paths.")
|
|
73
|
+
discovery_recursive: bool = Field(default=True, description="Recursively scan submodules during discovery.")
|
|
74
|
+
interceptors: list[str] | None = Field(default=None, description="Interceptor import paths.")
|
|
75
|
+
converter: str | None = Field(default=None, description="Data converter import path.")
|
|
76
|
+
pre_init: list[str] | None = Field(default=None, description="Pre-initialization function paths.")
|
|
77
|
+
max_concurrent_activities: int = Field(default=100, description="Max concurrent activities.")
|
|
78
|
+
max_concurrent_workflow_tasks: int | None = Field(default=None, description="Max concurrent workflow tasks.")
|
|
79
|
+
metric_bind_address: str | None = Field(default=None, description="Prometheus metrics bind address.")
|
|
80
|
+
enable_metrics: bool | None = Field(default=None, description="Enable Prometheus metrics.")
|
|
81
|
+
debug_mode: bool = Field(default=False, description="Enable debug mode.")
|
|
82
|
+
disable_eager_activity_execution: bool = Field(default=True, description="Disable eager activity execution.")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TemporalConfigSchema(BaseConfig):
|
|
86
|
+
"""Temporal connection and worker configuration."""
|
|
87
|
+
|
|
88
|
+
host: str = Field(default="127.0.0.1:7233", description="Temporal server host.")
|
|
89
|
+
namespace: str = Field(default="default", description="Temporal namespace.")
|
|
90
|
+
default_factory: str = Field(
|
|
91
|
+
default="tempokat.worker:WorkerFactory",
|
|
92
|
+
description="Default worker factory class path.",
|
|
93
|
+
)
|
|
94
|
+
interceptors: list[str] = Field(default_factory=list, description="Global interceptor import paths.")
|
|
95
|
+
converter: str | None = Field(default=None, description="Global data converter import path.")
|
|
96
|
+
pre_init: list[str] = Field(default_factory=list, description="Global pre-initialization function paths.")
|
|
97
|
+
max_concurrent_activities: int = Field(default=100, description="Default max concurrent activities.")
|
|
98
|
+
max_concurrent_workflow_tasks: int = Field(default=100, description="Default max concurrent workflow tasks.")
|
|
99
|
+
metric_bind_address: str = Field(default="0.0.0.0:9000", description="Default Prometheus metrics bind address.")
|
|
100
|
+
enable_metrics: bool = Field(default=False, description="Enable Prometheus metrics by default.")
|
|
101
|
+
workers: list[WorkerConfigSchema] = Field(default_factory=list, description="Worker configurations.")
|
|
102
|
+
|
|
103
|
+
@model_validator(mode="after")
|
|
104
|
+
def inherit_worker_settings(self) -> "TemporalConfigSchema":
|
|
105
|
+
"""Apply top-level settings to workers that don't have them set."""
|
|
106
|
+
for worker in self.workers:
|
|
107
|
+
if worker.host is None:
|
|
108
|
+
worker.host = self.host
|
|
109
|
+
if worker.namespace is None:
|
|
110
|
+
worker.namespace = self.namespace
|
|
111
|
+
if worker.factory is None:
|
|
112
|
+
worker.factory = self.default_factory
|
|
113
|
+
if worker.converter is None:
|
|
114
|
+
worker.converter = self.converter
|
|
115
|
+
if worker.interceptors is None:
|
|
116
|
+
worker.interceptors = self.interceptors
|
|
117
|
+
if worker.pre_init is None:
|
|
118
|
+
worker.pre_init = self.pre_init
|
|
119
|
+
if worker.max_concurrent_activities is None:
|
|
120
|
+
worker.max_concurrent_activities = self.max_concurrent_activities
|
|
121
|
+
if worker.max_concurrent_workflow_tasks is None:
|
|
122
|
+
worker.max_concurrent_workflow_tasks = self.max_concurrent_workflow_tasks
|
|
123
|
+
if worker.metric_bind_address is None:
|
|
124
|
+
worker.metric_bind_address = self.metric_bind_address
|
|
125
|
+
if worker.enable_metrics is None:
|
|
126
|
+
worker.enable_metrics = self.enable_metrics
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TempokatConfigSchema(BaseConfig):
|
|
131
|
+
"""Main tempokat config schema."""
|
|
132
|
+
|
|
133
|
+
temporalio: TemporalConfigSchema = Field(
|
|
134
|
+
default_factory=TemporalConfigSchema,
|
|
135
|
+
description="Temporal configuration.",
|
|
136
|
+
)
|
|
137
|
+
schedules: dict[str, TemporalScheduleSchema] = Field(
|
|
138
|
+
default_factory=dict,
|
|
139
|
+
description="Schedule definitions.",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class TempokatSchema(CoreKatConfigSchema):
|
|
144
|
+
"""Main tempokat config schema."""
|
|
145
|
+
|
|
146
|
+
model_config = SettingsConfigDict(
|
|
147
|
+
env_prefix=f"{ENVPREFIX}_",
|
|
148
|
+
env_nested_delimiter="__",
|
|
149
|
+
case_sensitive=False,
|
|
150
|
+
extra="allow",
|
|
151
|
+
)
|
|
152
|
+
tempokat: TempokatConfigSchema = Field(
|
|
153
|
+
default_factory=TempokatConfigSchema,
|
|
154
|
+
description="Temporal configuration.",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class Config(CoreKatConfig[TempokatSchema]):
|
|
159
|
+
"""tempokat typed configuration manager."""
|
|
160
|
+
|
|
161
|
+
__config_class__ = TempokatSchema
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def tempokat(self) -> TempokatConfigSchema:
|
|
165
|
+
"""Access tempokat config."""
|
|
166
|
+
return self.schema.tempokat
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def config(path: str | None = None) -> Config:
|
|
170
|
+
"""
|
|
171
|
+
Convenience function to load default CoreKat configuration.
|
|
172
|
+
For custom schemas, use Config.load() directly.
|
|
173
|
+
|
|
174
|
+
Example:
|
|
175
|
+
cfg = config("myconfig.yaml")
|
|
176
|
+
print(cfg.schema.name)
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
return Config.load(TempokatSchema, path=path, env_prefix=ENVPREFIX)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Pydantic-aware Temporal payload converter."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from temporalio.api.common.v1 import Payload
|
|
7
|
+
from temporalio.converter import (
|
|
8
|
+
CompositePayloadConverter,
|
|
9
|
+
DataConverter,
|
|
10
|
+
DefaultPayloadConverter,
|
|
11
|
+
JSONPlainPayloadConverter,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PydanticJSONPayloadConverter(JSONPlainPayloadConverter):
|
|
16
|
+
"""
|
|
17
|
+
Extends JSONPlainPayloadConverter to serialize Pydantic models via model_dump_json().
|
|
18
|
+
|
|
19
|
+
Falls back to standard json.dumps for non-Pydantic values.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def to_payload(self, value: Any) -> Payload | None:
|
|
23
|
+
if hasattr(value, "model_dump_json"):
|
|
24
|
+
return Payload(
|
|
25
|
+
metadata={"encoding": self.encoding.encode()},
|
|
26
|
+
data=value.model_dump_json().encode(),
|
|
27
|
+
)
|
|
28
|
+
return Payload(
|
|
29
|
+
metadata={"encoding": self.encoding.encode()},
|
|
30
|
+
data=json.dumps(value).encode(),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PydanticPayloadConverter(CompositePayloadConverter):
|
|
35
|
+
"""Composite payload converter that replaces Temporal's default JSON converter with Pydantic-aware one."""
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
super().__init__(
|
|
39
|
+
*(
|
|
40
|
+
c if not isinstance(c, JSONPlainPayloadConverter) else PydanticJSONPayloadConverter()
|
|
41
|
+
for c in DefaultPayloadConverter.default_encoding_payload_converters
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
pydantic_data_converter = DataConverter(payload_converter_class=PydanticPayloadConverter)
|
|
47
|
+
"""DataConverter using Pydantic JSON serialization."""
|
tempokat/discovery.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Temporal auto-discovery utilities for workflows and activities."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import inspect
|
|
5
|
+
import logging
|
|
6
|
+
import pkgutil
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _inspect_module(module: ModuleType, activities: set[str], workflows: set[str]) -> None:
|
|
13
|
+
"""Safely inspects a module to find temporal activities and workflows."""
|
|
14
|
+
try:
|
|
15
|
+
members = inspect.getmembers(module)
|
|
16
|
+
except Exception as e:
|
|
17
|
+
logger.debug("Failed to inspect module %s: %s", getattr(module, "__name__", "unknown"), e)
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
for name, obj in members:
|
|
21
|
+
try:
|
|
22
|
+
# Ensure the object is defined in this exact module to avoid duplicate registrations from imports
|
|
23
|
+
if getattr(obj, "__module__", None) != module.__name__:
|
|
24
|
+
continue
|
|
25
|
+
|
|
26
|
+
if inspect.isfunction(obj) and hasattr(obj, "__temporal_activity_definition"):
|
|
27
|
+
activities.add(f"{module.__name__}:{name}")
|
|
28
|
+
elif inspect.isclass(obj) and hasattr(obj, "__temporal_workflow_definition"):
|
|
29
|
+
workflows.add(f"{module.__name__}:{name}")
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def discover_temporal_components(
|
|
35
|
+
packages: list[str],
|
|
36
|
+
recursive: bool = True,
|
|
37
|
+
discover_activities: bool = True,
|
|
38
|
+
discover_workflows: bool = True,
|
|
39
|
+
) -> tuple[list[str], list[str]]:
|
|
40
|
+
"""
|
|
41
|
+
Scans a list of packages for Temporal activities and workflows.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
packages: A list of package names to scan.
|
|
45
|
+
recursive: Whether to recursively scan all submodules inside the packages.
|
|
46
|
+
discover_activities: Whether to inspect and return discovered activities.
|
|
47
|
+
discover_workflows: Whether to inspect and return discovered workflows.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
A tuple containing two lists: sorted activity import paths and sorted workflow import paths.
|
|
51
|
+
"""
|
|
52
|
+
activities: set[str] = set()
|
|
53
|
+
workflows: set[str] = set()
|
|
54
|
+
|
|
55
|
+
for package_name in packages:
|
|
56
|
+
try:
|
|
57
|
+
package = importlib.import_module(package_name)
|
|
58
|
+
except ImportError as e:
|
|
59
|
+
logger.warning("Could not import package '%s' for Temporal auto-discovery: %s", package_name, e)
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
_inspect_module(package, activities, workflows)
|
|
63
|
+
|
|
64
|
+
if recursive:
|
|
65
|
+
prefix = package.__name__ + "."
|
|
66
|
+
package_path = getattr(package, "__path__", [])
|
|
67
|
+
|
|
68
|
+
for module_info in pkgutil.walk_packages(package_path, prefix):
|
|
69
|
+
try:
|
|
70
|
+
module = importlib.import_module(module_info.name)
|
|
71
|
+
_inspect_module(module, activities, workflows)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.debug("Could not inspect submodule '%s': %s", module_info.name, e)
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
result_activities = sorted(list(activities)) if discover_activities else []
|
|
77
|
+
result_workflows = sorted(list(workflows)) if discover_workflows else []
|
|
78
|
+
|
|
79
|
+
return result_activities, result_workflows
|