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 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
+ ]
@@ -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)
@@ -0,0 +1,4 @@
1
+ # Generated by VelociKat — DO NOT EDIT
2
+ # template: kat v0.3.1
3
+ # file-hash: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
4
+
@@ -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,5 @@
1
+ """Temporal data converters for tempokat."""
2
+
3
+ from tempokat.converters.pydantic import PydanticPayloadConverter, pydantic_data_converter
4
+
5
+ __all__ = ["PydanticPayloadConverter", "pydantic_data_converter"]
@@ -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