render_sdk 0.1.3__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 (150) 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/workflows.py +10 -2
  8. render_sdk/experimental/__init__.py +31 -0
  9. render_sdk/experimental/experimental.py +71 -0
  10. render_sdk/experimental/object/__init__.py +30 -0
  11. render_sdk/experimental/object/api.py +260 -0
  12. render_sdk/experimental/object/client.py +475 -0
  13. render_sdk/experimental/object/types.py +87 -0
  14. render_sdk/public_api/api/audit_logs/list_organization_audit_logs.py +303 -0
  15. render_sdk/public_api/api/audit_logs/list_owner_audit_logs.py +303 -0
  16. render_sdk/public_api/api/blob_storage/delete_blob.py +215 -0
  17. render_sdk/public_api/api/blob_storage/get_blob.py +221 -0
  18. render_sdk/public_api/api/{workflows/list_workflow_versions.py → blob_storage/list_blobs.py} +52 -30
  19. render_sdk/public_api/api/blob_storage/put_blob.py +248 -0
  20. render_sdk/public_api/api/blueprints/validate_blueprint.py +212 -0
  21. render_sdk/public_api/api/key_value/resume_key_value.py +203 -0
  22. render_sdk/public_api/api/key_value/suspend_key_value.py +203 -0
  23. render_sdk/public_api/api/metrics/get_bandwidth_sources.py +251 -0
  24. render_sdk/public_api/api/postgres/create_postgres_user.py +229 -0
  25. render_sdk/public_api/api/postgres/delete_postgres_user.py +201 -0
  26. render_sdk/public_api/api/postgres/list_postgres_users.py +195 -0
  27. render_sdk/public_api/api/redis_deprecated/__init__.py +1 -0
  28. render_sdk/public_api/api/{redis → redis_deprecated}/create_redis.py +4 -4
  29. render_sdk/public_api/api/{redis → redis_deprecated}/delete_redis.py +4 -4
  30. render_sdk/public_api/api/{redis → redis_deprecated}/list_redis.py +4 -0
  31. render_sdk/public_api/api/{redis → redis_deprecated}/retrieve_redis.py +4 -4
  32. render_sdk/public_api/api/{redis → redis_deprecated}/retrieve_redis_connection_info.py +4 -0
  33. render_sdk/public_api/api/{redis → redis_deprecated}/update_redis.py +4 -4
  34. render_sdk/public_api/api/services/create_service.py +4 -4
  35. render_sdk/public_api/api/workflow_tasks_ea/__init__.py +1 -0
  36. render_sdk/public_api/api/{workflows → workflow_tasks_ea}/cancel_task_run.py +12 -4
  37. render_sdk/public_api/api/{workflows → workflow_tasks_ea}/create_task.py +12 -4
  38. render_sdk/public_api/api/{workflows → workflow_tasks_ea}/get_task.py +12 -4
  39. render_sdk/public_api/api/{workflows → workflow_tasks_ea}/get_task_run.py +12 -4
  40. render_sdk/public_api/api/{workflows → workflow_tasks_ea}/list_task_runs.py +12 -0
  41. render_sdk/public_api/api/{workflows → workflow_tasks_ea}/list_tasks.py +24 -12
  42. render_sdk/public_api/api/workflows_ea/__init__.py +1 -0
  43. render_sdk/public_api/api/workflows_ea/create_workflow.py +199 -0
  44. render_sdk/public_api/api/{workflows/deploy_workflow.py → workflows_ea/create_workflow_version.py} +31 -14
  45. render_sdk/public_api/api/{workflows → workflows_ea}/delete_workflow.py +12 -4
  46. render_sdk/public_api/api/{workflows → workflows_ea}/get_workflow.py +32 -14
  47. render_sdk/public_api/api/{workflows → workflows_ea}/get_workflow_version.py +12 -4
  48. render_sdk/public_api/api/workflows_ea/list_workflow_versions.py +275 -0
  49. render_sdk/public_api/api/{workflows → workflows_ea}/list_workflows.py +41 -14
  50. render_sdk/public_api/api/workflows_ea/update_workflow.py +212 -0
  51. render_sdk/public_api/api/workspaces/remove_workspace_member.py +206 -0
  52. render_sdk/public_api/api/workspaces/update_workspace_member.py +235 -0
  53. render_sdk/public_api/models/__init__.py +82 -4
  54. render_sdk/public_api/models/audit_log.py +113 -0
  55. render_sdk/public_api/models/audit_log_actor.py +80 -0
  56. render_sdk/public_api/models/audit_log_actor_type.py +10 -0
  57. render_sdk/public_api/models/audit_log_event.py +80 -0
  58. render_sdk/public_api/models/audit_log_metadata.py +49 -0
  59. render_sdk/public_api/models/audit_log_status.py +9 -0
  60. render_sdk/public_api/models/audit_log_with_cursor.py +73 -0
  61. render_sdk/public_api/models/background_worker_details.py +2 -2
  62. render_sdk/public_api/models/background_worker_details_patch.py +1 -1
  63. render_sdk/public_api/models/background_worker_details_post.py +1 -1
  64. render_sdk/public_api/models/blob_metadata.py +85 -0
  65. render_sdk/public_api/models/blob_with_cursor.py +73 -0
  66. render_sdk/public_api/models/cache.py +6 -4
  67. render_sdk/public_api/models/cache_profile.py +10 -0
  68. render_sdk/public_api/models/create_deploy_body.py +23 -0
  69. render_sdk/public_api/models/create_version.py +70 -0
  70. render_sdk/public_api/models/credential_create_input.py +59 -0
  71. render_sdk/public_api/models/cron_job_details.py +2 -2
  72. render_sdk/public_api/models/cron_job_details_patch.py +1 -1
  73. render_sdk/public_api/models/cron_job_details_post.py +1 -1
  74. render_sdk/public_api/models/deploy_mode.py +9 -0
  75. render_sdk/public_api/models/event.py +11 -27
  76. render_sdk/public_api/models/event_type.py +1 -1
  77. render_sdk/public_api/models/get_bandwidth_sources_response_200.py +75 -0
  78. render_sdk/public_api/models/get_bandwidth_sources_response_200_data_item.py +101 -0
  79. render_sdk/public_api/models/get_bandwidth_sources_response_200_data_item_labels.py +78 -0
  80. render_sdk/public_api/models/get_bandwidth_sources_response_200_data_item_labels_traffic_source.py +12 -0
  81. render_sdk/public_api/models/get_bandwidth_sources_response_200_data_item_values_item.py +68 -0
  82. render_sdk/public_api/models/{server_unhealthy.py → get_bandwidth_sources_response_400.py} +12 -12
  83. render_sdk/public_api/models/get_blob_output.py +71 -0
  84. render_sdk/public_api/models/list_postgres_users_response_200_item.py +86 -0
  85. render_sdk/public_api/models/otel_provider_type.py +2 -0
  86. render_sdk/public_api/models/postgres.py +8 -0
  87. render_sdk/public_api/models/postgres_detail.py +26 -0
  88. render_sdk/public_api/models/postgres_parameter_overrides.py +44 -0
  89. render_sdk/public_api/models/postgres_patch_input.py +27 -0
  90. render_sdk/public_api/models/postgres_post_input.py +27 -0
  91. render_sdk/public_api/models/postgres_version.py +1 -0
  92. render_sdk/public_api/models/preview_input.py +2 -2
  93. render_sdk/public_api/models/private_service_details.py +2 -2
  94. render_sdk/public_api/models/private_service_details_patch.py +1 -1
  95. render_sdk/public_api/models/private_service_details_post.py +1 -1
  96. render_sdk/public_api/models/project_post_environment_input.py +26 -1
  97. render_sdk/public_api/models/put_blob_input.py +59 -0
  98. render_sdk/public_api/models/put_blob_output.py +79 -0
  99. render_sdk/public_api/models/read_replica.py +25 -1
  100. render_sdk/public_api/models/read_replica_input.py +25 -1
  101. render_sdk/public_api/models/run_task.py +35 -7
  102. render_sdk/public_api/models/service_event.py +12 -27
  103. render_sdk/public_api/models/service_event_type.py +0 -1
  104. render_sdk/public_api/models/service_post.py +9 -6
  105. render_sdk/public_api/models/task_attempt.py +88 -0
  106. render_sdk/public_api/models/task_attempt_details.py +108 -0
  107. render_sdk/public_api/models/task_data_type_1.py +44 -0
  108. render_sdk/public_api/models/task_run.py +23 -1
  109. render_sdk/public_api/models/task_run_details.py +50 -5
  110. render_sdk/public_api/models/task_run_status.py +1 -0
  111. render_sdk/public_api/models/task_with_cursor.py +73 -0
  112. render_sdk/public_api/models/team_member.py +5 -4
  113. render_sdk/public_api/models/team_member_role.py +12 -0
  114. render_sdk/public_api/models/update_workspace_member_body.py +61 -0
  115. render_sdk/public_api/models/validate_blueprint_request.py +84 -0
  116. render_sdk/public_api/models/validate_blueprint_response.py +105 -0
  117. render_sdk/public_api/models/validation_error.py +88 -0
  118. render_sdk/public_api/models/validation_plan_summary.py +107 -0
  119. render_sdk/public_api/models/web_service_details.py +2 -2
  120. render_sdk/public_api/models/web_service_details_patch.py +6 -5
  121. render_sdk/public_api/models/web_service_details_post.py +6 -5
  122. render_sdk/public_api/models/workflow.py +144 -0
  123. render_sdk/public_api/models/workflow_create.py +99 -0
  124. render_sdk/public_api/models/workflow_update.py +90 -0
  125. render_sdk/public_api/models/workflow_version.py +10 -14
  126. render_sdk/public_api/models/workflow_version_status.py +13 -0
  127. render_sdk/public_api/models/workflow_version_with_cursor.py +73 -0
  128. render_sdk/public_api/models/workflow_with_cursor.py +73 -0
  129. render_sdk/render.py +65 -0
  130. render_sdk/version.py +27 -0
  131. render_sdk/workflows/__init__.py +5 -1
  132. render_sdk/workflows/app.py +262 -0
  133. render_sdk/workflows/callback_api/models/task_options.py +18 -0
  134. render_sdk/workflows/cli.py +58 -0
  135. render_sdk/workflows/client.py +2 -7
  136. render_sdk/workflows/runner.py +12 -7
  137. render_sdk/workflows/task.py +11 -2
  138. render_sdk/workflows/tests/test_app.py +412 -0
  139. render_sdk/workflows/tests/test_cli.py +134 -0
  140. render_sdk/workflows/tests/test_end_to_end.py +69 -1
  141. render_sdk/workflows/tests/test_registration.py +56 -1
  142. {render_sdk-0.1.3.dist-info → render_sdk-0.2.0.dist-info}/METADATA +1 -1
  143. {render_sdk-0.1.3.dist-info → render_sdk-0.2.0.dist-info}/RECORD +149 -78
  144. render_sdk-0.2.0.dist-info/entry_points.txt +3 -0
  145. render_sdk/public_api/models/image_version.py +0 -79
  146. /render_sdk/public_api/api/{redis → audit_logs}/__init__.py +0 -0
  147. /render_sdk/public_api/api/{workflows → blob_storage}/__init__.py +0 -0
  148. /render_sdk/public_api/api/{workflows → workflow_tasks_ea}/stream_task_runs_events.py +0 -0
  149. {render_sdk-0.1.3.dist-info → render_sdk-0.2.0.dist-info}/WHEEL +0 -0
  150. {render_sdk-0.1.3.dist-info → render_sdk-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -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
@@ -0,0 +1,134 @@
1
+ """Tests for the CLI entrypoint module."""
2
+
3
+ import pytest
4
+
5
+ from render_sdk.workflows import cli
6
+
7
+
8
+ class TestCLIMain:
9
+ """Tests for the main() CLI entrypoint."""
10
+
11
+ def test_no_arguments_shows_usage_and_exits(self, mocker, capsys):
12
+ """Test that running with no arguments shows usage and exits with code 1."""
13
+ mocker.patch("sys.argv", ["render-workflows"])
14
+
15
+ with pytest.raises(SystemExit) as exc_info:
16
+ cli.main()
17
+
18
+ assert exc_info.value.code == 1
19
+ captured = capsys.readouterr()
20
+ assert "Usage: render-workflows <module:app>" in captured.err
21
+ assert "Example: render-workflows myapp:app" in captured.err
22
+
23
+ def test_invalid_format_no_colon_shows_error(self, mocker, capsys):
24
+ """Test that an argument without a colon shows an error."""
25
+ mocker.patch("sys.argv", ["render-workflows", "myapp"])
26
+
27
+ with pytest.raises(SystemExit) as exc_info:
28
+ cli.main()
29
+
30
+ assert exc_info.value.code == 1
31
+ captured = capsys.readouterr()
32
+ assert "Error: Invalid app path 'myapp'" in captured.err
33
+ assert "Expected format: <module>:<app_variable>" in captured.err
34
+
35
+ def test_module_not_found_shows_error(self, mocker, capsys):
36
+ """Test that a non-existent module shows an import error."""
37
+ mocker.patch("sys.argv", ["render-workflows", "nonexistent_module:app"])
38
+
39
+ with pytest.raises(SystemExit) as exc_info:
40
+ cli.main()
41
+
42
+ assert exc_info.value.code == 1
43
+ captured = capsys.readouterr()
44
+ assert "Error: Could not import module 'nonexistent_module'" in captured.err
45
+
46
+ def test_attribute_not_found_shows_error(self, mocker, capsys):
47
+ """Test that a missing attribute shows an error."""
48
+ mocker.patch("sys.argv", ["render-workflows", "os:nonexistent_attr"])
49
+
50
+ with pytest.raises(SystemExit) as exc_info:
51
+ cli.main()
52
+
53
+ assert exc_info.value.code == 1
54
+ captured = capsys.readouterr()
55
+ assert "Error: Module 'os' has no attribute 'nonexistent_attr'" in captured.err
56
+
57
+ def test_object_without_start_method_shows_error(self, mocker, capsys):
58
+ """Test that an object without a start() method shows an error."""
59
+ # os.path is a module, not a Workflows instance
60
+ mocker.patch("sys.argv", ["render-workflows", "os:path"])
61
+
62
+ with pytest.raises(SystemExit) as exc_info:
63
+ cli.main()
64
+
65
+ assert exc_info.value.code == 1
66
+ captured = capsys.readouterr()
67
+ assert "Error: 'path' does not have a start() method" in captured.err
68
+ assert "Expected a Workflows instance" in captured.err
69
+
70
+ def test_valid_app_calls_start(self, mocker):
71
+ """Test that a valid app path calls the app's start() method."""
72
+ # Create a mock app with a start method
73
+ mock_app = mocker.Mock()
74
+ mock_app.start = mocker.Mock()
75
+
76
+ # Create a mock module containing the app
77
+ mock_module = mocker.Mock()
78
+ mock_module.app = mock_app
79
+
80
+ # Patch sys.argv and importlib
81
+ mocker.patch("sys.argv", ["render-workflows", "mymodule:app"])
82
+ mocker.patch("importlib.import_module", return_value=mock_module)
83
+
84
+ # Should not raise
85
+ cli.main()
86
+
87
+ # Verify start() was called
88
+ mock_app.start.assert_called_once()
89
+
90
+ def test_nested_module_path(self, mocker):
91
+ """Test that nested module paths (e.g., 'myproject.tasks:app') work."""
92
+ mock_app = mocker.Mock()
93
+ mock_app.start = mocker.Mock()
94
+
95
+ mock_module = mocker.Mock()
96
+ mock_module.app = mock_app
97
+
98
+ mocker.patch("sys.argv", ["render-workflows", "myproject.tasks.workers:app"])
99
+ mock_import = mocker.patch("importlib.import_module", return_value=mock_module)
100
+
101
+ cli.main()
102
+
103
+ # Verify the full module path was used
104
+ mock_import.assert_called_once_with("myproject.tasks.workers")
105
+ mock_app.start.assert_called_once()
106
+
107
+ def test_colon_in_module_path_uses_last_colon(self, mocker, capsys):
108
+ """Test that multiple colons use rsplit to get the last segment."""
109
+ # This is an edge case: "weird:module:app" should split as
110
+ # module="weird:module", app="app"
111
+ mocker.patch("sys.argv", ["render-workflows", "weird:module:app"])
112
+
113
+ with pytest.raises(SystemExit) as exc_info:
114
+ cli.main()
115
+
116
+ # Should fail on import (module "weird:module" doesn't exist)
117
+ assert exc_info.value.code == 1
118
+ captured = capsys.readouterr()
119
+ assert "Could not import module 'weird:module'" in captured.err
120
+
121
+ def test_import_error_includes_original_message(self, mocker, capsys):
122
+ """Test that import errors include the original exception message."""
123
+ mocker.patch("sys.argv", ["render-workflows", "mymodule:app"])
124
+ mocker.patch(
125
+ "importlib.import_module",
126
+ side_effect=ImportError("No module named 'dependency'"),
127
+ )
128
+
129
+ with pytest.raises(SystemExit) as exc_info:
130
+ cli.main()
131
+
132
+ assert exc_info.value.code == 1
133
+ captured = capsys.readouterr()
134
+ assert "No module named 'dependency'" in captured.err
@@ -71,7 +71,7 @@ def test_task_registration_network_payload(task_registry, task_decorator, mocker
71
71
 
72
72
  @task_decorator(
73
73
  options=Options(
74
- retry=Retry(max_retries=3, wait_duration=1000, backoff_scaling=1.5)
74
+ retry=Retry(max_retries=3, wait_duration_ms=1000, backoff_scaling=1.5)
75
75
  ),
76
76
  )
77
77
  def retry_task(data: str) -> str:
@@ -122,6 +122,74 @@ def test_task_registration_network_payload(task_registry, task_decorator, mocker
122
122
  assert retry_options.factor == 1.5
123
123
 
124
124
 
125
+ def test_task_registration_with_timeout_seconds(task_registry, task_decorator, mocker):
126
+ """
127
+ Test that task registration correctly serializes timeout_seconds
128
+ in the network payload.
129
+ """
130
+ from render_sdk.workflows.callback_api.types import UNSET
131
+
132
+ # Mock the UDSClient class
133
+ mock_uds_client_class = mocker.patch("render_sdk.workflows.runner.UDSClient")
134
+
135
+ # Set up the mock instance
136
+ mock_client_instance = mocker.Mock()
137
+ mock_uds_client_class.return_value = mock_client_instance
138
+ mock_client_instance.register_tasks = mocker.AsyncMock(
139
+ return_value={"status": "success"},
140
+ )
141
+ mock_client_instance.disconnect = mocker.AsyncMock()
142
+
143
+ # Define a task with timeout_seconds
144
+ @task_decorator(options=Options(timeout_seconds=120))
145
+ def timeout_task(x: int) -> int:
146
+ return x * 2
147
+
148
+ # Define a task without timeout_seconds
149
+ @task_decorator
150
+ def no_timeout_task(x: int) -> int:
151
+ return x * 3
152
+
153
+ # Define a task with both timeout_seconds and retry
154
+ @task_decorator(
155
+ options=Options(
156
+ timeout_seconds=300,
157
+ retry=Retry(max_retries=2, wait_duration_ms=500, backoff_scaling=1.5),
158
+ )
159
+ )
160
+ def timeout_and_retry_task(x: int) -> int:
161
+ return x * 4
162
+
163
+ # Mock get_task_registry to return our test registry
164
+ mock_get_registry = mocker.patch("render_sdk.workflows.runner.get_task_registry")
165
+ mock_get_registry.return_value = task_registry
166
+
167
+ register("/tmp/test.sock") # noqa:S108
168
+
169
+ # Get the actual payload that was sent
170
+ sent_tasks = mock_client_instance.register_tasks.call_args[0][0]
171
+
172
+ # Find tasks by name
173
+ task_by_name = {task.name: task for task in sent_tasks.tasks}
174
+
175
+ # Verify task with timeout_seconds
176
+ assert "timeout_task" in task_by_name
177
+ timeout_task_payload = task_by_name["timeout_task"]
178
+ assert timeout_task_payload.options.timeout_seconds == 120
179
+
180
+ # Verify task without timeout_seconds has UNSET timeout
181
+ assert "no_timeout_task" in task_by_name
182
+ no_timeout_payload = task_by_name["no_timeout_task"]
183
+ assert no_timeout_payload.options.timeout_seconds is UNSET
184
+
185
+ # Verify task with both timeout and retry
186
+ assert "timeout_and_retry_task" in task_by_name
187
+ combined_payload = task_by_name["timeout_and_retry_task"]
188
+ assert combined_payload.options.timeout_seconds == 300
189
+ assert combined_payload.options.retry is not UNSET
190
+ assert combined_payload.options.retry.max_retries == 2
191
+
192
+
125
193
  @pytest.mark.asyncio
126
194
  async def test_callback_payloads_with_mocked_client(
127
195
  task_registry,
@@ -94,7 +94,7 @@ def test_task_registration_with_options_object():
94
94
  # Task with only retry options
95
95
  @task_decorator(
96
96
  options=Options(
97
- retry=Retry(max_retries=1, wait_duration=500, backoff_scaling=1.0)
97
+ retry=Retry(max_retries=1, wait_duration_ms=500, backoff_scaling=1.0)
98
98
  ),
99
99
  )
100
100
  def task_with_retry_only(x: int) -> int:
@@ -130,3 +130,58 @@ def test_task_registration_preserves_function_attributes(task_registry, task_dec
130
130
  # Verify the original function attributes are preserved
131
131
  assert documented_task.__name__ == "documented_task"
132
132
  assert documented_task.__doc__ == "This is a documented function."
133
+
134
+
135
+ def test_task_registration_with_timeout_seconds():
136
+ """Test task registration with timeout_seconds option."""
137
+ registry = TaskRegistry()
138
+ task_decorator = create_task_decorator(registry)
139
+
140
+ # Task with timeout_seconds
141
+ @task_decorator(options=Options(timeout_seconds=120))
142
+ def task_with_timeout(x: int) -> int:
143
+ return x
144
+
145
+ # Verify task registered correctly
146
+ task_names = registry.get_task_names()
147
+ assert "task_with_timeout" in task_names
148
+
149
+ # Verify timeout_seconds is set
150
+ task_info = registry.get_task("task_with_timeout")
151
+ assert task_info.options is not None
152
+ assert task_info.options.timeout_seconds == 120
153
+
154
+
155
+ def test_task_registration_without_timeout_seconds():
156
+ """Test task registration without timeout_seconds defaults to None."""
157
+ registry = TaskRegistry()
158
+ task_decorator = create_task_decorator(registry)
159
+
160
+ @task_decorator
161
+ def task_without_timeout(x: int) -> int:
162
+ return x
163
+
164
+ task_info = registry.get_task("task_without_timeout")
165
+ assert task_info.options is not None
166
+ assert task_info.options.timeout_seconds is None
167
+
168
+
169
+ def test_task_registration_with_timeout_and_retry():
170
+ """Test task registration with both timeout_seconds and retry options."""
171
+ registry = TaskRegistry()
172
+ task_decorator = create_task_decorator(registry)
173
+
174
+ @task_decorator(
175
+ options=Options(
176
+ timeout_seconds=300,
177
+ retry=Retry(max_retries=3, wait_duration_ms=1000, backoff_scaling=2.0),
178
+ )
179
+ )
180
+ def task_with_both(x: int) -> int:
181
+ return x
182
+
183
+ task_info = registry.get_task("task_with_both")
184
+ assert task_info.options is not None
185
+ assert task_info.options.timeout_seconds == 300
186
+ assert task_info.options.retry is not None
187
+ assert task_info.options.retry.max_retries == 3