vellum-ai 0.13.14__py3-none-any.whl → 0.13.18__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 (26) hide show
  1. vellum/client/core/client_wrapper.py +1 -1
  2. vellum/client/resources/workflows/client.py +0 -10
  3. vellum/workflows/nodes/core/templating_node/node.py +4 -47
  4. vellum/workflows/nodes/displayable/code_execution_node/node.py +29 -23
  5. vellum/workflows/nodes/displayable/code_execution_node/tests/test_code_execution_node.py +169 -5
  6. vellum/workflows/nodes/displayable/code_execution_node/utils.py +98 -1
  7. vellum/workflows/nodes/utils.py +50 -1
  8. vellum/workflows/references/external_input.py +14 -0
  9. vellum/workflows/state/base.py +7 -0
  10. vellum/workflows/state/tests/test_state.py +42 -0
  11. {vellum_ai-0.13.14.dist-info → vellum_ai-0.13.18.dist-info}/METADATA +1 -1
  12. {vellum_ai-0.13.14.dist-info → vellum_ai-0.13.18.dist-info}/RECORD +26 -25
  13. vellum_cli/__init__.py +11 -0
  14. vellum_cli/pull.py +57 -20
  15. vellum_cli/push.py +27 -10
  16. vellum_cli/tests/test_pull.py +115 -8
  17. vellum_cli/tests/test_push.py +148 -44
  18. vellum_ee/workflows/display/nodes/base_node_display.py +2 -2
  19. vellum_ee/workflows/display/nodes/get_node_display_class.py +16 -20
  20. vellum_ee/workflows/display/nodes/vellum/__init__.py +2 -0
  21. vellum_ee/workflows/display/nodes/vellum/retry_node.py +10 -0
  22. vellum_ee/workflows/display/tests/workflow_serialization/generic_nodes/test_adornments_serialization.py +23 -0
  23. vellum_ee/workflows/display/tests/workflow_serialization/test_basic_code_execution_node_serialization.py +2 -2
  24. {vellum_ai-0.13.14.dist-info → vellum_ai-0.13.18.dist-info}/LICENSE +0 -0
  25. {vellum_ai-0.13.14.dist-info → vellum_ai-0.13.18.dist-info}/WHEEL +0 -0
  26. {vellum_ai-0.13.14.dist-info → vellum_ai-0.13.18.dist-info}/entry_points.txt +0 -0
@@ -39,6 +39,7 @@ def test_pull(vellum_client, mock_module, base_command):
39
39
  # GIVEN a module on the user's filesystem
40
40
  temp_dir = mock_module.temp_dir
41
41
  module = mock_module.module
42
+ workflow_sandbox_id = mock_module.workflow_sandbox_id
42
43
 
43
44
  # AND the workflow pull API call returns a zip file
44
45
  vellum_client.workflows.pull.return_value = iter([_zip_file_map({"workflow.py": "print('hello')"})])
@@ -56,6 +57,27 @@ def test_pull(vellum_client, mock_module, base_command):
56
57
  with open(workflow_py) as f:
57
58
  assert f.read() == "print('hello')"
58
59
 
60
+ # AND the vellum.lock.json file is created
61
+ vellum_lock_json = os.path.join(temp_dir, "vellum.lock.json")
62
+ assert os.path.exists(vellum_lock_json)
63
+ with open(vellum_lock_json) as f:
64
+ lock_data = json.load(f)
65
+ assert lock_data == {
66
+ "version": "1.0",
67
+ "workflows": [
68
+ {
69
+ "module": module,
70
+ "workflow_sandbox_id": workflow_sandbox_id,
71
+ "container_image_name": None,
72
+ "container_image_tag": None,
73
+ "ignore": None,
74
+ "deployments": [],
75
+ "workspace": "default",
76
+ }
77
+ ],
78
+ "workspaces": [],
79
+ }
80
+
59
81
 
60
82
  def test_pull__second_module(vellum_client, mock_module):
61
83
  # GIVEN a module on the user's filesystem
@@ -95,7 +117,13 @@ def test_pull__sandbox_id_with_no_config(vellum_client):
95
117
  workflow_sandbox_id = "87654321-0000-0000-0000-000000000000"
96
118
 
97
119
  # AND the workflow pull API call returns a zip file
98
- vellum_client.workflows.pull.return_value = iter([_zip_file_map({"workflow.py": "print('hello')"})])
120
+ vellum_client.workflows.pull.return_value = iter(
121
+ [
122
+ _zip_file_map(
123
+ {"workflow.py": "print('hello')", "metadata.json": json.dumps({"label": "Super Cool Workflow"})}
124
+ )
125
+ ]
126
+ )
99
127
 
100
128
  # AND we are currently in a new directory
101
129
  current_dir = os.getcwd()
@@ -112,7 +140,7 @@ def test_pull__sandbox_id_with_no_config(vellum_client):
112
140
 
113
141
  # AND the pull api is called with the workflow sandbox id
114
142
  vellum_client.workflows.pull.assert_called_once()
115
- workflow_py = os.path.join(temp_dir, "workflow_87654321", "workflow.py")
143
+ workflow_py = os.path.join(temp_dir, "super_cool_workflow", "workflow.py")
116
144
  assert os.path.exists(workflow_py)
117
145
  with open(workflow_py) as f:
118
146
  assert f.read() == "print('hello')"
@@ -127,7 +155,7 @@ def test_pull__sandbox_id_with_no_config(vellum_client):
127
155
  "workspaces": [],
128
156
  "workflows": [
129
157
  {
130
- "module": "workflow_87654321",
158
+ "module": "super_cool_workflow",
131
159
  "workflow_sandbox_id": "87654321-0000-0000-0000-000000000000",
132
160
  "ignore": None,
133
161
  "deployments": [],
@@ -147,7 +175,13 @@ def test_pull__sandbox_id_with_other_workflow_configured(vellum_client, mock_mod
147
175
  workflow_sandbox_id = "87654321-0000-0000-0000-000000000000"
148
176
 
149
177
  # AND the workflow pull API call returns a zip file
150
- vellum_client.workflows.pull.return_value = iter([_zip_file_map({"workflow.py": "print('hello')"})])
178
+ vellum_client.workflows.pull.return_value = iter(
179
+ [
180
+ _zip_file_map(
181
+ {"workflow.py": "print('hello')", "metadata.json": json.dumps({"label": "Super Cool Workflow"})}
182
+ )
183
+ ]
184
+ )
151
185
 
152
186
  # WHEN the user runs the pull command with the new workflow sandbox id
153
187
  runner = CliRunner()
@@ -162,7 +196,7 @@ def test_pull__sandbox_id_with_other_workflow_configured(vellum_client, mock_mod
162
196
  assert call_args[0] == workflow_sandbox_id
163
197
 
164
198
  # AND the workflow.py file is written to the module directory
165
- workflow_py = os.path.join(temp_dir, "workflow_87654321", "workflow.py")
199
+ workflow_py = os.path.join(temp_dir, "super_cool_workflow", "workflow.py")
166
200
  assert os.path.exists(workflow_py)
167
201
  with open(workflow_py) as f:
168
202
  assert f.read() == "print('hello')"
@@ -420,8 +454,12 @@ def test_pull__sandbox_id_with_other_workflow_deployment_in_lock(vellum_client,
420
454
  _zip_file_map(
421
455
  {
422
456
  "workflow.py": "print('hello')",
423
- "metadata.json": '{"runner_config": { "container_image_name": "test", '
424
- '"container_image_tag": "1.0" } }',
457
+ "metadata.json": json.dumps(
458
+ {
459
+ "runner_config": {"container_image_name": "test", "container_image_tag": "1.0"},
460
+ "label": "Super Cool Workflow",
461
+ }
462
+ ),
425
463
  }
426
464
  )
427
465
  ]
@@ -456,7 +494,7 @@ def test_pull__sandbox_id_with_other_workflow_deployment_in_lock(vellum_client,
456
494
  "workspace": "default",
457
495
  },
458
496
  {
459
- "module": "workflow_87654321",
497
+ "module": "super_cool_workflow",
460
498
  "workflow_sandbox_id": new_workflow_sandbox_id,
461
499
  "ignore": None,
462
500
  "deployments": [],
@@ -539,3 +577,72 @@ def test_pull__include_sandbox(vellum_client, mock_module):
539
577
  with open(lock_json) as f:
540
578
  lock_data = json.load(f)
541
579
  assert lock_data["workflows"][0]["ignore"] == "sandbox.py"
580
+
581
+
582
+ def test_pull__same_pull_twice__one_entry_in_lockfile(vellum_client, mock_module):
583
+ # GIVEN a module on the user's filesystem
584
+ module = mock_module.module
585
+ temp_dir = mock_module.temp_dir
586
+ workflow_sandbox_id = mock_module.workflow_sandbox_id
587
+
588
+ # AND the workflow pull API call returns a zip file both times
589
+ zip_contents = _zip_file_map({"workflow.py": "print('hello')"})
590
+ responses = iter([zip_contents, zip_contents])
591
+
592
+ def workflows_pull_side_effect(*args, **kwargs):
593
+ return iter([next(responses)])
594
+
595
+ vellum_client.workflows.pull.side_effect = workflows_pull_side_effect
596
+
597
+ # AND the user runs the pull command once
598
+ runner = CliRunner()
599
+ runner.invoke(cli_main, ["pull", module])
600
+
601
+ # WHEN the user runs the pull command again but with the workflow sandbox id
602
+ result = runner.invoke(cli_main, ["workflows", "pull", "--workflow-sandbox-id", workflow_sandbox_id])
603
+
604
+ # THEN the command returns successfully
605
+ assert result.exit_code == 0, (result.output, result.exception)
606
+
607
+ # AND the lockfile should only have one entry
608
+ lock_json = os.path.join(temp_dir, "vellum.lock.json")
609
+ with open(lock_json) as f:
610
+ lock_data = json.load(f)
611
+ assert len(lock_data["workflows"]) == 1
612
+
613
+
614
+ def test_pull__module_not_in_config(vellum_client, mock_module):
615
+ # GIVEN a module on the user's filesystem
616
+ module = mock_module.module
617
+ temp_dir = mock_module.temp_dir
618
+ workflow_sandbox_id = mock_module.workflow_sandbox_id
619
+ set_pyproject_toml = mock_module.set_pyproject_toml
620
+
621
+ # AND the pyproject.toml does not have the module configured
622
+ set_pyproject_toml({"workflows": []})
623
+
624
+ # AND the workflow pull API call returns a zip file
625
+ vellum_client.workflows.pull.return_value = iter([_zip_file_map({"workflow.py": "print('hello')"})])
626
+
627
+ # WHEN the user runs the pull command again with the workflow sandbox id and module
628
+ runner = CliRunner()
629
+ result = runner.invoke(cli_main, ["workflows", "pull", module, "--workflow-sandbox-id", workflow_sandbox_id])
630
+
631
+ # THEN the command returns successfully
632
+ assert result.exit_code == 0, (result.output, result.exception)
633
+
634
+ # AND the lockfile should have the new entry
635
+ lock_json = os.path.join(temp_dir, "vellum.lock.json")
636
+ with open(lock_json) as f:
637
+ lock_data = json.load(f)
638
+ assert lock_data["workflows"] == [
639
+ {
640
+ "module": module,
641
+ "workflow_sandbox_id": workflow_sandbox_id,
642
+ "ignore": None,
643
+ "deployments": [],
644
+ "container_image_name": None,
645
+ "container_image_tag": None,
646
+ "workspace": "default",
647
+ }
648
+ ]
@@ -29,6 +29,21 @@ def _extract_tar_gz(tar_gz_bytes: bytes) -> dict[str, str]:
29
29
  return files
30
30
 
31
31
 
32
+ def _ensure_workflow_py(temp_dir: str, module: str) -> str:
33
+ base_dir = os.path.join(temp_dir, *module.split("."))
34
+ os.makedirs(base_dir, exist_ok=True)
35
+ workflow_py_file_content = """\
36
+ from vellum.workflows import BaseWorkflow
37
+
38
+ class ExampleWorkflow(BaseWorkflow):
39
+ pass
40
+ """
41
+ with open(os.path.join(temp_dir, *module.split("."), "workflow.py"), "w") as f:
42
+ f.write(workflow_py_file_content)
43
+
44
+ return workflow_py_file_content
45
+
46
+
32
47
  def test_push__no_config(mock_module):
33
48
  # GIVEN no config file set
34
49
  mock_module.set_pyproject_toml({"workflows": []})
@@ -89,16 +104,7 @@ def test_push__happy_path(mock_module, vellum_client, base_command):
89
104
  module = mock_module.module
90
105
 
91
106
  # AND a workflow exists in the module successfully
92
- base_dir = os.path.join(temp_dir, *module.split("."))
93
- os.makedirs(base_dir, exist_ok=True)
94
- workflow_py_file_content = """\
95
- from vellum.workflows import BaseWorkflow
96
-
97
- class ExampleWorkflow(BaseWorkflow):
98
- pass
99
- """
100
- with open(os.path.join(temp_dir, *module.split("."), "workflow.py"), "w") as f:
101
- f.write(workflow_py_file_content)
107
+ workflow_py_file_content = _ensure_workflow_py(temp_dir, module)
102
108
 
103
109
  # AND the push API call returns successfully
104
110
  vellum_client.workflows.push.return_value = WorkflowPushResponse(
@@ -113,14 +119,12 @@ class ExampleWorkflow(BaseWorkflow):
113
119
  assert result.exit_code == 0
114
120
 
115
121
  # Get the last part of the module path and format it
116
- expected_label = mock_module.module.split(".")[-1].replace("_", " ").title()
117
122
  expected_artifact_name = f"{mock_module.module.replace('.', '__')}.tar.gz"
118
123
 
119
124
  # AND we should have called the push API with the correct args
120
125
  vellum_client.workflows.push.assert_called_once()
121
126
  call_args = vellum_client.workflows.push.call_args.kwargs
122
127
  assert json.loads(call_args["exec_config"])["workflow_raw_data"]["definition"]["name"] == "ExampleWorkflow"
123
- assert call_args["label"] == expected_label
124
128
  assert is_valid_uuid(call_args["workflow_sandbox_id"])
125
129
  assert call_args["artifact"].name == expected_artifact_name
126
130
  assert "deplyment_config" not in call_args
@@ -137,22 +141,142 @@ class ExampleWorkflow(BaseWorkflow):
137
141
  ],
138
142
  ids=["push", "workflows_push"],
139
143
  )
140
- def test_push__deployment(mock_module, vellum_client, base_command):
144
+ def test_push__workflow_sandbox_option__existing_id(mock_module, vellum_client, base_command):
141
145
  # GIVEN a single workflow configured
142
146
  temp_dir = mock_module.temp_dir
143
147
  module = mock_module.module
148
+ existing_workflow_sandbox_id = mock_module.workflow_sandbox_id
144
149
 
145
150
  # AND a workflow exists in the module successfully
146
- base_dir = os.path.join(temp_dir, *module.split("."))
147
- os.makedirs(base_dir, exist_ok=True)
148
- workflow_py_file_content = """\
149
- from vellum.workflows import BaseWorkflow
151
+ workflow_py_file_content = _ensure_workflow_py(temp_dir, module)
150
152
 
151
- class ExampleWorkflow(BaseWorkflow):
152
- pass
153
- """
154
- with open(os.path.join(temp_dir, *module.split("."), "workflow.py"), "w") as f:
155
- f.write(workflow_py_file_content)
153
+ # AND the push API call would return successfully
154
+ vellum_client.workflows.push.return_value = WorkflowPushResponse(
155
+ workflow_sandbox_id=existing_workflow_sandbox_id,
156
+ )
157
+
158
+ # WHEN calling `vellum push` with the workflow sandbox option on an existing config
159
+ runner = CliRunner()
160
+ result = runner.invoke(cli_main, base_command + [module, "--workflow-sandbox-id", existing_workflow_sandbox_id])
161
+
162
+ # THEN it should succeed
163
+ assert result.exit_code == 0
164
+
165
+ # Get the last part of the module path and format it
166
+ expected_artifact_name = f"{mock_module.module.replace('.', '__')}.tar.gz"
167
+
168
+ # AND we should have called the push API with the correct args
169
+ vellum_client.workflows.push.assert_called_once()
170
+ call_args = vellum_client.workflows.push.call_args.kwargs
171
+ assert json.loads(call_args["exec_config"])["workflow_raw_data"]["definition"]["name"] == "ExampleWorkflow"
172
+ assert call_args["workflow_sandbox_id"] == existing_workflow_sandbox_id
173
+ assert call_args["artifact"].name == expected_artifact_name
174
+ assert "deplyment_config" not in call_args
175
+
176
+ extracted_files = _extract_tar_gz(call_args["artifact"].read())
177
+ assert extracted_files["workflow.py"] == workflow_py_file_content
178
+
179
+
180
+ def test_push__workflow_sandbox_option__existing_no_module(mock_module, vellum_client):
181
+ # GIVEN a single workflow configured
182
+ temp_dir = mock_module.temp_dir
183
+ first_module = mock_module.module
184
+ second_module = f"{first_module}2"
185
+ first_workflow_sandbox_id = mock_module.workflow_sandbox_id
186
+ second_workflow_sandbox_id = str(uuid4())
187
+
188
+ # AND the pyproject.toml has two workflow sandboxes configured
189
+ mock_module.set_pyproject_toml(
190
+ {
191
+ "workflows": [
192
+ {"module": first_module, "workflow_sandbox_id": first_workflow_sandbox_id},
193
+ {"module": second_module, "workflow_sandbox_id": second_workflow_sandbox_id},
194
+ ]
195
+ }
196
+ )
197
+
198
+ # AND a workflow exists for both modules
199
+ _ensure_workflow_py(temp_dir, first_module)
200
+ workflow_py_file_content = _ensure_workflow_py(temp_dir, second_module)
201
+
202
+ # AND the push API call would return successfully for the second module
203
+ vellum_client.workflows.push.return_value = WorkflowPushResponse(
204
+ workflow_sandbox_id=second_workflow_sandbox_id,
205
+ )
206
+
207
+ # WHEN calling `vellum push` with the workflow sandbox option on the second module
208
+ runner = CliRunner()
209
+ result = runner.invoke(cli_main, ["workflows", "push", "--workflow-sandbox-id", second_workflow_sandbox_id])
210
+
211
+ # THEN it should succeed
212
+ assert result.exit_code == 0
213
+
214
+ # Get the last part of the module path and format it
215
+ expected_artifact_name = f"{second_module.replace('.', '__')}.tar.gz"
216
+
217
+ # AND we should have called the push API with the correct args
218
+ vellum_client.workflows.push.assert_called_once()
219
+ call_args = vellum_client.workflows.push.call_args.kwargs
220
+ assert json.loads(call_args["exec_config"])["workflow_raw_data"]["definition"]["name"] == "ExampleWorkflow"
221
+ assert call_args["workflow_sandbox_id"] == second_workflow_sandbox_id
222
+ assert call_args["artifact"].name == expected_artifact_name
223
+ assert "deplyment_config" not in call_args
224
+
225
+ extracted_files = _extract_tar_gz(call_args["artifact"].read())
226
+ assert extracted_files["workflow.py"] == workflow_py_file_content
227
+
228
+
229
+ def test_push__workflow_sandbox_option__existing_id_different_module(mock_module):
230
+ # GIVEN a single workflow configured
231
+ temp_dir = mock_module.temp_dir
232
+ module = mock_module.module
233
+ second_module = f"{module}2"
234
+ first_workflow_sandbox_id = mock_module.workflow_sandbox_id
235
+ second_workflow_sandbox_id = str(uuid4())
236
+ set_pyproject_toml = mock_module.set_pyproject_toml
237
+
238
+ # AND the pyproject.toml has two workflow sandboxes configured
239
+ set_pyproject_toml(
240
+ {
241
+ "workflows": [
242
+ {"module": module, "workflow_sandbox_id": first_workflow_sandbox_id},
243
+ {"module": second_module, "workflow_sandbox_id": second_workflow_sandbox_id},
244
+ ]
245
+ }
246
+ )
247
+
248
+ # AND a workflow exists in both modules successfully
249
+ _ensure_workflow_py(temp_dir, module)
250
+ _ensure_workflow_py(temp_dir, second_module)
251
+
252
+ # WHEN calling `vellum push` with the first module and the second workflow sandbox id
253
+ runner = CliRunner()
254
+ result = runner.invoke(cli_main, ["workflows", "push", module, "--workflow-sandbox-id", second_workflow_sandbox_id])
255
+
256
+ # THEN it should fail
257
+ assert result.exit_code == 1
258
+ assert result.exception
259
+ assert (
260
+ str(result.exception)
261
+ == "Multiple workflows found in project to push. Pushing only a single workflow is supported."
262
+ )
263
+
264
+
265
+ @pytest.mark.parametrize(
266
+ "base_command",
267
+ [
268
+ ["push"],
269
+ ["workflows", "push"],
270
+ ],
271
+ ids=["push", "workflows_push"],
272
+ )
273
+ def test_push__deployment(mock_module, vellum_client, base_command):
274
+ # GIVEN a single workflow configured
275
+ temp_dir = mock_module.temp_dir
276
+ module = mock_module.module
277
+
278
+ # AND a workflow exists in the module successfully
279
+ workflow_py_file_content = _ensure_workflow_py(temp_dir, module)
156
280
 
157
281
  # AND the push API call returns successfully
158
282
  vellum_client.workflows.push.return_value = WorkflowPushResponse(
@@ -167,14 +291,12 @@ class ExampleWorkflow(BaseWorkflow):
167
291
  assert result.exit_code == 0
168
292
 
169
293
  # Get the last part of the module path and format it
170
- expected_label = mock_module.module.split(".")[-1].replace("_", " ").title()
171
294
  expected_artifact_name = f"{mock_module.module.replace('.', '__')}.tar.gz"
172
295
 
173
296
  # AND we should have called the push API with the correct args
174
297
  vellum_client.workflows.push.assert_called_once()
175
298
  call_args = vellum_client.workflows.push.call_args.kwargs
176
299
  assert json.loads(call_args["exec_config"])["workflow_raw_data"]["definition"]["name"] == "ExampleWorkflow"
177
- assert call_args["label"] == expected_label
178
300
  assert is_valid_uuid(call_args["workflow_sandbox_id"])
179
301
  assert call_args["artifact"].name == expected_artifact_name
180
302
  assert call_args["deployment_config"] == "{}"
@@ -252,16 +374,7 @@ def test_push__strict_option_returns_diffs(mock_module, vellum_client):
252
374
  module = mock_module.module
253
375
 
254
376
  # AND a workflow exists in the module successfully
255
- base_dir = os.path.join(temp_dir, *module.split("."))
256
- os.makedirs(base_dir, exist_ok=True)
257
- workflow_py_file_content = """\
258
- from vellum.workflows import BaseWorkflow
259
-
260
- class ExampleWorkflow(BaseWorkflow):
261
- pass
262
- """
263
- with open(os.path.join(temp_dir, *module.split("."), "workflow.py"), "w") as f:
264
- f.write(workflow_py_file_content)
377
+ _ensure_workflow_py(temp_dir, module)
265
378
 
266
379
  # AND the push API call returns a 4xx response with diffs
267
380
  vellum_client.workflows.push.side_effect = ApiError(
@@ -356,16 +469,7 @@ MY_OTHER_VELLUM_API_KEY=aaabbbcccddd
356
469
  )
357
470
 
358
471
  # AND a workflow exists in the module successfully
359
- base_dir = os.path.join(temp_dir, *module.split("."))
360
- os.makedirs(base_dir, exist_ok=True)
361
- workflow_py_file_content = """\
362
- from vellum.workflows import BaseWorkflow
363
-
364
- class ExampleWorkflow(BaseWorkflow):
365
- pass
366
- """
367
- with open(os.path.join(temp_dir, *module.split("."), "workflow.py"), "w") as f:
368
- f.write(workflow_py_file_content)
472
+ _ensure_workflow_py(temp_dir, module)
369
473
 
370
474
  # AND the push API call returns a new workflow sandbox id
371
475
  new_workflow_sandbox_id = str(uuid4())
@@ -208,8 +208,8 @@ class BaseNodeDisplay(Generic[NodeType], metaclass=BaseNodeDisplayMeta):
208
208
  return uuid4_from_hash(f"{self.node_id}|trigger")
209
209
 
210
210
  @classmethod
211
- def get_from_node_display_registry(cls, node_class: Type[NodeType]) -> Type["BaseNodeDisplay"]:
212
- return cls._node_display_registry[node_class]
211
+ def get_from_node_display_registry(cls, node_class: Type[NodeType]) -> Optional[Type["BaseNodeDisplay"]]:
212
+ return cls._node_display_registry.get(node_class)
213
213
 
214
214
  @classmethod
215
215
  def infer_node_class(cls) -> Type[NodeType]:
@@ -10,27 +10,23 @@ if TYPE_CHECKING:
10
10
  def get_node_display_class(
11
11
  base_class: Type["NodeDisplayType"], node_class: Type[NodeType], root_node_class: Optional[Type[NodeType]] = None
12
12
  ) -> Type["NodeDisplayType"]:
13
- try:
14
- node_display_class = base_class.get_from_node_display_registry(node_class)
15
- except KeyError:
16
- try:
17
- base_node_display_class = get_node_display_class(
18
- base_class, node_class.__bases__[0], node_class if root_node_class is None else root_node_class
13
+ node_display_class = base_class.get_from_node_display_registry(node_class)
14
+ if node_display_class:
15
+ if not issubclass(node_display_class, base_class):
16
+ raise TypeError(
17
+ f"Expected to find a subclass of '{base_class.__name__}' for node class '{node_class.__name__}'"
19
18
  )
20
19
 
21
- # `base_node_display_class` is always a Generic class, so it's safe to index into it
22
- NodeDisplayBaseClass = base_node_display_class[node_class] # type: ignore[index]
23
- NodeDisplayClass = types.new_class(
24
- f"{node_class.__name__}Display",
25
- bases=(NodeDisplayBaseClass,),
26
- )
27
- return NodeDisplayClass
28
- except IndexError:
29
- return base_class
20
+ return node_display_class
30
21
 
31
- if not issubclass(node_display_class, base_class):
32
- raise TypeError(
33
- f"Expected to find a subclass of '{base_class.__name__}' for node class '{node_class.__name__}'"
34
- )
22
+ base_node_display_class = get_node_display_class(
23
+ base_class, node_class.__bases__[0], node_class if root_node_class is None else root_node_class
24
+ )
35
25
 
36
- return node_display_class
26
+ # `base_node_display_class` is always a Generic class, so it's safe to index into it
27
+ NodeDisplayBaseClass = base_node_display_class[node_class] # type: ignore[index]
28
+ NodeDisplayClass = types.new_class(
29
+ f"{node_class.__name__}Display",
30
+ bases=(NodeDisplayBaseClass,),
31
+ )
32
+ return NodeDisplayClass
@@ -10,6 +10,7 @@ from .map_node import BaseMapNodeDisplay
10
10
  from .merge_node import BaseMergeNodeDisplay
11
11
  from .note_node import BaseNoteNodeDisplay
12
12
  from .prompt_deployment_node import BasePromptDeploymentNodeDisplay
13
+ from .retry_node import BaseRetryNodeDisplay
13
14
  from .search_node import BaseSearchNodeDisplay
14
15
  from .subworkflow_deployment_node import BaseSubworkflowDeploymentNodeDisplay
15
16
  from .templating_node import BaseTemplatingNodeDisplay
@@ -17,6 +18,7 @@ from .try_node import BaseTryNodeDisplay
17
18
 
18
19
  # All node display classes must be imported here to be registered in BaseNodeDisplay's node display registry
19
20
  __all__ = [
21
+ "BaseRetryNodeDisplay",
20
22
  "BaseAPINodeDisplay",
21
23
  "BaseCodeExecutionNodeDisplay",
22
24
  "BaseConditionalNodeDisplay",
@@ -0,0 +1,10 @@
1
+ from typing import Generic, TypeVar
2
+
3
+ from vellum.workflows.nodes.core.retry_node.node import RetryNode
4
+ from vellum_ee.workflows.display.nodes.base_node_display import BaseNodeDisplay
5
+
6
+ _RetryNodeType = TypeVar("_RetryNodeType", bound=RetryNode)
7
+
8
+
9
+ class BaseRetryNodeDisplay(BaseNodeDisplay[_RetryNodeType], Generic[_RetryNodeType]):
10
+ pass
@@ -7,10 +7,13 @@ from vellum.workflows.nodes.bases.base import BaseNode
7
7
  from vellum.workflows.nodes.core.retry_node.node import RetryNode
8
8
  from vellum.workflows.nodes.core.try_node.node import TryNode
9
9
  from vellum.workflows.outputs.base import BaseOutputs
10
+ from vellum.workflows.workflows.base import BaseWorkflow
10
11
  from vellum_ee.workflows.display.base import WorkflowInputsDisplay
11
12
  from vellum_ee.workflows.display.nodes.base_node_display import BaseNodeDisplay
12
13
  from vellum_ee.workflows.display.nodes.base_node_vellum_display import BaseNodeVellumDisplay
13
14
  from vellum_ee.workflows.display.nodes.vellum.try_node import BaseTryNodeDisplay
15
+ from vellum_ee.workflows.display.workflows.get_vellum_workflow_display_class import get_workflow_display
16
+ from vellum_ee.workflows.display.workflows.vellum_workflow_display import VellumWorkflowDisplay
14
17
 
15
18
 
16
19
  class Inputs(BaseInputs):
@@ -110,6 +113,26 @@ def test_serialize_node__retry(serialize_node):
110
113
  )
111
114
 
112
115
 
116
+ def test_serialize_node__retry__no_display(): # GIVEN an adornment node
117
+ @RetryNode.wrap(max_attempts=5)
118
+ class StartNode(BaseNode):
119
+ pass
120
+
121
+ # AND a workflow that uses the adornment node
122
+ class MyWorkflow(BaseWorkflow):
123
+ graph = StartNode
124
+
125
+ # WHEN we serialize the workflow
126
+ workflow_display = get_workflow_display(
127
+ base_display_class=VellumWorkflowDisplay,
128
+ workflow_class=MyWorkflow,
129
+ )
130
+ exec_config = workflow_display.serialize()
131
+
132
+ # THEN the workflow display is created successfully
133
+ assert exec_config is not None
134
+
135
+
113
136
  @TryNode.wrap()
114
137
  class InnerTryGenericNode(BaseNode):
115
138
  input = Inputs.input
@@ -93,7 +93,7 @@ def test_serialize_workflow_with_filepath():
93
93
  "code_input_id": "f2e8a4fa-b54e-41e9-b314-0e5443519ac7",
94
94
  "runtime_input_id": "19d64948-f22b-4103-a7f5-3add184b31cc",
95
95
  "output_type": "NUMBER",
96
- "packages": [],
96
+ "packages": [{"name": "openai", "version": "1.0.0"}],
97
97
  "output_id": "0fde9607-353f-42c2-85c4-20f720ebc1ec",
98
98
  "log_output_id": "7cac05e3-b7c3-475e-8df8-422b496c3398",
99
99
  },
@@ -566,7 +566,7 @@ def test_serialize_workflow__try_wrapped():
566
566
  "code_input_id": "f2e8a4fa-b54e-41e9-b314-0e5443519ac7",
567
567
  "runtime_input_id": "19d64948-f22b-4103-a7f5-3add184b31cc",
568
568
  "output_type": "NUMBER",
569
- "packages": [],
569
+ "packages": [{"name": "openai", "version": "1.0.0"}],
570
570
  "output_id": "0fde9607-353f-42c2-85c4-20f720ebc1ec",
571
571
  "log_output_id": "7cac05e3-b7c3-475e-8df8-422b496c3398",
572
572
  },