operaton-tasks 1.0.0a1__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.
@@ -0,0 +1,20 @@
1
+ from operaton.tasks.api import external_task_worker
2
+ from operaton.tasks.api import handlers
3
+ from operaton.tasks.api import operaton_session
4
+ from operaton.tasks.api import router
5
+ from operaton.tasks.api import serve
6
+ from operaton.tasks.api import settings
7
+ from operaton.tasks.api import task
8
+ from operaton.tasks.api import task as register
9
+
10
+
11
+ __all__ = [
12
+ "external_task_worker",
13
+ "handlers",
14
+ "operaton_session",
15
+ "register",
16
+ "router",
17
+ "serve",
18
+ "settings",
19
+ "task",
20
+ ]
operaton/tasks/api.py ADDED
@@ -0,0 +1,91 @@
1
+ from operaton.tasks.config import handlers
2
+ from operaton.tasks.config import logger
3
+ from operaton.tasks.config import router
4
+ from operaton.tasks.config import settings
5
+ from operaton.tasks.types import ExternalTaskHandler
6
+ from operaton.tasks.types import ExternalTaskTopic
7
+ from operaton.tasks.utils import operaton_session
8
+ from operaton.tasks.worker import external_task_worker
9
+ from typing import Any
10
+ from typing import Callable
11
+ from typing import Optional
12
+ import sys
13
+
14
+
15
+ try:
16
+ import typer
17
+ import uvicorn
18
+
19
+ HAS_CLI = True
20
+ except ImportError:
21
+ typer: Any = None # type: ignore
22
+ uvicorn: Any = None # type: ignore
23
+
24
+ HAS_CLI = False
25
+
26
+
27
+ def task(
28
+ topic: str,
29
+ localVariables: bool = True,
30
+ ) -> Callable[[ExternalTaskHandler], ExternalTaskHandler]:
31
+ """Register function as a service task."""
32
+
33
+ def decorator(func: ExternalTaskHandler) -> ExternalTaskHandler:
34
+ handlers[topic] = ExternalTaskTopic(handler=func, localVariables=localVariables)
35
+ return func
36
+
37
+ return decorator
38
+
39
+
40
+ if HAS_CLI:
41
+ cli = typer.Typer()
42
+
43
+ @cli.command(name="serve")
44
+ def cli_serve(
45
+ base_url: str = "http://localhost:8080/engine-rest",
46
+ authorization: Optional[str] = None,
47
+ timeout: int = 20,
48
+ poll_ttl: int = 10,
49
+ lock_ttl: int = 30,
50
+ worker_id: str = "operaton-tasks-client",
51
+ log_level: str = "INFO",
52
+ args: Optional[list[str]] = typer.Argument(
53
+ default=None, help="arguments passed to uvicorn"
54
+ ),
55
+ ) -> None:
56
+ """CLI."""
57
+ settings.ENGINE_REST_BASE_URL = base_url
58
+ settings.ENGINE_REST_AUTHORIZATION = authorization
59
+ settings.ENGINE_REST_TIMEOUT_SECONDS = timeout
60
+ settings.ENGINE_REST_POLL_TTL_SECONDS = poll_ttl
61
+ settings.ENGINE_REST_LOCK_TTL_SECONDS = lock_ttl
62
+ settings.LOG_LEVEL = log_level
63
+ settings.TASKS_WORKER_ID = worker_id
64
+ settings.TASKS_MODULE = None
65
+
66
+ sys.argv = [sys.argv[0], "operaton.tasks.main:app"]
67
+ if args and "--no-proxy-headers" not in args:
68
+ sys.argv.append("--proxy-headers")
69
+ if args:
70
+ sys.argv.extend(args)
71
+ uvicorn.main()
72
+
73
+
74
+ def serve() -> None:
75
+ """Run Operaton External Service Task Worker."""
76
+ if HAS_CLI:
77
+ cli()
78
+ else:
79
+ logger.error("operaton-tasks[cli] required")
80
+ exit(1)
81
+
82
+
83
+ __all__ = [
84
+ "external_task_worker",
85
+ "handlers",
86
+ "operaton_session",
87
+ "router",
88
+ "serve",
89
+ "settings",
90
+ "task",
91
+ ]
@@ -0,0 +1,54 @@
1
+ from fastapi import APIRouter
2
+ from operaton.tasks.types import ExternalTaskTopic
3
+ from pydantic_settings import BaseSettings
4
+ from typing import Dict
5
+ from typing import Optional
6
+ import logging
7
+
8
+
9
+ # https://pydantic-docs.helpmanual.io/usage/settings/
10
+ class Settings(BaseSettings):
11
+ ENGINE_REST_BASE_URL: str = "http://localhost:8080/engine-rest"
12
+ ENGINE_REST_AUTHORIZATION: Optional[str] = None
13
+
14
+ ENGINE_REST_TIMEOUT_SECONDS: int = 20
15
+ ENGINE_REST_POLL_TTL_SECONDS: int = 10
16
+ ENGINE_REST_LOCK_TTL_SECONDS: int = 30
17
+
18
+ TASKS_HEARTBEAT_TOPIC: str = "operaton.tasks.heartbeat"
19
+ TASKS_WORKER_ID: str = "operaton-tasks-client"
20
+ TASKS_MODULE: Optional[str] = None
21
+
22
+ LOG_LEVEL: str = "DEBUG"
23
+
24
+
25
+ settings = Settings()
26
+
27
+ formatter = logging.Formatter(
28
+ "%(asctime)s | %(levelname)s | %(name)s:%(lineno)d | %(message)s",
29
+ "%d-%m-%Y %H:%M:%S",
30
+ )
31
+
32
+ stream_handler = logging.StreamHandler()
33
+ stream_handler.setFormatter(formatter)
34
+ stream_handler.setLevel(settings.LOG_LEVEL)
35
+
36
+ logger = logging.getLogger("operaton.tasks")
37
+ logger.addHandler(stream_handler)
38
+ logger.setLevel(settings.LOG_LEVEL)
39
+ logger.propagate = False
40
+
41
+
42
+ def update() -> None:
43
+ stream_handler.setLevel(settings.LOG_LEVEL)
44
+ logger.setLevel(settings.LOG_LEVEL)
45
+
46
+
47
+ # Built-in FastAPI router
48
+ router = APIRouter()
49
+
50
+ # All topics registered using the task decorator
51
+ handlers: Dict[str, ExternalTaskTopic] = {}
52
+
53
+
54
+ __all__ = ["logger", "settings", "router", "handlers", "update"]
@@ -0,0 +1,71 @@
1
+ from operaton.tasks import router
2
+ from operaton.tasks import task
3
+ from operaton.tasks.api import operaton_session
4
+ from operaton.tasks.config import settings
5
+ from operaton.tasks.types import CompleteExternalTaskDto
6
+ from operaton.tasks.types import ExternalTaskComplete
7
+ from operaton.tasks.types import LockedExternalTaskDto
8
+ from operaton.tasks.types import VariableValueDto
9
+ from operaton.tasks.utils import verify_response_status
10
+ from pydantic import BaseModel
11
+ from pydantic import Field
12
+ from pydantic.dataclasses import dataclass
13
+ from starlette.exceptions import HTTPException
14
+ from typing import Optional
15
+ import datetime
16
+
17
+
18
+ class Heartbeat(BaseModel):
19
+ """Health check response."""
20
+
21
+ timestamp: Optional[str] = Field(
22
+ None,
23
+ description="UTC timestamp of the last recorded heartbeat.",
24
+ )
25
+
26
+
27
+ @dataclass
28
+ class State:
29
+ """Service health check state."""
30
+
31
+ timestamp: Optional[str] = None
32
+
33
+
34
+ state = State()
35
+
36
+
37
+ @task(topic=settings.TASKS_HEARTBEAT_TOPIC)
38
+ async def handler(task: LockedExternalTaskDto) -> ExternalTaskComplete:
39
+ """Update health check timestamp."""
40
+ state.timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
41
+ return ExternalTaskComplete(
42
+ task=task,
43
+ response=CompleteExternalTaskDto(
44
+ workerId=task.workerId,
45
+ variables={
46
+ "timestamp": VariableValueDto(value=state.timestamp, type="string"),
47
+ },
48
+ ),
49
+ )
50
+
51
+
52
+ @router.get(
53
+ "/healthz", response_model=Heartbeat, summary="Service health status", tags=["Meta"]
54
+ )
55
+ async def healthz() -> Heartbeat:
56
+ """Service health status."""
57
+
58
+ # Without heartbeat external task triggered
59
+ if state.timestamp is None:
60
+ async with operaton_session() as session:
61
+ get = await session.get(settings.ENGINE_REST_BASE_URL + "/engine")
62
+ await verify_response_status(get, (200,))
63
+ timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
64
+ return Heartbeat(timestamp=timestamp)
65
+
66
+ # With heartbeat external task triggered at least once
67
+ now = datetime.datetime.now(datetime.timezone.utc)
68
+ if (now - datetime.timedelta(seconds=45)).isoformat() < state.timestamp:
69
+ return Heartbeat(timestamp=state.timestamp)
70
+ age = (now - datetime.datetime.fromisoformat(state.timestamp)).total_seconds()
71
+ raise HTTPException(status_code=500, detail=f"No heartbeat for {age} seconds")
operaton/tasks/main.py ADDED
@@ -0,0 +1,143 @@
1
+ """Operaton External Service Task Client"""
2
+
3
+ from contextlib import asynccontextmanager
4
+ from fastapi.applications import FastAPI
5
+ from operaton.tasks import healthz
6
+ from operaton.tasks.config import handlers
7
+ from operaton.tasks.config import router
8
+ from operaton.tasks.config import settings
9
+ from operaton.tasks.config import update
10
+ from operaton.tasks.worker import external_task_worker
11
+ from pathlib import Path
12
+ from starlette.requests import Request
13
+ from starlette.responses import Response
14
+ from typing import Any
15
+ from typing import AsyncGenerator
16
+ from typing import Awaitable
17
+ from typing import Callable
18
+ from typing import Optional
19
+ import asyncio
20
+ import hashlib
21
+ import importlib.util
22
+ import logging
23
+ import sys
24
+ import tempfile
25
+
26
+
27
+ try:
28
+ import typer
29
+ import uvicorn
30
+
31
+ HAS_CLI = True
32
+ except ImportError:
33
+ typer: Any = None # type: ignore
34
+ uvicorn: Any = None # type: ignore
35
+
36
+ HAS_CLI = False
37
+
38
+
39
+ assert healthz # import registers healthz task handler
40
+
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ @asynccontextmanager
46
+ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]:
47
+ """Start external task worker on FastAPI startup."""
48
+ update()
49
+ if settings.TASKS_MODULE:
50
+ module_name = hashlib.sha256(settings.TASKS_MODULE.encode("utf-8")).hexdigest()
51
+ spec = importlib.util.spec_from_file_location(
52
+ module_name, settings.TASKS_MODULE
53
+ )
54
+ if spec:
55
+ module = importlib.util.module_from_spec(spec)
56
+ if spec.loader:
57
+ spec.loader.exec_module(module)
58
+ asyncio.ensure_future(external_task_worker(handlers))
59
+ logger.info("Event loop: %s", asyncio.get_event_loop())
60
+ yield
61
+
62
+
63
+ app = FastAPI(
64
+ title="Operaton Tasks Client",
65
+ description="Operaton External Service Task Client",
66
+ version="0.1.0",
67
+ lifespan=lifespan,
68
+ )
69
+
70
+
71
+ app.include_router(router)
72
+
73
+
74
+ @app.middleware("http")
75
+ async def cache_headers(
76
+ request: Request, call_next: Callable[[Request], Awaitable[Response]]
77
+ ) -> Response:
78
+ """Set cache headers."""
79
+ response = await call_next(request)
80
+ response.headers["Cache-Control"] = "no-store, max-age=0"
81
+ return response
82
+
83
+
84
+ if HAS_CLI:
85
+ cli = typer.Typer()
86
+
87
+ @cli.command()
88
+ def serve(
89
+ module: Path,
90
+ base_url: str = "http://localhost:8080/engine-rest",
91
+ authorization: Optional[str] = None,
92
+ timeout: int = 20,
93
+ poll_ttl: int = 10,
94
+ lock_ttl: int = 30,
95
+ worker_id: str = "operaton-tasks-client",
96
+ log_level: str = "INFO",
97
+ args: Optional[list[str]] = typer.Argument(
98
+ default=None, help="arguments passed to uvicorn"
99
+ ),
100
+ ) -> None:
101
+ """CLI."""
102
+ settings.ENGINE_REST_BASE_URL = base_url
103
+ settings.ENGINE_REST_AUTHORIZATION = authorization
104
+ settings.ENGINE_REST_TIMEOUT_SECONDS = timeout
105
+ settings.ENGINE_REST_POLL_TTL_SECONDS = poll_ttl
106
+ settings.ENGINE_REST_LOCK_TTL_SECONDS = lock_ttl
107
+ settings.TASKS_WORKER_ID = worker_id
108
+ settings.LOG_LEVEL = log_level
109
+ settings.TASKS_MODULE = f"{module.absolute()}"
110
+
111
+ sys.argv = [sys.argv[0], "operaton.tasks.main:app"]
112
+ if args and "--no-proxy-headers" not in args:
113
+ sys.argv.append("--proxy-headers")
114
+ if args:
115
+ sys.argv.extend(args)
116
+ if args and "--reload" in args:
117
+ with tempfile.NamedTemporaryFile(mode="w+", delete=True) as temp_file:
118
+ temp_file.writelines(
119
+ [
120
+ f"ENGINE_REST_BASE_URL={base_url}\n",
121
+ f"ENGINE_REST_AUTHORIZATION={authorization}\n",
122
+ f"ENGINE_REST_TIMEOUT_SECONDS={timeout}\n",
123
+ f"ENGINE_REST_POLL_TTL_SECONDS={poll_ttl}\n",
124
+ f"ENGINE_REST_LOCK_TTL_SECONDS={lock_ttl}\n",
125
+ f"TASKS_WORKER_ID={worker_id}\n",
126
+ f"TASKS_MODULE={module}\n",
127
+ f"LOG_LEVEL={log_level}",
128
+ ]
129
+ )
130
+ temp_file.flush()
131
+ sys.argv.extend(["--env-file", temp_file.name])
132
+ uvicorn.main()
133
+ else:
134
+ uvicorn.main()
135
+
136
+
137
+ def main() -> None:
138
+ """Main."""
139
+ if HAS_CLI:
140
+ cli()
141
+ else:
142
+ logger.error("operaton-tasks[cli] required")
143
+ exit(1)
File without changes