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.
Files changed (156) hide show
  1. render_sdk/__init__.py +41 -4
  2. render_sdk/client/__init__.py +25 -0
  3. render_sdk/client/client.py +5 -0
  4. render_sdk/client/sse.py +5 -1
  5. render_sdk/client/tests/test_client.py +6 -4
  6. render_sdk/client/tests/test_sse.py +1 -0
  7. render_sdk/client/types.py +2 -1
  8. render_sdk/client/workflows.py +13 -3
  9. render_sdk/experimental/__init__.py +31 -0
  10. render_sdk/experimental/experimental.py +71 -0
  11. render_sdk/experimental/object/__init__.py +30 -0
  12. render_sdk/experimental/object/api.py +260 -0
  13. render_sdk/experimental/object/client.py +475 -0
  14. render_sdk/experimental/object/types.py +87 -0
  15. render_sdk/public_api/api/audit_logs/list_organization_audit_logs.py +303 -0
  16. render_sdk/public_api/api/audit_logs/list_owner_audit_logs.py +303 -0
  17. render_sdk/public_api/api/blob_storage/delete_blob.py +215 -0
  18. render_sdk/public_api/api/blob_storage/get_blob.py +221 -0
  19. render_sdk/public_api/api/{workflows/list_workflow_versions.py → blob_storage/list_blobs.py} +52 -30
  20. render_sdk/public_api/api/blob_storage/put_blob.py +248 -0
  21. render_sdk/public_api/api/blueprints/validate_blueprint.py +212 -0
  22. render_sdk/public_api/api/key_value/resume_key_value.py +203 -0
  23. render_sdk/public_api/api/key_value/suspend_key_value.py +203 -0
  24. render_sdk/public_api/api/metrics/get_bandwidth_sources.py +251 -0
  25. render_sdk/public_api/api/postgres/create_postgres_user.py +229 -0
  26. render_sdk/public_api/api/postgres/delete_postgres_user.py +201 -0
  27. render_sdk/public_api/api/postgres/list_postgres_users.py +195 -0
  28. render_sdk/public_api/api/redis_deprecated/__init__.py +1 -0
  29. render_sdk/public_api/api/{redis → redis_deprecated}/create_redis.py +4 -4
  30. render_sdk/public_api/api/{redis → redis_deprecated}/delete_redis.py +4 -4
  31. render_sdk/public_api/api/{redis → redis_deprecated}/list_redis.py +4 -0
  32. render_sdk/public_api/api/{redis → redis_deprecated}/retrieve_redis.py +4 -4
  33. render_sdk/public_api/api/{redis → redis_deprecated}/retrieve_redis_connection_info.py +4 -0
  34. render_sdk/public_api/api/{redis → redis_deprecated}/update_redis.py +4 -4
  35. render_sdk/public_api/api/services/create_service.py +4 -4
  36. render_sdk/public_api/api/workflow_tasks_ea/__init__.py +1 -0
  37. render_sdk/public_api/api/{workflows → workflow_tasks_ea}/cancel_task_run.py +12 -4
  38. render_sdk/public_api/api/{workflows → workflow_tasks_ea}/create_task.py +12 -4
  39. render_sdk/public_api/api/{workflows → workflow_tasks_ea}/get_task.py +12 -4
  40. render_sdk/public_api/api/{workflows → workflow_tasks_ea}/get_task_run.py +12 -4
  41. render_sdk/public_api/api/{workflows → workflow_tasks_ea}/list_task_runs.py +12 -0
  42. render_sdk/public_api/api/{workflows → workflow_tasks_ea}/list_tasks.py +24 -12
  43. render_sdk/public_api/api/workflows_ea/__init__.py +1 -0
  44. render_sdk/public_api/api/workflows_ea/create_workflow.py +199 -0
  45. render_sdk/public_api/api/{workflows/deploy_workflow.py → workflows_ea/create_workflow_version.py} +31 -14
  46. render_sdk/public_api/api/{workflows → workflows_ea}/delete_workflow.py +12 -4
  47. render_sdk/public_api/api/{workflows → workflows_ea}/get_workflow.py +32 -14
  48. render_sdk/public_api/api/{workflows → workflows_ea}/get_workflow_version.py +12 -4
  49. render_sdk/public_api/api/workflows_ea/list_workflow_versions.py +275 -0
  50. render_sdk/public_api/api/{workflows → workflows_ea}/list_workflows.py +41 -14
  51. render_sdk/public_api/api/workflows_ea/update_workflow.py +212 -0
  52. render_sdk/public_api/api/workspaces/remove_workspace_member.py +206 -0
  53. render_sdk/public_api/api/workspaces/update_workspace_member.py +235 -0
  54. render_sdk/public_api/models/__init__.py +82 -4
  55. render_sdk/public_api/models/audit_log.py +113 -0
  56. render_sdk/public_api/models/audit_log_actor.py +80 -0
  57. render_sdk/public_api/models/audit_log_actor_type.py +10 -0
  58. render_sdk/public_api/models/audit_log_event.py +80 -0
  59. render_sdk/public_api/models/audit_log_metadata.py +49 -0
  60. render_sdk/public_api/models/audit_log_status.py +9 -0
  61. render_sdk/public_api/models/audit_log_with_cursor.py +73 -0
  62. render_sdk/public_api/models/background_worker_details.py +2 -2
  63. render_sdk/public_api/models/background_worker_details_patch.py +1 -1
  64. render_sdk/public_api/models/background_worker_details_post.py +1 -1
  65. render_sdk/public_api/models/blob_metadata.py +85 -0
  66. render_sdk/public_api/models/blob_with_cursor.py +73 -0
  67. render_sdk/public_api/models/cache.py +6 -4
  68. render_sdk/public_api/models/cache_profile.py +10 -0
  69. render_sdk/public_api/models/create_deploy_body.py +23 -0
  70. render_sdk/public_api/models/create_version.py +70 -0
  71. render_sdk/public_api/models/credential_create_input.py +59 -0
  72. render_sdk/public_api/models/cron_job_details.py +2 -2
  73. render_sdk/public_api/models/cron_job_details_patch.py +1 -1
  74. render_sdk/public_api/models/cron_job_details_post.py +1 -1
  75. render_sdk/public_api/models/deploy_mode.py +9 -0
  76. render_sdk/public_api/models/event.py +11 -27
  77. render_sdk/public_api/models/event_type.py +1 -1
  78. render_sdk/public_api/models/get_bandwidth_sources_response_200.py +75 -0
  79. render_sdk/public_api/models/get_bandwidth_sources_response_200_data_item.py +101 -0
  80. render_sdk/public_api/models/get_bandwidth_sources_response_200_data_item_labels.py +78 -0
  81. render_sdk/public_api/models/get_bandwidth_sources_response_200_data_item_labels_traffic_source.py +12 -0
  82. render_sdk/public_api/models/get_bandwidth_sources_response_200_data_item_values_item.py +68 -0
  83. render_sdk/public_api/models/{server_unhealthy.py → get_bandwidth_sources_response_400.py} +12 -12
  84. render_sdk/public_api/models/get_blob_output.py +71 -0
  85. render_sdk/public_api/models/list_postgres_users_response_200_item.py +86 -0
  86. render_sdk/public_api/models/otel_provider_type.py +2 -0
  87. render_sdk/public_api/models/postgres.py +8 -0
  88. render_sdk/public_api/models/postgres_detail.py +26 -0
  89. render_sdk/public_api/models/postgres_parameter_overrides.py +44 -0
  90. render_sdk/public_api/models/postgres_patch_input.py +27 -0
  91. render_sdk/public_api/models/postgres_post_input.py +27 -0
  92. render_sdk/public_api/models/postgres_version.py +1 -0
  93. render_sdk/public_api/models/preview_input.py +2 -2
  94. render_sdk/public_api/models/private_service_details.py +2 -2
  95. render_sdk/public_api/models/private_service_details_patch.py +1 -1
  96. render_sdk/public_api/models/private_service_details_post.py +1 -1
  97. render_sdk/public_api/models/project_post_environment_input.py +26 -1
  98. render_sdk/public_api/models/put_blob_input.py +59 -0
  99. render_sdk/public_api/models/put_blob_output.py +79 -0
  100. render_sdk/public_api/models/read_replica.py +25 -1
  101. render_sdk/public_api/models/read_replica_input.py +25 -1
  102. render_sdk/public_api/models/run_task.py +35 -7
  103. render_sdk/public_api/models/service_event.py +12 -27
  104. render_sdk/public_api/models/service_event_type.py +0 -1
  105. render_sdk/public_api/models/service_post.py +9 -6
  106. render_sdk/public_api/models/task_attempt.py +88 -0
  107. render_sdk/public_api/models/task_attempt_details.py +108 -0
  108. render_sdk/public_api/models/task_data_type_1.py +44 -0
  109. render_sdk/public_api/models/task_run.py +23 -1
  110. render_sdk/public_api/models/task_run_details.py +50 -5
  111. render_sdk/public_api/models/task_run_status.py +1 -0
  112. render_sdk/public_api/models/task_with_cursor.py +73 -0
  113. render_sdk/public_api/models/team_member.py +5 -4
  114. render_sdk/public_api/models/team_member_role.py +12 -0
  115. render_sdk/public_api/models/update_workspace_member_body.py +61 -0
  116. render_sdk/public_api/models/validate_blueprint_request.py +84 -0
  117. render_sdk/public_api/models/validate_blueprint_response.py +105 -0
  118. render_sdk/public_api/models/validation_error.py +88 -0
  119. render_sdk/public_api/models/validation_plan_summary.py +107 -0
  120. render_sdk/public_api/models/web_service_details.py +2 -2
  121. render_sdk/public_api/models/web_service_details_patch.py +6 -5
  122. render_sdk/public_api/models/web_service_details_post.py +6 -5
  123. render_sdk/public_api/models/workflow.py +144 -0
  124. render_sdk/public_api/models/workflow_create.py +99 -0
  125. render_sdk/public_api/models/workflow_update.py +90 -0
  126. render_sdk/public_api/models/workflow_version.py +10 -14
  127. render_sdk/public_api/models/workflow_version_status.py +13 -0
  128. render_sdk/public_api/models/workflow_version_with_cursor.py +73 -0
  129. render_sdk/public_api/models/workflow_with_cursor.py +73 -0
  130. render_sdk/render.py +65 -0
  131. render_sdk/version.py +27 -0
  132. render_sdk/workflows/__init__.py +5 -1
  133. render_sdk/workflows/app.py +262 -0
  134. render_sdk/workflows/callback_api/models/__init__.py +2 -0
  135. render_sdk/workflows/callback_api/models/task.py +21 -0
  136. render_sdk/workflows/callback_api/models/task_options.py +18 -0
  137. render_sdk/workflows/callback_api/models/task_parameter.py +88 -0
  138. render_sdk/workflows/callback_api/py.typed +1 -1
  139. render_sdk/workflows/cli.py +58 -0
  140. render_sdk/workflows/client.py +8 -9
  141. render_sdk/workflows/executor.py +19 -7
  142. render_sdk/workflows/runner.py +43 -10
  143. render_sdk/workflows/task.py +84 -5
  144. render_sdk/workflows/tests/test_app.py +412 -0
  145. render_sdk/workflows/tests/test_cli.py +134 -0
  146. render_sdk/workflows/tests/test_end_to_end.py +71 -1
  147. render_sdk/workflows/tests/test_registration.py +58 -1
  148. {render_sdk-0.1.2.dist-info → render_sdk-0.2.0.dist-info}/METADATA +4 -3
  149. {render_sdk-0.1.2.dist-info → render_sdk-0.2.0.dist-info}/RECORD +155 -83
  150. {render_sdk-0.1.2.dist-info → render_sdk-0.2.0.dist-info}/WHEEL +1 -1
  151. render_sdk-0.2.0.dist-info/entry_points.txt +3 -0
  152. render_sdk/public_api/models/image_version.py +0 -79
  153. /render_sdk/public_api/api/{redis → audit_logs}/__init__.py +0 -0
  154. /render_sdk/public_api/api/{workflows → blob_storage}/__init__.py +0 -0
  155. /render_sdk/public_api/api/{workflows → workflow_tasks_ea}/stream_task_runs_events.py +0 -0
  156. {render_sdk-0.1.2.dist-info → render_sdk-0.2.0.dist-info/licenses}/LICENSE +0 -0
@@ -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 and task_info.options.retry:
90
- retry = task_info.options.retry
91
- options.retry = RetryConfig(
92
- max_retries=retry.max_retries,
93
- wait_duration_ms=retry.wait_duration_ms,
94
- factor=retry.factor,
95
- )
96
-
97
- task_def = Task(name=name, options=options)
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
+ )
@@ -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
- factor: float = 1.5
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__(self, func: Callable, name: str, options: Options | None = None):
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
- task_info = TaskInfo(func, task_name, options)
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, list(args)))
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