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.
- 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/workflows.py +10 -2
- 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/task_options.py +18 -0
- render_sdk/workflows/cli.py +58 -0
- render_sdk/workflows/client.py +2 -7
- render_sdk/workflows/runner.py +12 -7
- render_sdk/workflows/task.py +11 -2
- 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 +69 -1
- render_sdk/workflows/tests/test_registration.py +56 -1
- {render_sdk-0.1.3.dist-info → render_sdk-0.2.0.dist-info}/METADATA +1 -1
- {render_sdk-0.1.3.dist-info → render_sdk-0.2.0.dist-info}/RECORD +149 -78
- 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.3.dist-info → render_sdk-0.2.0.dist-info}/WHEEL +0 -0
- {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,
|
|
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,
|
|
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
|