Skip to content

Commit 3e05219

Browse files
authored
Bugfix issue 144 (#148)
* updated format for passing workflow prompt variables * code formatting * updated documentation * added latest bugfix * test prompts passed to run_workflow_definition * fixed issue with pre-epoch dates on Windows
1 parent ea1036e commit 3e05219

File tree

4 files changed

+182
-39
lines changed

4 files changed

+182
-39
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ Unreleased
33
**Improvements**
44
- `folders.get_folder()` can now handle folder paths and delegates (e.g. @public).
55

6+
**Bugfixes**
7+
- Fixed an issue with `model_management.execute_model_workflow_definition()` where input values for
8+
workflow prompts were not correctly submitted. Note that the `input=` parameter was renamed to
9+
`prompts=` to avoid conflicting with the built-in `input()`.
10+
611
v1.8.1 (2023-01-19)
712
----------
813
**Changes**

src/sasctl/_services/model_management.py

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -311,15 +311,13 @@ def list_model_workflow_definition(cls):
311311
312312
Returns
313313
-------
314-
RestObj
314+
list of RestObj
315315
The list of workflows
316316
317317
"""
318318
from .workflow import Workflow
319319

320-
wf = Workflow()
321-
322-
return wf.list_enabled_definitions()
320+
return Workflow.list_enabled_definitions()
323321

324322
@classmethod
325323
@experimental
@@ -339,9 +337,7 @@ def list_model_workflow_prompt(cls, workflowName):
339337
"""
340338
from .workflow import Workflow
341339

342-
wf = Workflow()
343-
344-
return wf.list_workflow_prompt(workflowName)
340+
return Workflow.list_workflow_prompt(workflowName)
345341

346342
@classmethod
347343
@experimental
@@ -373,7 +369,9 @@ def list_model_workflow_executed(cls, projectName):
373369

374370
@classmethod
375371
@experimental
376-
def execute_model_workflow_definition(cls, project_name, workflow_name, input=None):
372+
def execute_model_workflow_definition(
373+
cls, project_name, workflow_name, prompts=None
374+
):
377375
"""Runs specific Workflow Processes Definitions.
378376
379377
Parameters
@@ -382,29 +380,31 @@ def execute_model_workflow_definition(cls, project_name, workflow_name, input=No
382380
Name of the Project that will execute workflow
383381
workflow_name : str
384382
Name or ID of an enabled workflow to execute
385-
input : dict, optional
386-
Input values for the workflow for initial workflow prompt
383+
prompts : dict, optional
384+
Input values to provide for the initial workflow prompts. Should be
385+
specified as name:value pairs.
387386
388387
Returns
389388
-------
390389
RestObj
391390
The executing workflow
392391
392+
.. versionchanged:: 1.8.2
393+
Renamed the `input` parameter to `prompts`.
394+
393395
"""
394396
from .model_repository import ModelRepository
395397
from .workflow import Workflow
396398

397399
mr = ModelRepository()
398-
wf = Workflow()
399400

400401
project = mr.get_project(project_name)
401402

402-
workflow = wf.run_workflow_definition(workflow_name, input=input)
403-
404-
# Associations running workflow to model project, note workflow has to be running
405-
# THINK ABOUT: do we do a check on status of the workflow to determine if it is still running before associating?
403+
workflow = Workflow.run_workflow_definition(workflow_name, prompts=prompts)
406404

407-
input = {
405+
# Associate running workflow to model project.
406+
# NOTE: workflow has to be running
407+
data = {
408408
"processName": workflow["name"],
409409
"processId": workflow["id"],
410410
"objectType": "MM_Project",
@@ -414,14 +414,15 @@ def execute_model_workflow_definition(cls, project_name, workflow_name, input=No
414414
"solutionObjectMediaType": "application/vnd.sas.models.project+json",
415415
}
416416

417-
# Note, you can get a HTTP Error 404: {"errorCode":74052,"message":"The workflow process for id <> cannot be found.
418-
# Associations can only be made to running processes.","details":["correlator:
419-
# e62c5562-2b11-45db-bcb7-933200cb0f0a","traceId: 3118c0fb1eb9702d","path:
420-
# /modelManagement/workflowAssociations"],"links":[],"version":2,"httpStatusCode":404}
417+
# Note: you can get a HTTP Error 404:
418+
# {"errorCode":74052,"message":"The workflow process for id <> cannot be found.
419+
# Associations can only be made to running processes.","details":["correlator:
420+
# e62c5562-2b11-45db-bcb7-933200cb0f0a","traceId: 3118c0fb1eb9702d","path:
421+
# /modelManagement/workflowAssociations"],"links":[],"version":2,"httpStatusCode":404}
421422
# Which is fine and expected like the Visual Experience.
422423
return cls.post(
423424
"/workflowAssociations",
424-
json=input,
425+
json=data,
425426
headers={
426427
"Content-Type": "application/vnd.sas.workflow.object.association+json"
427428
},

src/sasctl/_services/workflow.py

Lines changed: 80 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,30 @@
33
#
44
# Copyright © 2019, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
55
# SPDX-License-Identifier: Apache-2.0
6+
import datetime
67

8+
from ..core import PagedItemIterator
79
from .service import Service
810

911

1012
class Workflow(Service):
1113
"""The Workflow API provides basic resources for list, prompt details,
1214
and running workflow processes.
15+
16+
Warnings
17+
--------
18+
Note that this service is intentionally not included in the sasctl documentation.
19+
As of 2022.09 this service is not publicly documented on developer.sas.com and is
20+
only partially implemented here in order to provide functionality for the Model
21+
Manager service. All methods included in this service should be treated as
22+
experimental and are subject to change without notice.
23+
1324
"""
1425

1526
_SERVICE_ROOT = "/workflow"
1627

17-
def list_definitions(self, include_enabled=True, include_disabled=False):
28+
@classmethod
29+
def list_definitions(cls, include_enabled=True, include_disabled=False):
1830
"""List workflow definitions.
1931
2032
Parameters
@@ -40,9 +52,16 @@ def list_definitions(self, include_enabled=True, include_disabled=False):
4052
return []
4153

4254
# Header required to prevent 400 ERROR Bad Request
43-
return self.get(url, headers={"Accept-Language": "en-US"})
55+
results = cls.get(url, headers={"Accept-Language": "en-US"})
4456

45-
def list_enabled_definitions(self):
57+
if results is None:
58+
return []
59+
if isinstance(results, (list, PagedItemIterator)):
60+
return results
61+
return [results]
62+
63+
@classmethod
64+
def list_enabled_definitions(cls):
4665
"""List process definitions that are currently enabled.
4766
4867
Returns
@@ -51,9 +70,10 @@ def list_enabled_definitions(self):
5170
The list of definitions.
5271
5372
"""
54-
return self.list_definitions(include_enabled=True, include_disabled=False)
73+
return cls.list_definitions(include_enabled=True, include_disabled=False)
5574

56-
def list_workflow_prompt(self, name):
75+
@classmethod
76+
def list_workflow_prompt(cls, name):
5777
"""List prompt Workflow Processes Definitions.
5878
5979
Parameters
@@ -68,7 +88,7 @@ def list_workflow_prompt(self, name):
6888
6989
"""
7090

71-
ret = self._find_specific_workflow(name)
91+
ret = cls._find_specific_workflow(name)
7292
if ret is None:
7393
raise ValueError("No Workflow enabled for %s name or id." % name)
7494

@@ -78,44 +98,86 @@ def list_workflow_prompt(self, name):
7898
# No prompt inputs on workflow
7999
return None
80100

81-
def run_workflow_definition(self, name, input=None):
101+
@classmethod
102+
def run_workflow_definition(cls, name, prompts=None):
82103
"""Runs specific Workflow Processes Definitions.
83104
84105
Parameters
85106
----------
86107
name : str
87108
Name or ID of an enabled workflow to execute
88-
input : dict, optional
89-
Input values for the workflow for initial workflow prompt
109+
prompts : dict, optional
110+
Input values to provide for the initial workflow prompts. Should be
111+
specified as name:value pairs.
90112
91113
Returns
92114
-------
93115
RestObj
94116
The executing workflow
95117
96118
"""
97-
98-
workflow = self._find_specific_workflow(name)
119+
workflow = cls._find_specific_workflow(name)
99120
if workflow is None:
100121
raise ValueError("No Workflow enabled for %s name or id." % name)
101122

102-
if input is None:
103-
return self.post(
123+
if prompts is None:
124+
return cls.post(
104125
"/processes?definitionId=" + workflow["id"],
105126
headers={"Content-Type": "application/vnd.sas.workflow.variables+json"},
106127
)
107-
if isinstance(input, dict):
108-
return self.post(
128+
if isinstance(prompts, dict):
129+
130+
variables = []
131+
132+
# For each prompt defined in the workflow, check if a value was provided.
133+
for prompt in workflow.prompts:
134+
if prompt["variableName"] in prompts:
135+
name = prompt["variableName"]
136+
value = prompts[name]
137+
138+
if type(value) == datetime.datetime:
139+
# NOTE: do not use isinstance() to compare types as
140+
# datetime.date will also evaluate as True.
141+
142+
# Explicitly convert to local time zone if not set.
143+
if value.tzinfo is None:
144+
try:
145+
value = value.astimezone()
146+
except OSError:
147+
# On Windows pre-1970 dates will cause an issue.
148+
# See https://bugs.python.org/issue36759
149+
pass
150+
151+
if value.tzinfo is None:
152+
# Failed to convert to local time. Have to just assume it's UTC.
153+
# Example: 2023-01-25T13:49:40.726162Z
154+
value = value.isoformat() + "Z"
155+
else:
156+
# Example: 2023-01-25T13:49:40.726162-05:00
157+
value = value.isoformat()
158+
159+
variables.append(
160+
{
161+
"name": name,
162+
"value": value,
163+
"scope": "local",
164+
"type": prompt["variableType"],
165+
"version": prompt["version"],
166+
}
167+
)
168+
169+
return cls.post(
109170
"/processes?definitionId=" + workflow["id"],
110-
json=input,
171+
json={"variables": variables},
111172
headers={"Content-Type": "application/vnd.sas.workflow.variables+json"},
112173
)
113174

114-
def _find_specific_workflow(self, name):
175+
@classmethod
176+
def _find_specific_workflow(cls, name):
115177
# Internal helper method
116178
# Finds a workflow with the name (can be a name or id)
117179
# Returns a dict objects of the workflow
118-
listendef = self.list_enabled_definitions()
180+
listendef = cls.list_enabled_definitions()
119181
for tmp in listendef:
120182
if tmp["name"] == name:
121183
return tmp

tests/unit/test_workflow.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
# Copyright © 2019, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
55
# SPDX-License-Identifier: Apache-2.0
66

7+
import datetime
78
from unittest import mock
89

910
import pytest
1011

12+
from sasctl.core import RestObj
1113
from sasctl._services import workflow
1214

1315

@@ -92,3 +94,76 @@ def test_list_workflow_prompt_workflowprompt():
9294
testresult = wf.list_workflow_prompt("98765")
9395
print(testresult)
9496
assert testresult is not None
97+
98+
99+
@mock.patch("sasctl._services.workflow.Workflow.post")
100+
@mock.patch("sasctl._services.workflow.Workflow._find_specific_workflow")
101+
def test_run_workflow_definition_no_prompts(get_workflow, post):
102+
"""Verify correct REST call to run a workflow with no inputs."""
103+
104+
DEFINITION_ID = "abc-123"
105+
106+
get_workflow.return_value = RestObj(name="Inquisition", id=DEFINITION_ID)
107+
post.return_value.status_code = 200
108+
post.return_value.json.return_value = {}
109+
110+
workflow.Workflow.run_workflow_definition("inquisition")
111+
112+
assert post.call_count == 1
113+
url, params = post.call_args
114+
assert url[0].startswith("/processes")
115+
assert DEFINITION_ID in url[0]
116+
assert "json" not in params # should not have passed any prompt info
117+
118+
119+
@mock.patch("sasctl._services.workflow.Workflow.post")
120+
@mock.patch("sasctl._services.workflow.Workflow._find_specific_workflow")
121+
def test_run_workflow_definition_with_prompts(get_workflow, post):
122+
"""Verify correct REST call and prompt formatting."""
123+
124+
# Mock response from looking up workflow information.
125+
# NOTE: needs to include prompt information to match with prompt input values.
126+
WORKFLOW = RestObj(
127+
{
128+
"name": "Inquisition",
129+
"id": "abc-123",
130+
"prompts": [
131+
{"variableName": "moto", "variableType": "string", "version": 3},
132+
{"variableName": "date", "variableType": "dateTime", "version": 1},
133+
],
134+
}
135+
)
136+
137+
PROMPTS = {
138+
"moto": "No one expects the Spanish Inquisition!",
139+
"date": datetime.datetime(1912, 1, 1, 17, 30),
140+
}
141+
142+
# Return mock workflow when asked
143+
get_workflow.return_value = WORKFLOW
144+
145+
# Return a valid HTTP response for POSTs
146+
post.return_value.status_code = 200
147+
post.return_value.json.return_value = {}
148+
149+
workflow.Workflow.run_workflow_definition("inquisition", prompts=PROMPTS)
150+
151+
assert post.call_count == 1
152+
url, params = post.call_args
153+
154+
assert url[0].startswith("/processes")
155+
assert WORKFLOW["id"] in url[0]
156+
157+
# Check each prompt value that was passed and ensure it was correctly
158+
# matched to the prompts defined by the workflow.
159+
for name, value in PROMPTS.items():
160+
161+
# Find the matching variable entry in the POST data
162+
variable = next(v for v in params["json"]["variables"] if v["name"] == name)
163+
164+
# String and datetime prompts should be passed as string values
165+
if isinstance(value, (str, datetime.datetime)):
166+
assert isinstance(variable["value"], str)
167+
168+
if isinstance(value, datetime.datetime):
169+
assert variable["type"] == "dateTime"

0 commit comments

Comments
 (0)