render_sdk 0.1.2__py3-none-any.whl → 0.2.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.
- render_sdk/__init__.py +41 -4
- render_sdk/client/__init__.py +25 -0
- render_sdk/client/client.py +5 -0
- render_sdk/client/sse.py +5 -1
- render_sdk/client/tests/test_client.py +6 -4
- render_sdk/client/tests/test_sse.py +1 -0
- render_sdk/client/types.py +2 -1
- render_sdk/client/workflows.py +13 -3
- render_sdk/experimental/__init__.py +31 -0
- render_sdk/experimental/experimental.py +71 -0
- render_sdk/experimental/object/__init__.py +30 -0
- render_sdk/experimental/object/api.py +260 -0
- render_sdk/experimental/object/client.py +475 -0
- render_sdk/experimental/object/types.py +87 -0
- render_sdk/public_api/api/audit_logs/list_organization_audit_logs.py +303 -0
- render_sdk/public_api/api/audit_logs/list_owner_audit_logs.py +303 -0
- render_sdk/public_api/api/blob_storage/delete_blob.py +215 -0
- render_sdk/public_api/api/blob_storage/get_blob.py +221 -0
- render_sdk/public_api/api/{workflows/list_workflow_versions.py → blob_storage/list_blobs.py} +52 -30
- render_sdk/public_api/api/blob_storage/put_blob.py +248 -0
- render_sdk/public_api/api/blueprints/validate_blueprint.py +212 -0
- render_sdk/public_api/api/key_value/resume_key_value.py +203 -0
- render_sdk/public_api/api/key_value/suspend_key_value.py +203 -0
- render_sdk/public_api/api/metrics/get_bandwidth_sources.py +251 -0
- render_sdk/public_api/api/postgres/create_postgres_user.py +229 -0
- render_sdk/public_api/api/postgres/delete_postgres_user.py +201 -0
- render_sdk/public_api/api/postgres/list_postgres_users.py +195 -0
- render_sdk/public_api/api/redis_deprecated/__init__.py +1 -0
- render_sdk/public_api/api/{redis → redis_deprecated}/create_redis.py +4 -4
- render_sdk/public_api/api/{redis → redis_deprecated}/delete_redis.py +4 -4
- render_sdk/public_api/api/{redis → redis_deprecated}/list_redis.py +4 -0
- render_sdk/public_api/api/{redis → redis_deprecated}/retrieve_redis.py +4 -4
- render_sdk/public_api/api/{redis → redis_deprecated}/retrieve_redis_connection_info.py +4 -0
- render_sdk/public_api/api/{redis → redis_deprecated}/update_redis.py +4 -4
- render_sdk/public_api/api/services/create_service.py +4 -4
- render_sdk/public_api/api/workflow_tasks_ea/__init__.py +1 -0
- render_sdk/public_api/api/{workflows → workflow_tasks_ea}/cancel_task_run.py +12 -4
- render_sdk/public_api/api/{workflows → workflow_tasks_ea}/create_task.py +12 -4
- render_sdk/public_api/api/{workflows → workflow_tasks_ea}/get_task.py +12 -4
- render_sdk/public_api/api/{workflows → workflow_tasks_ea}/get_task_run.py +12 -4
- render_sdk/public_api/api/{workflows → workflow_tasks_ea}/list_task_runs.py +12 -0
- render_sdk/public_api/api/{workflows → workflow_tasks_ea}/list_tasks.py +24 -12
- render_sdk/public_api/api/workflows_ea/__init__.py +1 -0
- render_sdk/public_api/api/workflows_ea/create_workflow.py +199 -0
- render_sdk/public_api/api/{workflows/deploy_workflow.py → workflows_ea/create_workflow_version.py} +31 -14
- render_sdk/public_api/api/{workflows → workflows_ea}/delete_workflow.py +12 -4
- render_sdk/public_api/api/{workflows → workflows_ea}/get_workflow.py +32 -14
- render_sdk/public_api/api/{workflows → workflows_ea}/get_workflow_version.py +12 -4
- render_sdk/public_api/api/workflows_ea/list_workflow_versions.py +275 -0
- render_sdk/public_api/api/{workflows → workflows_ea}/list_workflows.py +41 -14
- render_sdk/public_api/api/workflows_ea/update_workflow.py +212 -0
- render_sdk/public_api/api/workspaces/remove_workspace_member.py +206 -0
- render_sdk/public_api/api/workspaces/update_workspace_member.py +235 -0
- render_sdk/public_api/models/__init__.py +82 -4
- render_sdk/public_api/models/audit_log.py +113 -0
- render_sdk/public_api/models/audit_log_actor.py +80 -0
- render_sdk/public_api/models/audit_log_actor_type.py +10 -0
- render_sdk/public_api/models/audit_log_event.py +80 -0
- render_sdk/public_api/models/audit_log_metadata.py +49 -0
- render_sdk/public_api/models/audit_log_status.py +9 -0
- render_sdk/public_api/models/audit_log_with_cursor.py +73 -0
- render_sdk/public_api/models/background_worker_details.py +2 -2
- render_sdk/public_api/models/background_worker_details_patch.py +1 -1
- render_sdk/public_api/models/background_worker_details_post.py +1 -1
- render_sdk/public_api/models/blob_metadata.py +85 -0
- render_sdk/public_api/models/blob_with_cursor.py +73 -0
- render_sdk/public_api/models/cache.py +6 -4
- render_sdk/public_api/models/cache_profile.py +10 -0
- render_sdk/public_api/models/create_deploy_body.py +23 -0
- render_sdk/public_api/models/create_version.py +70 -0
- render_sdk/public_api/models/credential_create_input.py +59 -0
- render_sdk/public_api/models/cron_job_details.py +2 -2
- render_sdk/public_api/models/cron_job_details_patch.py +1 -1
- render_sdk/public_api/models/cron_job_details_post.py +1 -1
- render_sdk/public_api/models/deploy_mode.py +9 -0
- render_sdk/public_api/models/event.py +11 -27
- render_sdk/public_api/models/event_type.py +1 -1
- render_sdk/public_api/models/get_bandwidth_sources_response_200.py +75 -0
- render_sdk/public_api/models/get_bandwidth_sources_response_200_data_item.py +101 -0
- render_sdk/public_api/models/get_bandwidth_sources_response_200_data_item_labels.py +78 -0
- render_sdk/public_api/models/get_bandwidth_sources_response_200_data_item_labels_traffic_source.py +12 -0
- render_sdk/public_api/models/get_bandwidth_sources_response_200_data_item_values_item.py +68 -0
- render_sdk/public_api/models/{server_unhealthy.py → get_bandwidth_sources_response_400.py} +12 -12
- render_sdk/public_api/models/get_blob_output.py +71 -0
- render_sdk/public_api/models/list_postgres_users_response_200_item.py +86 -0
- render_sdk/public_api/models/otel_provider_type.py +2 -0
- render_sdk/public_api/models/postgres.py +8 -0
- render_sdk/public_api/models/postgres_detail.py +26 -0
- render_sdk/public_api/models/postgres_parameter_overrides.py +44 -0
- render_sdk/public_api/models/postgres_patch_input.py +27 -0
- render_sdk/public_api/models/postgres_post_input.py +27 -0
- render_sdk/public_api/models/postgres_version.py +1 -0
- render_sdk/public_api/models/preview_input.py +2 -2
- render_sdk/public_api/models/private_service_details.py +2 -2
- render_sdk/public_api/models/private_service_details_patch.py +1 -1
- render_sdk/public_api/models/private_service_details_post.py +1 -1
- render_sdk/public_api/models/project_post_environment_input.py +26 -1
- render_sdk/public_api/models/put_blob_input.py +59 -0
- render_sdk/public_api/models/put_blob_output.py +79 -0
- render_sdk/public_api/models/read_replica.py +25 -1
- render_sdk/public_api/models/read_replica_input.py +25 -1
- render_sdk/public_api/models/run_task.py +35 -7
- render_sdk/public_api/models/service_event.py +12 -27
- render_sdk/public_api/models/service_event_type.py +0 -1
- render_sdk/public_api/models/service_post.py +9 -6
- render_sdk/public_api/models/task_attempt.py +88 -0
- render_sdk/public_api/models/task_attempt_details.py +108 -0
- render_sdk/public_api/models/task_data_type_1.py +44 -0
- render_sdk/public_api/models/task_run.py +23 -1
- render_sdk/public_api/models/task_run_details.py +50 -5
- render_sdk/public_api/models/task_run_status.py +1 -0
- render_sdk/public_api/models/task_with_cursor.py +73 -0
- render_sdk/public_api/models/team_member.py +5 -4
- render_sdk/public_api/models/team_member_role.py +12 -0
- render_sdk/public_api/models/update_workspace_member_body.py +61 -0
- render_sdk/public_api/models/validate_blueprint_request.py +84 -0
- render_sdk/public_api/models/validate_blueprint_response.py +105 -0
- render_sdk/public_api/models/validation_error.py +88 -0
- render_sdk/public_api/models/validation_plan_summary.py +107 -0
- render_sdk/public_api/models/web_service_details.py +2 -2
- render_sdk/public_api/models/web_service_details_patch.py +6 -5
- render_sdk/public_api/models/web_service_details_post.py +6 -5
- render_sdk/public_api/models/workflow.py +144 -0
- render_sdk/public_api/models/workflow_create.py +99 -0
- render_sdk/public_api/models/workflow_update.py +90 -0
- render_sdk/public_api/models/workflow_version.py +10 -14
- render_sdk/public_api/models/workflow_version_status.py +13 -0
- render_sdk/public_api/models/workflow_version_with_cursor.py +73 -0
- render_sdk/public_api/models/workflow_with_cursor.py +73 -0
- render_sdk/render.py +65 -0
- render_sdk/version.py +27 -0
- render_sdk/workflows/__init__.py +5 -1
- render_sdk/workflows/app.py +262 -0
- render_sdk/workflows/callback_api/models/__init__.py +2 -0
- render_sdk/workflows/callback_api/models/task.py +21 -0
- render_sdk/workflows/callback_api/models/task_options.py +18 -0
- render_sdk/workflows/callback_api/models/task_parameter.py +88 -0
- render_sdk/workflows/callback_api/py.typed +1 -1
- render_sdk/workflows/cli.py +58 -0
- render_sdk/workflows/client.py +8 -9
- render_sdk/workflows/executor.py +19 -7
- render_sdk/workflows/runner.py +43 -10
- render_sdk/workflows/task.py +84 -5
- render_sdk/workflows/tests/test_app.py +412 -0
- render_sdk/workflows/tests/test_cli.py +134 -0
- render_sdk/workflows/tests/test_end_to_end.py +71 -1
- render_sdk/workflows/tests/test_registration.py +58 -1
- {render_sdk-0.1.2.dist-info → render_sdk-0.2.0.dist-info}/METADATA +4 -3
- {render_sdk-0.1.2.dist-info → render_sdk-0.2.0.dist-info}/RECORD +155 -83
- {render_sdk-0.1.2.dist-info → render_sdk-0.2.0.dist-info}/WHEEL +1 -1
- render_sdk-0.2.0.dist-info/entry_points.txt +3 -0
- render_sdk/public_api/models/image_version.py +0 -79
- /render_sdk/public_api/api/{redis → audit_logs}/__init__.py +0 -0
- /render_sdk/public_api/api/{workflows → blob_storage}/__init__.py +0 -0
- /render_sdk/public_api/api/{workflows → workflow_tasks_ea}/stream_task_runs_events.py +0 -0
- {render_sdk-0.1.2.dist-info → render_sdk-0.2.0.dist-info/licenses}/LICENSE +0 -0
render_sdk/workflows/runner.py
CHANGED
|
@@ -10,11 +10,13 @@ from render_sdk.workflows.callback_api.models import (
|
|
|
10
10
|
RetryConfig,
|
|
11
11
|
Task,
|
|
12
12
|
TaskOptions,
|
|
13
|
+
TaskParameter,
|
|
13
14
|
Tasks,
|
|
14
15
|
)
|
|
16
|
+
from render_sdk.workflows.callback_api.types import UNSET, Unset
|
|
15
17
|
from render_sdk.workflows.client import UDSClient
|
|
16
18
|
from render_sdk.workflows.executor import TaskExecutor
|
|
17
|
-
from render_sdk.workflows.task import get_task_registry
|
|
19
|
+
from render_sdk.workflows.task import ParameterInfo, get_task_registry
|
|
18
20
|
|
|
19
21
|
logger = logging.getLogger(__name__)
|
|
20
22
|
|
|
@@ -86,15 +88,27 @@ async def register_async(socket_path: str) -> None:
|
|
|
86
88
|
|
|
87
89
|
options = TaskOptions()
|
|
88
90
|
# Add options if present
|
|
89
|
-
if task_info and task_info.options
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
91
|
+
if task_info and task_info.options:
|
|
92
|
+
if task_info.options.retry:
|
|
93
|
+
retry = task_info.options.retry
|
|
94
|
+
options.retry = RetryConfig(
|
|
95
|
+
max_retries=retry.max_retries,
|
|
96
|
+
wait_duration_ms=retry.wait_duration_ms,
|
|
97
|
+
factor=retry.backoff_scaling,
|
|
98
|
+
)
|
|
99
|
+
if task_info.options.timeout_seconds:
|
|
100
|
+
options.timeout_seconds = task_info.options.timeout_seconds
|
|
101
|
+
if task_info.options.plan:
|
|
102
|
+
options.plan = task_info.options.plan
|
|
103
|
+
|
|
104
|
+
parameters: list[TaskParameter] | Unset = UNSET
|
|
105
|
+
if task_info and task_info.parameters:
|
|
106
|
+
parameters = [
|
|
107
|
+
_convert_parameter_info_to_api_model(param)
|
|
108
|
+
for param in task_info.parameters
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
task_def = Task(name=name, options=options, parameters=parameters)
|
|
98
112
|
tasks.append(task_def)
|
|
99
113
|
|
|
100
114
|
# Register tasks with server
|
|
@@ -138,3 +152,22 @@ def start() -> None:
|
|
|
138
152
|
register(socket_path)
|
|
139
153
|
else:
|
|
140
154
|
raise ValueError(f"Unknown mode: {mode}")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _convert_parameter_info_to_api_model(param_info: ParameterInfo) -> TaskParameter:
|
|
158
|
+
"""Convert internal ParameterInfo to API TaskParameter model."""
|
|
159
|
+
# JSON-encode the default value if it exists
|
|
160
|
+
default_value_str = None
|
|
161
|
+
if param_info.has_default and param_info.default_value is not None:
|
|
162
|
+
try:
|
|
163
|
+
default_value_str = json.dumps(param_info.default_value)
|
|
164
|
+
except (TypeError, ValueError):
|
|
165
|
+
# If the default can't be serialized, skip it
|
|
166
|
+
default_value_str = None
|
|
167
|
+
|
|
168
|
+
return TaskParameter(
|
|
169
|
+
name=param_info.name,
|
|
170
|
+
has_default=param_info.has_default,
|
|
171
|
+
type_=param_info.type_hint if param_info.type_hint is not None else UNSET,
|
|
172
|
+
default_value=default_value_str if default_value_str is not None else UNSET,
|
|
173
|
+
)
|
render_sdk/workflows/task.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import contextvars
|
|
5
5
|
import functools
|
|
6
|
+
import inspect
|
|
6
7
|
from abc import ABC, abstractmethod
|
|
7
8
|
from collections.abc import Callable
|
|
8
9
|
from dataclasses import dataclass
|
|
@@ -20,14 +21,36 @@ class Retry:
|
|
|
20
21
|
|
|
21
22
|
max_retries: int
|
|
22
23
|
wait_duration_ms: int
|
|
23
|
-
|
|
24
|
+
backoff_scaling: float = 1.5
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
@dataclass
|
|
27
28
|
class Options:
|
|
28
|
-
"""Configuration options for a task.
|
|
29
|
+
"""Configuration options for a task.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
retry: Retry configuration for automatic task retries.
|
|
33
|
+
timeout_seconds: Task execution timeout in seconds (30-86400).
|
|
34
|
+
plan: Resource plan for task execution. Options: "starter" (0.5CPU/512MB),
|
|
35
|
+
"standard" (1CPU/2GB), "pro" (2CPU/4GB). Defaults to "standard".
|
|
36
|
+
"""
|
|
29
37
|
|
|
30
38
|
retry: Retry | None = None
|
|
39
|
+
timeout_seconds: int | None = None
|
|
40
|
+
plan: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ParameterInfo:
|
|
45
|
+
"""
|
|
46
|
+
Information about a task parameter extracted from the task's function
|
|
47
|
+
signature.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
name: str
|
|
51
|
+
type_hint: str | None
|
|
52
|
+
has_default: bool
|
|
53
|
+
default_value: Any | None = None
|
|
31
54
|
|
|
32
55
|
|
|
33
56
|
class TaskResult:
|
|
@@ -59,10 +82,17 @@ class TaskContext(ABC):
|
|
|
59
82
|
class TaskInfo:
|
|
60
83
|
"""Information about a registered task."""
|
|
61
84
|
|
|
62
|
-
def __init__(
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
func: Callable,
|
|
88
|
+
name: str,
|
|
89
|
+
options: Options | None = None,
|
|
90
|
+
parameters: list[ParameterInfo] | None = None,
|
|
91
|
+
):
|
|
63
92
|
self.func = func
|
|
64
93
|
self.name = name
|
|
65
94
|
self.options = options or Options()
|
|
95
|
+
self.parameters = parameters
|
|
66
96
|
|
|
67
97
|
|
|
68
98
|
class TaskRegistry:
|
|
@@ -71,6 +101,35 @@ class TaskRegistry:
|
|
|
71
101
|
def __init__(self) -> None:
|
|
72
102
|
self._tasks: dict[str, TaskInfo] = {}
|
|
73
103
|
|
|
104
|
+
def _extract_parameters(self, func: Callable) -> list[ParameterInfo]:
|
|
105
|
+
"""Extract parameter information from a function signature."""
|
|
106
|
+
sig = inspect.signature(func)
|
|
107
|
+
parameters: list[ParameterInfo] = []
|
|
108
|
+
|
|
109
|
+
for param_name, param in sig.parameters.items():
|
|
110
|
+
# Get type hint as string if available
|
|
111
|
+
type_hint: str | None = None
|
|
112
|
+
if param.annotation is not inspect.Parameter.empty:
|
|
113
|
+
if hasattr(param.annotation, "__name__"):
|
|
114
|
+
type_hint = param.annotation.__name__
|
|
115
|
+
else:
|
|
116
|
+
type_hint = str(param.annotation)
|
|
117
|
+
|
|
118
|
+
# Check if the parameter has a default value
|
|
119
|
+
has_default = param.default is not inspect.Parameter.empty
|
|
120
|
+
default_value = param.default if has_default else None
|
|
121
|
+
|
|
122
|
+
parameters.append(
|
|
123
|
+
ParameterInfo(
|
|
124
|
+
name=param_name,
|
|
125
|
+
type_hint=type_hint,
|
|
126
|
+
has_default=has_default,
|
|
127
|
+
default_value=default_value,
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return parameters
|
|
132
|
+
|
|
74
133
|
def register(
|
|
75
134
|
self,
|
|
76
135
|
func: Callable,
|
|
@@ -80,7 +139,9 @@ class TaskRegistry:
|
|
|
80
139
|
"""Register a task function."""
|
|
81
140
|
task_name = name or func.__name__
|
|
82
141
|
|
|
83
|
-
|
|
142
|
+
parameters = self._extract_parameters(func)
|
|
143
|
+
|
|
144
|
+
task_info = TaskInfo(func, task_name, options, parameters)
|
|
84
145
|
|
|
85
146
|
if task_name in self._tasks:
|
|
86
147
|
raise ValueError(f"Task '{task_name}' already registered")
|
|
@@ -139,8 +200,26 @@ class TaskCallable:
|
|
|
139
200
|
def __call__(self, *args, **kwargs):
|
|
140
201
|
# Create a new TaskInstance for each call
|
|
141
202
|
client = _current_client.get()
|
|
203
|
+
|
|
204
|
+
# Error on mixed positional and kw args
|
|
205
|
+
if args and kwargs:
|
|
206
|
+
raise ValueError(
|
|
207
|
+
"Cannot mix positional and keyword arguments when calling a task. "
|
|
208
|
+
"Use either positional arguments (e.g., task(arg1, arg2)) or "
|
|
209
|
+
"keyword arguments (e.g., task(param1=value1, param2=value2)), "
|
|
210
|
+
"but not both."
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Determine input data type based on how the task was called
|
|
214
|
+
if kwargs:
|
|
215
|
+
# Named parameters: pass as dict
|
|
216
|
+
input_data: dict[str, Any] = kwargs
|
|
217
|
+
else:
|
|
218
|
+
# Positional parameters: pass as list
|
|
219
|
+
input_data = list(args)
|
|
220
|
+
|
|
142
221
|
# Start execution immediately
|
|
143
|
-
future = asyncio.create_task(client.run_subtask(self._name,
|
|
222
|
+
future = asyncio.create_task(client.run_subtask(self._name, input_data))
|
|
144
223
|
return TaskInstance(self._name, future)
|
|
145
224
|
|
|
146
225
|
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Tests for the Workflows application class."""
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from render_sdk.workflows.app import Workflows
|
|
8
|
+
from render_sdk.workflows.task import Retry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Reset global state before each test
|
|
12
|
+
@pytest.fixture(autouse=True)
|
|
13
|
+
def reset_auto_start_state():
|
|
14
|
+
"""Reset the global auto_start tracking state before each test."""
|
|
15
|
+
import render_sdk.workflows.app as app_module
|
|
16
|
+
|
|
17
|
+
app_module._auto_start_app_registered = False
|
|
18
|
+
yield
|
|
19
|
+
app_module._auto_start_app_registered = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestFromWorkflows:
|
|
23
|
+
"""Tests for Workflows.from_workflows() composition."""
|
|
24
|
+
|
|
25
|
+
def test_combines_tasks_from_multiple_apps(self):
|
|
26
|
+
"""Test that from_workflows combines tasks from multiple apps."""
|
|
27
|
+
app_a = Workflows()
|
|
28
|
+
app_b = Workflows()
|
|
29
|
+
|
|
30
|
+
@app_a.task
|
|
31
|
+
def task_a(x: int) -> int:
|
|
32
|
+
return x + 1
|
|
33
|
+
|
|
34
|
+
@app_b.task
|
|
35
|
+
def task_b(x: int) -> int:
|
|
36
|
+
return x + 2
|
|
37
|
+
|
|
38
|
+
combined = Workflows.from_workflows(app_a, app_b)
|
|
39
|
+
|
|
40
|
+
# Verify both tasks are in the combined app
|
|
41
|
+
task_names = combined._registry.get_task_names()
|
|
42
|
+
assert "task_a" in task_names
|
|
43
|
+
assert "task_b" in task_names
|
|
44
|
+
assert len(task_names) == 2
|
|
45
|
+
|
|
46
|
+
def test_raises_on_duplicate_task_names(self):
|
|
47
|
+
"""Test that from_workflows raises ValueError on duplicate task names."""
|
|
48
|
+
app_a = Workflows()
|
|
49
|
+
app_b = Workflows()
|
|
50
|
+
|
|
51
|
+
@app_a.task
|
|
52
|
+
def duplicate_task(x: int) -> int:
|
|
53
|
+
return x + 1
|
|
54
|
+
|
|
55
|
+
@app_b.task
|
|
56
|
+
def duplicate_task(x: int) -> int: # noqa: F811
|
|
57
|
+
return x + 2
|
|
58
|
+
|
|
59
|
+
with pytest.raises(
|
|
60
|
+
ValueError, match="Task 'duplicate_task' is defined in multiple apps"
|
|
61
|
+
):
|
|
62
|
+
Workflows.from_workflows(app_a, app_b)
|
|
63
|
+
|
|
64
|
+
def test_combined_app_uses_its_own_defaults(self):
|
|
65
|
+
"""Test that the combined app uses its own defaults, not source app defaults."""
|
|
66
|
+
source_retry = Retry(max_retries=5, wait_duration_ms=500, backoff_scaling=1.0)
|
|
67
|
+
app_a = Workflows(default_retry=source_retry, default_timeout=100)
|
|
68
|
+
|
|
69
|
+
@app_a.task
|
|
70
|
+
def task_a(x: int) -> int:
|
|
71
|
+
return x
|
|
72
|
+
|
|
73
|
+
# Combined app has different defaults
|
|
74
|
+
combined_retry = Retry(
|
|
75
|
+
max_retries=10, wait_duration_ms=1000, backoff_scaling=2.0
|
|
76
|
+
)
|
|
77
|
+
combined = Workflows.from_workflows(
|
|
78
|
+
app_a,
|
|
79
|
+
default_retry=combined_retry,
|
|
80
|
+
default_timeout=300,
|
|
81
|
+
default_plan="premium",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Verify the combined app stored its own defaults
|
|
85
|
+
assert combined._default_retry == combined_retry
|
|
86
|
+
assert combined._default_timeout == 300
|
|
87
|
+
assert combined._default_plan == "premium"
|
|
88
|
+
|
|
89
|
+
def test_combined_app_can_define_new_tasks(self):
|
|
90
|
+
"""Test that new tasks can be added to the combined app."""
|
|
91
|
+
app_a = Workflows()
|
|
92
|
+
|
|
93
|
+
@app_a.task
|
|
94
|
+
def task_a(x: int) -> int:
|
|
95
|
+
return x
|
|
96
|
+
|
|
97
|
+
combined = Workflows.from_workflows(app_a)
|
|
98
|
+
|
|
99
|
+
@combined.task
|
|
100
|
+
def task_b(x: int) -> int:
|
|
101
|
+
return x * 2
|
|
102
|
+
|
|
103
|
+
task_names = combined._registry.get_task_names()
|
|
104
|
+
assert "task_a" in task_names
|
|
105
|
+
assert "task_b" in task_names
|
|
106
|
+
assert len(task_names) == 2
|
|
107
|
+
|
|
108
|
+
def test_works_with_zero_apps(self):
|
|
109
|
+
"""Test that from_workflows works with no input apps."""
|
|
110
|
+
combined = Workflows.from_workflows()
|
|
111
|
+
|
|
112
|
+
assert combined._registry.get_task_names() == []
|
|
113
|
+
|
|
114
|
+
def test_works_with_single_app(self):
|
|
115
|
+
"""Test that from_workflows works with a single app."""
|
|
116
|
+
app = Workflows()
|
|
117
|
+
|
|
118
|
+
@app.task
|
|
119
|
+
def my_task(x: int) -> int:
|
|
120
|
+
return x
|
|
121
|
+
|
|
122
|
+
combined = Workflows.from_workflows(app)
|
|
123
|
+
|
|
124
|
+
assert "my_task" in combined._registry.get_task_names()
|
|
125
|
+
assert len(combined._registry.get_task_names()) == 1
|
|
126
|
+
|
|
127
|
+
def test_preserves_task_options(self):
|
|
128
|
+
"""Test that task options are preserved when combining apps."""
|
|
129
|
+
retry = Retry(max_retries=3, wait_duration_ms=1000, backoff_scaling=2.0)
|
|
130
|
+
app = Workflows()
|
|
131
|
+
|
|
132
|
+
@app.task(retry=retry, timeout=60, plan="starter")
|
|
133
|
+
def configured_task(x: int) -> int:
|
|
134
|
+
return x
|
|
135
|
+
|
|
136
|
+
combined = Workflows.from_workflows(app)
|
|
137
|
+
|
|
138
|
+
task_info = combined._registry.get_task("configured_task")
|
|
139
|
+
assert task_info is not None
|
|
140
|
+
assert task_info.options.retry.max_retries == 3
|
|
141
|
+
assert task_info.options.timeout_seconds == 60
|
|
142
|
+
assert task_info.options.plan == "starter"
|
|
143
|
+
|
|
144
|
+
def test_combines_three_or_more_apps(self):
|
|
145
|
+
"""Test that from_workflows works with many apps."""
|
|
146
|
+
app1 = Workflows()
|
|
147
|
+
app2 = Workflows()
|
|
148
|
+
app3 = Workflows()
|
|
149
|
+
|
|
150
|
+
@app1.task
|
|
151
|
+
def task_1(x: int) -> int:
|
|
152
|
+
return x + 1
|
|
153
|
+
|
|
154
|
+
@app2.task
|
|
155
|
+
def task_2(x: int) -> int:
|
|
156
|
+
return x + 2
|
|
157
|
+
|
|
158
|
+
@app3.task
|
|
159
|
+
def task_3(x: int) -> int:
|
|
160
|
+
return x + 3
|
|
161
|
+
|
|
162
|
+
combined = Workflows.from_workflows(app1, app2, app3)
|
|
163
|
+
|
|
164
|
+
task_names = combined._registry.get_task_names()
|
|
165
|
+
assert len(task_names) == 3
|
|
166
|
+
assert "task_1" in task_names
|
|
167
|
+
assert "task_2" in task_names
|
|
168
|
+
assert "task_3" in task_names
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class TestWorkflowsInit:
|
|
172
|
+
"""Tests for Workflows initialization."""
|
|
173
|
+
|
|
174
|
+
def test_stores_default_retry(self):
|
|
175
|
+
"""Test that default_retry is stored correctly."""
|
|
176
|
+
retry = Retry(max_retries=5, wait_duration_ms=1000, backoff_scaling=2.0)
|
|
177
|
+
app = Workflows(default_retry=retry)
|
|
178
|
+
|
|
179
|
+
assert app._default_retry == retry
|
|
180
|
+
|
|
181
|
+
def test_stores_default_timeout(self):
|
|
182
|
+
"""Test that default_timeout is stored correctly."""
|
|
183
|
+
app = Workflows(default_timeout=300)
|
|
184
|
+
|
|
185
|
+
assert app._default_timeout == 300
|
|
186
|
+
|
|
187
|
+
def test_stores_default_plan(self):
|
|
188
|
+
"""Test that default_plan is stored correctly."""
|
|
189
|
+
app = Workflows(default_plan="premium")
|
|
190
|
+
|
|
191
|
+
assert app._default_plan == "premium"
|
|
192
|
+
|
|
193
|
+
def test_auto_start_false_by_default(self):
|
|
194
|
+
"""Test that auto_start is False by default."""
|
|
195
|
+
app = Workflows()
|
|
196
|
+
|
|
197
|
+
assert app._auto_start is False
|
|
198
|
+
|
|
199
|
+
def test_started_false_initially(self):
|
|
200
|
+
"""Test that _started is False initially."""
|
|
201
|
+
app = Workflows()
|
|
202
|
+
|
|
203
|
+
assert app._started is False
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class TestWorkflowsTaskDecorator:
|
|
207
|
+
"""Tests for the @app.task decorator."""
|
|
208
|
+
|
|
209
|
+
def test_task_uses_app_defaults(self):
|
|
210
|
+
"""Test that tasks use app-level defaults when not overridden."""
|
|
211
|
+
retry = Retry(max_retries=3, wait_duration_ms=1000, backoff_scaling=2.0)
|
|
212
|
+
app = Workflows(
|
|
213
|
+
default_retry=retry,
|
|
214
|
+
default_timeout=120,
|
|
215
|
+
default_plan="standard",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
@app.task
|
|
219
|
+
def my_task(x: int) -> int:
|
|
220
|
+
return x
|
|
221
|
+
|
|
222
|
+
task_info = app._registry.get_task("my_task")
|
|
223
|
+
assert task_info.options.retry == retry
|
|
224
|
+
assert task_info.options.timeout_seconds == 120
|
|
225
|
+
assert task_info.options.plan == "standard"
|
|
226
|
+
|
|
227
|
+
def test_task_overrides_app_defaults(self):
|
|
228
|
+
"""Test that task-level options override app defaults."""
|
|
229
|
+
app_retry = Retry(max_retries=3, wait_duration_ms=1000, backoff_scaling=2.0)
|
|
230
|
+
task_retry = Retry(max_retries=10, wait_duration_ms=5000, backoff_scaling=3.0)
|
|
231
|
+
|
|
232
|
+
app = Workflows(
|
|
233
|
+
default_retry=app_retry,
|
|
234
|
+
default_timeout=120,
|
|
235
|
+
default_plan="standard",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
@app.task(retry=task_retry, timeout=60, plan="starter")
|
|
239
|
+
def my_task(x: int) -> int:
|
|
240
|
+
return x
|
|
241
|
+
|
|
242
|
+
task_info = app._registry.get_task("my_task")
|
|
243
|
+
assert task_info.options.retry == task_retry
|
|
244
|
+
assert task_info.options.timeout_seconds == 60
|
|
245
|
+
assert task_info.options.plan == "starter"
|
|
246
|
+
|
|
247
|
+
def test_task_partial_override(self):
|
|
248
|
+
"""Test that tasks can partially override app defaults."""
|
|
249
|
+
app_retry = Retry(max_retries=3, wait_duration_ms=1000, backoff_scaling=2.0)
|
|
250
|
+
|
|
251
|
+
app = Workflows(
|
|
252
|
+
default_retry=app_retry,
|
|
253
|
+
default_timeout=120,
|
|
254
|
+
default_plan="standard",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Only override timeout, keep retry and plan from defaults
|
|
258
|
+
@app.task(timeout=60)
|
|
259
|
+
def my_task(x: int) -> int:
|
|
260
|
+
return x
|
|
261
|
+
|
|
262
|
+
task_info = app._registry.get_task("my_task")
|
|
263
|
+
assert task_info.options.retry == app_retry # From app default
|
|
264
|
+
assert task_info.options.timeout_seconds == 60 # Overridden
|
|
265
|
+
assert task_info.options.plan == "standard" # From app default
|
|
266
|
+
|
|
267
|
+
def test_task_with_custom_name(self):
|
|
268
|
+
"""Test that tasks can have custom names."""
|
|
269
|
+
app = Workflows()
|
|
270
|
+
|
|
271
|
+
@app.task(name="custom_task_name")
|
|
272
|
+
def my_function(x: int) -> int:
|
|
273
|
+
return x
|
|
274
|
+
|
|
275
|
+
task_names = app._registry.get_task_names()
|
|
276
|
+
assert "custom_task_name" in task_names
|
|
277
|
+
assert "my_function" not in task_names
|
|
278
|
+
|
|
279
|
+
def test_task_decorator_without_parentheses(self):
|
|
280
|
+
"""Test that @app.task works without parentheses."""
|
|
281
|
+
app = Workflows()
|
|
282
|
+
|
|
283
|
+
@app.task
|
|
284
|
+
def my_task(x: int) -> int:
|
|
285
|
+
return x
|
|
286
|
+
|
|
287
|
+
assert "my_task" in app._registry.get_task_names()
|
|
288
|
+
|
|
289
|
+
def test_task_decorator_with_empty_parentheses(self):
|
|
290
|
+
"""Test that @app.task() works with empty parentheses."""
|
|
291
|
+
app = Workflows()
|
|
292
|
+
|
|
293
|
+
@app.task()
|
|
294
|
+
def my_task(x: int) -> int:
|
|
295
|
+
return x
|
|
296
|
+
|
|
297
|
+
assert "my_task" in app._registry.get_task_names()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class TestWorkflowsStart:
|
|
301
|
+
"""Tests for Workflows.start() method."""
|
|
302
|
+
|
|
303
|
+
def test_raises_when_mode_not_set(self, monkeypatch):
|
|
304
|
+
"""Test that start() raises ValueError when RENDER_SDK_MODE is not set."""
|
|
305
|
+
monkeypatch.delenv("RENDER_SDK_MODE", raising=False)
|
|
306
|
+
monkeypatch.delenv("RENDER_SDK_SOCKET_PATH", raising=False)
|
|
307
|
+
|
|
308
|
+
app = Workflows()
|
|
309
|
+
|
|
310
|
+
with pytest.raises(
|
|
311
|
+
ValueError, match="RENDER_SDK_MODE environment variable is required"
|
|
312
|
+
):
|
|
313
|
+
app.start()
|
|
314
|
+
|
|
315
|
+
def test_raises_when_socket_path_not_set(self, monkeypatch):
|
|
316
|
+
"""
|
|
317
|
+
Test that start() raises ValueError when RENDER_SDK_SOCKET_PATH is not set.
|
|
318
|
+
"""
|
|
319
|
+
monkeypatch.setenv("RENDER_SDK_MODE", "run")
|
|
320
|
+
monkeypatch.delenv("RENDER_SDK_SOCKET_PATH", raising=False)
|
|
321
|
+
|
|
322
|
+
app = Workflows()
|
|
323
|
+
|
|
324
|
+
with pytest.raises(
|
|
325
|
+
ValueError, match="RENDER_SDK_SOCKET_PATH environment variable is required"
|
|
326
|
+
):
|
|
327
|
+
app.start()
|
|
328
|
+
|
|
329
|
+
def test_raises_for_unknown_mode(self, monkeypatch):
|
|
330
|
+
"""Test that start() raises ValueError for unknown mode."""
|
|
331
|
+
monkeypatch.setenv("RENDER_SDK_MODE", "invalid_mode")
|
|
332
|
+
monkeypatch.setenv("RENDER_SDK_SOCKET_PATH", "/tmp/test.sock") # noqa: S108
|
|
333
|
+
|
|
334
|
+
app = Workflows()
|
|
335
|
+
|
|
336
|
+
with pytest.raises(ValueError, match="Unknown mode: invalid_mode"):
|
|
337
|
+
app.start()
|
|
338
|
+
|
|
339
|
+
def test_started_not_set_on_validation_failure(self, monkeypatch):
|
|
340
|
+
"""Test that _started remains False when validation fails."""
|
|
341
|
+
monkeypatch.delenv("RENDER_SDK_MODE", raising=False)
|
|
342
|
+
|
|
343
|
+
app = Workflows()
|
|
344
|
+
assert app._started is False
|
|
345
|
+
|
|
346
|
+
with pytest.raises(ValueError):
|
|
347
|
+
app.start()
|
|
348
|
+
|
|
349
|
+
# _started should still be False since validation failed
|
|
350
|
+
assert app._started is False
|
|
351
|
+
|
|
352
|
+
def test_copies_tasks_to_global_registry(self, monkeypatch):
|
|
353
|
+
"""Test that start() copies tasks to the global registry."""
|
|
354
|
+
monkeypatch.setenv("RENDER_SDK_MODE", "register")
|
|
355
|
+
monkeypatch.setenv("RENDER_SDK_SOCKET_PATH", "/tmp/test.sock") # noqa: S108
|
|
356
|
+
|
|
357
|
+
from render_sdk.workflows.task import _global_registry
|
|
358
|
+
|
|
359
|
+
# Clear global registry
|
|
360
|
+
_global_registry._tasks.clear()
|
|
361
|
+
|
|
362
|
+
app = Workflows()
|
|
363
|
+
|
|
364
|
+
@app.task
|
|
365
|
+
def my_task(x: int) -> int:
|
|
366
|
+
return x
|
|
367
|
+
|
|
368
|
+
# Mock the register function to avoid actual socket operations
|
|
369
|
+
import render_sdk.workflows.app as app_module
|
|
370
|
+
|
|
371
|
+
original_register = app_module.register
|
|
372
|
+
app_module.register = lambda _: None
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
app.start()
|
|
376
|
+
|
|
377
|
+
# Task should be in global registry
|
|
378
|
+
assert "my_task" in _global_registry._tasks
|
|
379
|
+
finally:
|
|
380
|
+
app_module.register = original_register
|
|
381
|
+
_global_registry._tasks.clear()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class TestAutoStartWarning:
|
|
385
|
+
"""Tests for auto_start warning behavior."""
|
|
386
|
+
|
|
387
|
+
def test_warns_on_multiple_auto_start_apps(self):
|
|
388
|
+
"""Test that creating multiple apps with auto_start=True warns."""
|
|
389
|
+
# First app should not warn
|
|
390
|
+
_app1 = Workflows(auto_start=True)
|
|
391
|
+
|
|
392
|
+
# Second app should warn
|
|
393
|
+
with warnings.catch_warnings(record=True) as w:
|
|
394
|
+
warnings.simplefilter("always")
|
|
395
|
+
_app2 = Workflows(auto_start=True)
|
|
396
|
+
|
|
397
|
+
assert len(w) == 1
|
|
398
|
+
warning_msg = str(w[0].message)
|
|
399
|
+
assert "Multiple Workflows instances with auto_start=True" in warning_msg
|
|
400
|
+
assert issubclass(w[0].category, UserWarning)
|
|
401
|
+
|
|
402
|
+
def test_no_warning_for_auto_start_false(self):
|
|
403
|
+
"""Test that auto_start=False apps don't trigger warnings."""
|
|
404
|
+
_app1 = Workflows(auto_start=True)
|
|
405
|
+
|
|
406
|
+
# auto_start=False should not warn
|
|
407
|
+
with warnings.catch_warnings(record=True) as w:
|
|
408
|
+
warnings.simplefilter("always")
|
|
409
|
+
_app2 = Workflows(auto_start=False)
|
|
410
|
+
_app3 = Workflows() # Default is False
|
|
411
|
+
|
|
412
|
+
assert len(w) == 0
|