vellum-ai 0.14.5__py3-none-any.whl → 0.14.7__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 (68) hide show
  1. vellum/__init__.py +18 -0
  2. vellum/client/__init__.py +8 -8
  3. vellum/client/core/client_wrapper.py +1 -1
  4. vellum/client/resources/__init__.py +2 -0
  5. vellum/client/resources/workflow_sandboxes/__init__.py +3 -0
  6. vellum/client/resources/workflow_sandboxes/client.py +146 -0
  7. vellum/client/resources/workflow_sandboxes/types/__init__.py +5 -0
  8. vellum/client/resources/workflow_sandboxes/types/list_workflow_sandbox_examples_request_tag.py +5 -0
  9. vellum/client/types/__init__.py +16 -0
  10. vellum/client/types/array_chat_message_content_item.py +6 -1
  11. vellum/client/types/array_chat_message_content_item_request.py +2 -0
  12. vellum/client/types/chat_message_content.py +2 -0
  13. vellum/client/types/chat_message_content_request.py +2 -0
  14. vellum/client/types/document_chat_message_content.py +25 -0
  15. vellum/client/types/document_chat_message_content_request.py +25 -0
  16. vellum/client/types/document_vellum_value.py +25 -0
  17. vellum/client/types/document_vellum_value_request.py +25 -0
  18. vellum/client/types/paginated_workflow_sandbox_example_list.py +23 -0
  19. vellum/client/types/vellum_document.py +20 -0
  20. vellum/client/types/vellum_document_request.py +20 -0
  21. vellum/client/types/vellum_value.py +2 -0
  22. vellum/client/types/vellum_value_request.py +2 -0
  23. vellum/client/types/vellum_variable_type.py +1 -0
  24. vellum/client/types/workflow_sandbox_example.py +22 -0
  25. vellum/resources/workflow_sandboxes/types/__init__.py +3 -0
  26. vellum/resources/workflow_sandboxes/types/list_workflow_sandbox_examples_request_tag.py +3 -0
  27. vellum/types/document_chat_message_content.py +3 -0
  28. vellum/types/document_chat_message_content_request.py +3 -0
  29. vellum/types/document_vellum_value.py +3 -0
  30. vellum/types/document_vellum_value_request.py +3 -0
  31. vellum/types/paginated_workflow_sandbox_example_list.py +3 -0
  32. vellum/types/vellum_document.py +3 -0
  33. vellum/types/vellum_document_request.py +3 -0
  34. vellum/types/workflow_sandbox_example.py +3 -0
  35. vellum/workflows/exceptions.py +18 -0
  36. vellum/workflows/inputs/base.py +27 -1
  37. vellum/workflows/inputs/tests/__init__.py +0 -0
  38. vellum/workflows/inputs/tests/test_inputs.py +49 -0
  39. vellum/workflows/nodes/core/inline_subworkflow_node/node.py +1 -1
  40. vellum/workflows/nodes/core/map_node/node.py +7 -7
  41. vellum/workflows/nodes/core/try_node/node.py +1 -1
  42. vellum/workflows/nodes/displayable/bases/base_prompt_node/node.py +2 -2
  43. vellum/workflows/nodes/displayable/bases/inline_prompt_node/node.py +5 -3
  44. vellum/workflows/nodes/displayable/bases/prompt_deployment_node.py +5 -4
  45. vellum/workflows/nodes/displayable/inline_prompt_node/tests/test_node.py +4 -4
  46. vellum/workflows/nodes/displayable/subworkflow_deployment_node/node.py +49 -15
  47. vellum/workflows/nodes/displayable/subworkflow_deployment_node/tests/test_node.py +165 -0
  48. vellum/workflows/nodes/displayable/tests/test_text_prompt_deployment_node.py +3 -1
  49. vellum/workflows/outputs/base.py +1 -1
  50. vellum/workflows/runner/runner.py +16 -10
  51. vellum/workflows/state/context.py +7 -7
  52. vellum/workflows/workflows/base.py +61 -59
  53. vellum/workflows/workflows/tests/test_base_workflow.py +131 -40
  54. {vellum_ai-0.14.5.dist-info → vellum_ai-0.14.7.dist-info}/METADATA +1 -1
  55. {vellum_ai-0.14.5.dist-info → vellum_ai-0.14.7.dist-info}/RECORD +68 -44
  56. vellum_cli/__init__.py +36 -0
  57. vellum_cli/init.py +128 -0
  58. vellum_cli/pull.py +6 -3
  59. vellum_cli/tests/test_init.py +355 -0
  60. vellum_cli/tests/test_pull.py +127 -0
  61. vellum_ee/workflows/display/nodes/base_node_display.py +4 -4
  62. vellum_ee/workflows/display/nodes/vellum/tests/test_utils.py +31 -0
  63. vellum_ee/workflows/display/nodes/vellum/utils.py +8 -0
  64. vellum_ee/workflows/display/vellum.py +0 -4
  65. vellum_ee/workflows/display/workflows/tests/test_workflow_display.py +29 -0
  66. {vellum_ai-0.14.5.dist-info → vellum_ai-0.14.7.dist-info}/LICENSE +0 -0
  67. {vellum_ai-0.14.5.dist-info → vellum_ai-0.14.7.dist-info}/WHEEL +0 -0
  68. {vellum_ai-0.14.5.dist-info → vellum_ai-0.14.7.dist-info}/entry_points.txt +0 -0
vellum_cli/__init__.py CHANGED
@@ -4,6 +4,7 @@ import click
4
4
 
5
5
  from vellum_cli.aliased_group import ClickAliasedGroup
6
6
  from vellum_cli.image_push import image_push_command
7
+ from vellum_cli.init import init_command
7
8
  from vellum_cli.ping import ping_command
8
9
  from vellum_cli.pull import pull_command
9
10
  from vellum_cli.push import push_command
@@ -193,12 +194,20 @@ Should only be used for debugging purposes.""",
193
194
  help="""Generates a runnable sandbox.py file containing test data from the Resource's sandbox. \
194
195
  Helpful for running and debugging workflows locally.""",
195
196
  )
197
+ @click.option(
198
+ "--target-dir",
199
+ "target_directory", # Internal parameter name is target_directory
200
+ type=str,
201
+ help="""Directory to pull the workflow into. If not specified, \
202
+ the workflow will be pulled into the current working directory.""",
203
+ )
196
204
  def pull(
197
205
  ctx: click.Context,
198
206
  include_json: Optional[bool],
199
207
  exclude_code: Optional[bool],
200
208
  strict: Optional[bool],
201
209
  include_sandbox: Optional[bool],
210
+ target_directory: Optional[str],
202
211
  ) -> None:
203
212
  """Pull Resources from Vellum"""
204
213
 
@@ -208,6 +217,7 @@ def pull(
208
217
  exclude_code=exclude_code,
209
218
  strict=strict,
210
219
  include_sandbox=include_sandbox,
220
+ target_directory=target_directory,
211
221
  )
212
222
 
213
223
 
@@ -242,6 +252,13 @@ Should only be used for debugging purposes.""",
242
252
  help="""Generates a runnable sandbox.py file containing test data from the Resource's sandbox. \
243
253
  Helpful for running and debugging workflows locally.""",
244
254
  )
255
+ @click.option(
256
+ "--target-dir",
257
+ "target_directory", # Internal parameter name is target_directory
258
+ type=str,
259
+ help="""Directory to pull the workflow into. If not specified, \
260
+ the workflow will be pulled into the current working directory.""",
261
+ )
245
262
  def workflows_pull(
246
263
  module: Optional[str],
247
264
  include_json: Optional[bool],
@@ -250,6 +267,7 @@ def workflows_pull(
250
267
  exclude_code: Optional[bool],
251
268
  strict: Optional[bool],
252
269
  include_sandbox: Optional[bool],
270
+ target_directory: Optional[str],
253
271
  ) -> None:
254
272
  """
255
273
  Pull Workflows from Vellum. If a module is provided, only the Workflow for that module will be pulled.
@@ -264,6 +282,7 @@ def workflows_pull(
264
282
  exclude_code=exclude_code,
265
283
  strict=strict,
266
284
  include_sandbox=include_sandbox,
285
+ target_directory=target_directory,
267
286
  )
268
287
 
269
288
 
@@ -292,12 +311,20 @@ Should only be used for debugging purposes.""",
292
311
  help="""Generates a runnable sandbox.py file containing test data from the Resource's sandbox. \
293
312
  Helpful for running and debugging resources locally.""",
294
313
  )
314
+ @click.option(
315
+ "--target-dir",
316
+ "target_directory", # Internal parameter name is target_directory
317
+ type=str,
318
+ help="""Directory to pull the workflow into. If not specified, \
319
+ the workflow will be pulled into the current working directory.""",
320
+ )
295
321
  def pull_module(
296
322
  ctx: click.Context,
297
323
  include_json: Optional[bool],
298
324
  exclude_code: Optional[bool],
299
325
  strict: Optional[bool],
300
326
  include_sandbox: Optional[bool],
327
+ target_directory: Optional[str],
301
328
  ) -> None:
302
329
  """Pull a specific module from Vellum"""
303
330
 
@@ -308,6 +335,7 @@ def pull_module(
308
335
  exclude_code=exclude_code,
309
336
  strict=strict,
310
337
  include_sandbox=include_sandbox,
338
+ target_directory=target_directory,
311
339
  )
312
340
 
313
341
 
@@ -331,5 +359,13 @@ def image_push(image: str, tag: Optional[List[str]] = None) -> None:
331
359
  image_push_command(image, tag)
332
360
 
333
361
 
362
+ @workflows.command(name="init")
363
+ @click.argument("template_name", required=False)
364
+ def workflows_init(template_name: Optional[str] = None) -> None:
365
+ """Initialize a new Vellum Workflow using a predefined template"""
366
+
367
+ init_command(template_name=template_name)
368
+
369
+
334
370
  if __name__ == "__main__":
335
371
  main()
vellum_cli/init.py ADDED
@@ -0,0 +1,128 @@
1
+ import io
2
+ import json
3
+ import os
4
+ import zipfile
5
+ from typing import Optional, cast
6
+
7
+ import click
8
+ from dotenv import load_dotenv
9
+ from pydash import snake_case
10
+
11
+ from vellum.client.types.workflow_sandbox_example import WorkflowSandboxExample
12
+ from vellum.workflows.vellum_client import create_vellum_client
13
+ from vellum_cli.config import WorkflowConfig, load_vellum_cli_config
14
+ from vellum_cli.logger import load_cli_logger
15
+ from vellum_cli.pull import PullContentsMetadata, WorkflowConfigResolutionResult
16
+
17
+ ERROR_LOG_FILE_NAME = "error.log"
18
+ METADATA_FILE_NAME = "metadata.json"
19
+
20
+
21
+ def init_command(template_name: Optional[str] = None):
22
+ load_dotenv()
23
+ logger = load_cli_logger()
24
+ config = load_vellum_cli_config()
25
+
26
+ client = create_vellum_client()
27
+ templates_response = client.workflow_sandboxes.list_workflow_sandbox_examples(tag="TEMPLATES")
28
+
29
+ templates = templates_response.results
30
+ if not templates:
31
+ logger.error("No templates available")
32
+ return
33
+
34
+ if template_name:
35
+ selected_template = next((t for t in templates if snake_case(t.label) == template_name), None)
36
+ if not selected_template:
37
+ logger.error(f"Template {template_name} not found")
38
+ return
39
+ else:
40
+ click.echo(click.style("Available Templates", bold=True, fg="green"))
41
+ for idx, template in enumerate(templates, 1):
42
+ click.echo(f"{idx}. {template.label}")
43
+
44
+ choice = click.prompt(
45
+ f"Please select a template number (1-{len(templates)})", type=click.IntRange(1, len(templates))
46
+ )
47
+ selected_template = cast(WorkflowSandboxExample, templates[choice - 1])
48
+
49
+ click.echo(click.style(f"\nYou selected: {selected_template.label}\n", bold=True, fg="cyan"))
50
+
51
+ # Create workflow config with module name from template label
52
+ workflow_config = WorkflowConfig(
53
+ workflow_sandbox_id=selected_template.id,
54
+ module=snake_case(selected_template.label), # Set module name directly from template
55
+ )
56
+ config.workflows.append(workflow_config)
57
+
58
+ workflow_config_result = WorkflowConfigResolutionResult(
59
+ workflow_config=workflow_config,
60
+ pk=selected_template.id,
61
+ )
62
+
63
+ pk = workflow_config_result.pk
64
+ if not pk:
65
+ raise ValueError("No workflow sandbox ID found in project to pull from.")
66
+
67
+ target_dir = os.path.join(os.getcwd(), workflow_config.module)
68
+ if os.path.exists(target_dir):
69
+ click.echo(click.style(f"{target_dir} already exists.", fg="red"))
70
+ return
71
+
72
+ logger.info(f"Pulling workflow into {workflow_config.module}...")
73
+
74
+ query_parameters = {
75
+ "include_sandbox": True,
76
+ }
77
+
78
+ response = client.workflows.pull(
79
+ pk,
80
+ request_options={"additional_query_parameters": query_parameters},
81
+ )
82
+
83
+ zip_bytes = b"".join(response)
84
+ zip_buffer = io.BytesIO(zip_bytes)
85
+
86
+ error_content = ""
87
+
88
+ with zipfile.ZipFile(zip_buffer) as zip_file:
89
+ if METADATA_FILE_NAME in zip_file.namelist():
90
+ metadata_json: Optional[dict] = None
91
+ with zip_file.open(METADATA_FILE_NAME) as source:
92
+ metadata_json = json.load(source)
93
+
94
+ pull_contents_metadata = PullContentsMetadata.model_validate(metadata_json)
95
+
96
+ if pull_contents_metadata.runner_config:
97
+ workflow_config.container_image_name = pull_contents_metadata.runner_config.container_image_name
98
+ workflow_config.container_image_tag = pull_contents_metadata.runner_config.container_image_tag
99
+ if workflow_config.container_image_name and not workflow_config.container_image_tag:
100
+ workflow_config.container_image_tag = "latest"
101
+
102
+ if not workflow_config.module and pull_contents_metadata.label:
103
+ workflow_config.module = snake_case(pull_contents_metadata.label)
104
+
105
+ if not workflow_config.module:
106
+ raise ValueError(f"Failed to resolve a module name for Workflow {pk}")
107
+
108
+ for file_name in zip_file.namelist():
109
+ with zip_file.open(file_name) as source:
110
+ content = source.read().decode("utf-8")
111
+ if file_name == ERROR_LOG_FILE_NAME:
112
+ error_content = content
113
+ continue
114
+ if file_name == METADATA_FILE_NAME:
115
+ continue
116
+
117
+ target_file = os.path.join(target_dir, file_name)
118
+ os.makedirs(os.path.dirname(target_file), exist_ok=True)
119
+ with open(target_file, "w") as target:
120
+ logger.info(f"Writing to {target_file}...")
121
+ target.write(content)
122
+
123
+ config.save()
124
+
125
+ if error_content:
126
+ logger.error(error_content)
127
+ else:
128
+ logger.info(f"Successfully pulled Workflow into {target_dir}")
vellum_cli/pull.py CHANGED
@@ -108,6 +108,7 @@ def pull_command(
108
108
  exclude_code: Optional[bool] = None,
109
109
  strict: Optional[bool] = None,
110
110
  include_sandbox: Optional[bool] = None,
111
+ target_directory: Optional[str] = None,
111
112
  ) -> None:
112
113
  load_dotenv()
113
114
  logger = load_cli_logger()
@@ -129,7 +130,7 @@ def pull_command(
129
130
  raise ValueError("No workflow sandbox ID found in project to pull from.")
130
131
 
131
132
  if workflow_config.module:
132
- logger.info(f"Pulling workflow into {workflow_config.module}...")
133
+ logger.info(f"Pulling workflow {workflow_config.module}...")
133
134
  else:
134
135
  logger.info(f"Pulling workflow from {pk}...")
135
136
 
@@ -175,7 +176,9 @@ def pull_command(
175
176
  if not workflow_config.module:
176
177
  raise ValueError(f"Failed to resolve a module name for Workflow {pk}")
177
178
 
178
- target_dir = os.path.join(os.getcwd(), *workflow_config.module.split("."))
179
+ # Use target_directory if provided, otherwise use current working directory
180
+ base_dir = os.path.join(os.getcwd(), target_directory) if target_directory else os.getcwd()
181
+ target_dir = os.path.join(base_dir, *workflow_config.module.split("."))
179
182
 
180
183
  # Delete files in target_dir that aren't in the zip file
181
184
  if os.path.exists(target_dir):
@@ -233,4 +236,4 @@ Its schema should be considered unstable and subject to change at any time."""
233
236
  if error_content:
234
237
  logger.error(error_content)
235
238
  else:
236
- logger.info(f"Successfully pulled Workflow into {workflow_config.module}")
239
+ logger.info(f"Successfully pulled Workflow into {target_dir}")
@@ -0,0 +1,355 @@
1
+ import pytest
2
+ import io
3
+ import json
4
+ import os
5
+ from unittest.mock import patch
6
+ import zipfile
7
+
8
+ from click.testing import CliRunner
9
+ from pydash import snake_case
10
+
11
+ from vellum_cli import main as cli_main
12
+
13
+
14
+ def _zip_file_map(file_map: dict[str, str]) -> bytes:
15
+ # Create an in-memory bytes buffer to store the zip
16
+ zip_buffer = io.BytesIO()
17
+
18
+ # Create zip file and add files from file_map
19
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
20
+ for filename, content in file_map.items():
21
+ zip_file.writestr(filename, content)
22
+
23
+ # Get the bytes from the buffer
24
+ zip_bytes = zip_buffer.getvalue()
25
+ zip_buffer.close()
26
+
27
+ return zip_bytes
28
+
29
+
30
+ class MockTemplate:
31
+ def __init__(self, id, label):
32
+ self.id = id
33
+ self.label = label
34
+
35
+
36
+ @pytest.mark.parametrize(
37
+ "base_command",
38
+ [
39
+ ["workflows", "init"],
40
+ ],
41
+ ids=["workflows_init"],
42
+ )
43
+ def test_init_command(vellum_client, mock_module, base_command):
44
+ # GIVEN a module on the user's filesystem
45
+ temp_dir = mock_module.temp_dir
46
+ mock_module.set_pyproject_toml({"workflows": []})
47
+ # GIVEN the vellum client returns a list of template workflows
48
+ fake_templates = [
49
+ MockTemplate(id="template-1", label="Example Workflow"),
50
+ MockTemplate(id="template-2", label="Another Workflow"),
51
+ ]
52
+ vellum_client.workflow_sandboxes.list_workflow_sandbox_examples.return_value.results = fake_templates
53
+
54
+ # AND the workflow pull API call returns a zip file
55
+ vellum_client.workflows.pull.return_value = iter(
56
+ [
57
+ _zip_file_map(
58
+ {
59
+ "workflow.py": "print('hello')",
60
+ }
61
+ )
62
+ ]
63
+ )
64
+ # WHEN the user runs the `init` command and selects the first template
65
+ runner = CliRunner()
66
+ result = runner.invoke(cli_main, base_command, input="1\n")
67
+
68
+ # THEN the command returns successfully
69
+ assert result.exit_code == 0
70
+
71
+ # AND `vellum_client.workflows.pull` is called with the selected template ID
72
+ vellum_client.workflows.pull.assert_called_once_with(
73
+ "template-1",
74
+ request_options={"additional_query_parameters": {"include_sandbox": True}},
75
+ )
76
+
77
+ # AND the `workflow.py` file should be created in the correct module directory
78
+ workflow_py = os.path.join(temp_dir, "example_workflow", "workflow.py")
79
+ assert os.path.exists(workflow_py)
80
+ with open(workflow_py) as f:
81
+ assert f.read() == "print('hello')"
82
+
83
+ # AND the vellum.lock.json file should be created
84
+ vellum_lock_json = os.path.join(temp_dir, "vellum.lock.json")
85
+ assert os.path.exists(vellum_lock_json)
86
+ with open(vellum_lock_json) as f:
87
+ lock_data = json.load(f)
88
+ assert lock_data["workflows"] == [
89
+ {
90
+ "module": "example_workflow",
91
+ "workflow_sandbox_id": "template-1",
92
+ "ignore": None,
93
+ "deployments": [],
94
+ "container_image_name": None,
95
+ "container_image_tag": None,
96
+ "workspace": "default",
97
+ }
98
+ ]
99
+
100
+
101
+ @pytest.mark.parametrize(
102
+ "base_command",
103
+ [
104
+ ["workflows", "init"],
105
+ ],
106
+ ids=["workflows_init"],
107
+ )
108
+ def test_init_command__invalid_template_id(vellum_client, mock_module, base_command):
109
+ # GIVEN a module on the user's filesystem
110
+ temp_dir = mock_module.temp_dir
111
+ mock_module.set_pyproject_toml({"workflows": []})
112
+ # GIVEN the vellum client returns a list of template workflows
113
+ fake_templates = [
114
+ MockTemplate(id="template-1", label="Example Workflow"),
115
+ MockTemplate(id="template-2", label="Another Workflow"),
116
+ ]
117
+ vellum_client.workflow_sandboxes.list_workflow_sandbox_examples.return_value.results = fake_templates
118
+
119
+ # WHEN the user runs the `init` command, enters invalid input and then cancels
120
+ runner = CliRunner()
121
+ # Mock click.prompt to raise a KeyboardInterrupt (simulating Ctrl+C)
122
+ with patch("click.prompt", side_effect=KeyboardInterrupt):
123
+ runner = CliRunner()
124
+ result = runner.invoke(cli_main, base_command)
125
+
126
+ # THEN the command is aborted
127
+ assert result.exit_code != 0
128
+ assert "Aborted!" in result.output # Click shows this message on Ctrl+C
129
+
130
+ # AND `vellum_client.workflows.pull` is not called
131
+ vellum_client.workflows.pull.assert_not_called()
132
+
133
+ # AND no workflow files are created
134
+ workflow_py = os.path.join(temp_dir, "example_workflow", "workflow.py")
135
+ assert not os.path.exists(workflow_py)
136
+
137
+ # AND the lock file remains empty
138
+ vellum_lock_json = os.path.join(temp_dir, "vellum.lock.json")
139
+ if os.path.exists(vellum_lock_json):
140
+ with open(vellum_lock_json) as f:
141
+ lock_data = json.load(f)
142
+ assert lock_data["workflows"] == []
143
+
144
+
145
+ @pytest.mark.parametrize(
146
+ "base_command",
147
+ [
148
+ ["workflows", "init"],
149
+ ],
150
+ ids=["workflows_init"],
151
+ )
152
+ def test_init_command__no_templates(vellum_client, mock_module, base_command):
153
+ # GIVEN a module on the user's filesystem
154
+ temp_dir = mock_module.temp_dir
155
+ mock_module.set_pyproject_toml({"workflows": []})
156
+ # GIVEN the vellum client returns no template workflows
157
+ vellum_client.workflow_sandboxes.list_workflow_sandbox_examples.return_value.results = []
158
+
159
+ # WHEN the user runs the `init` command
160
+ runner = CliRunner()
161
+ result = runner.invoke(cli_main, base_command)
162
+
163
+ # THEN the command gracefully exits
164
+ assert result.exit_code == 0
165
+ assert "No templates available" in result.output
166
+
167
+ # AND `vellum_client.workflows.pull` is not called
168
+ vellum_client.workflows.pull.assert_not_called()
169
+
170
+ # AND no workflow files are created
171
+ workflow_py = os.path.join(temp_dir, "example_workflow", "workflow.py")
172
+ assert not os.path.exists(workflow_py)
173
+
174
+ # AND the lock file remains empty
175
+ vellum_lock_json = os.path.join(temp_dir, "vellum.lock.json")
176
+ if os.path.exists(vellum_lock_json):
177
+ with open(vellum_lock_json) as f:
178
+ lock_data = json.load(f)
179
+ assert lock_data["workflows"] == []
180
+
181
+
182
+ @pytest.mark.parametrize(
183
+ "base_command",
184
+ [
185
+ ["workflows", "init"],
186
+ ],
187
+ ids=["workflows_init"],
188
+ )
189
+ def test_init_command_target_directory_exists(vellum_client, mock_module, base_command):
190
+ """
191
+ GIVEN a target directory already exists
192
+ WHEN the user tries to run the `init` command
193
+ THEN the command should fail and exit without modifying existing files
194
+ """
195
+ temp_dir = mock_module.temp_dir
196
+ existing_workflow_dir = os.path.join(temp_dir, "example_workflow")
197
+
198
+ # Create the target directory to simulate it already existing
199
+ os.makedirs(existing_workflow_dir, exist_ok=True)
200
+
201
+ # Ensure directory exists before command execution
202
+ assert os.path.exists(existing_workflow_dir)
203
+
204
+ # GIVEN the vellum client returns a list of template workflows
205
+ fake_templates = [
206
+ MockTemplate(id="template-1", label="Example Workflow"),
207
+ ]
208
+ vellum_client.workflow_sandboxes.list_workflow_sandbox_examples.return_value.results = fake_templates
209
+
210
+ # AND the workflow pull API call returns a zip file
211
+ vellum_client.workflows.pull.return_value = iter(
212
+ [
213
+ _zip_file_map(
214
+ {
215
+ "workflow.py": "print('hello')",
216
+ }
217
+ )
218
+ ]
219
+ )
220
+
221
+ # WHEN the user runs the `init` command and selects the template
222
+ runner = CliRunner()
223
+ result = runner.invoke(cli_main, base_command, input="1\n")
224
+
225
+ # THEN the command should detect the existing directory and abort
226
+ assert result.exit_code == 0
227
+ assert f"{existing_workflow_dir} already exists." in result.output
228
+
229
+ # Ensure the directory still exists (wasn't deleted or modified)
230
+ assert os.path.exists(existing_workflow_dir)
231
+
232
+ # AND `vellum_client.workflows.pull` is not called
233
+ vellum_client.workflows.pull.assert_not_called()
234
+
235
+ # AND no workflow files are created
236
+ workflow_py = os.path.join(temp_dir, "example_workflow", "workflow.py")
237
+ assert not os.path.exists(workflow_py)
238
+
239
+ # AND the lock file remains empty
240
+ vellum_lock_json = os.path.join(temp_dir, "vellum.lock.json")
241
+ if os.path.exists(vellum_lock_json):
242
+ with open(vellum_lock_json) as f:
243
+ lock_data = json.load(f)
244
+ assert lock_data["workflows"] == []
245
+
246
+
247
+ @pytest.mark.parametrize(
248
+ "base_command",
249
+ [
250
+ ["workflows", "init"],
251
+ ],
252
+ ids=["workflows_init"],
253
+ )
254
+ def test_init_command_with_template_name(vellum_client, mock_module, base_command):
255
+ # GIVEN a module on the user's filesystem
256
+ temp_dir = mock_module.temp_dir
257
+ mock_module.set_pyproject_toml({"workflows": []})
258
+
259
+ # GIVEN the vellum client returns a list of template workflows
260
+ fake_templates = [
261
+ MockTemplate(id="template-1", label="Example Workflow"),
262
+ MockTemplate(id="template-2", label="Another Workflow"),
263
+ ]
264
+ vellum_client.workflow_sandboxes.list_workflow_sandbox_examples.return_value.results = fake_templates
265
+
266
+ # AND the workflow pull API call returns a zip file
267
+ vellum_client.workflows.pull.return_value = iter(
268
+ [_zip_file_map({"workflow.py": "print('hello')", "README.md": "# Another Workflow\nThis is a test template."})]
269
+ )
270
+
271
+ # WHEN the user runs the `init` command with a specific template name
272
+ template_name = snake_case("Another Workflow")
273
+ runner = CliRunner()
274
+ result = runner.invoke(cli_main, base_command + [template_name])
275
+
276
+ # THEN the command returns successfully
277
+ assert result.exit_code == 0
278
+
279
+ # AND `vellum_client.workflows.pull` is called with the correct template ID
280
+ vellum_client.workflows.pull.assert_called_once_with(
281
+ "template-2", # ID of "Another Workflow"
282
+ request_options={"additional_query_parameters": {"include_sandbox": True}},
283
+ )
284
+
285
+ # AND the workflow files should be created in the correct module directory
286
+ workflow_py = os.path.join(temp_dir, "another_workflow", "workflow.py")
287
+
288
+ assert os.path.exists(workflow_py)
289
+
290
+ with open(workflow_py) as f:
291
+ assert f.read() == "print('hello')"
292
+
293
+ # AND the vellum.lock.json file should be created with the correct data
294
+ vellum_lock_json = os.path.join(temp_dir, "vellum.lock.json")
295
+ assert os.path.exists(vellum_lock_json)
296
+
297
+ with open(vellum_lock_json) as f:
298
+ lock_data = json.load(f)
299
+ assert lock_data["workflows"] == [
300
+ {
301
+ "module": "another_workflow",
302
+ "workflow_sandbox_id": "template-2",
303
+ "ignore": None,
304
+ "deployments": [],
305
+ "container_image_name": None,
306
+ "container_image_tag": None,
307
+ "workspace": "default",
308
+ }
309
+ ]
310
+
311
+
312
+ @pytest.mark.parametrize(
313
+ "base_command",
314
+ [
315
+ ["workflows", "init"],
316
+ ],
317
+ ids=["workflows_init"],
318
+ )
319
+ def test_init_command_with_nonexistent_template_name(vellum_client, mock_module, base_command):
320
+ # GIVEN a module on the user's filesystem
321
+ temp_dir = mock_module.temp_dir
322
+ mock_module.set_pyproject_toml({"workflows": []})
323
+
324
+ # GIVEN the vellum client returns a list of template workflows
325
+ fake_templates = [
326
+ MockTemplate(id="template-1", label="Example Workflow"),
327
+ MockTemplate(id="template-2", label="Another Workflow"),
328
+ ]
329
+ vellum_client.workflow_sandboxes.list_workflow_sandbox_examples.return_value.results = fake_templates
330
+
331
+ # WHEN the user runs the `init` command with a non-existent template name
332
+ nonexistent_template = "nonexistent_template"
333
+ runner = CliRunner()
334
+ result = runner.invoke(cli_main, base_command + [nonexistent_template])
335
+
336
+ # THEN the command should indicate the template was not found
337
+ assert result.exit_code == 0
338
+ assert f"Template {nonexistent_template} not found" in result.output
339
+
340
+ # AND `vellum_client.workflows.pull` is not called
341
+ vellum_client.workflows.pull.assert_not_called()
342
+
343
+ # AND no workflow files are created
344
+ example_workflow_dir = os.path.join(temp_dir, "example_workflow")
345
+ nonexistent_workflow_dir = os.path.join(temp_dir, nonexistent_template)
346
+
347
+ assert not os.path.exists(example_workflow_dir)
348
+ assert not os.path.exists(nonexistent_workflow_dir)
349
+
350
+ # AND the lock file remains empty
351
+ vellum_lock_json = os.path.join(temp_dir, "vellum.lock.json")
352
+ if os.path.exists(vellum_lock_json):
353
+ with open(vellum_lock_json) as f:
354
+ lock_data = json.load(f)
355
+ assert lock_data["workflows"] == []