Skip to content

Commit 9b30779

Browse files
Merge pull request #167 from wfcommons/wfformat_1.6
Updated the code to be WfFormat-1.6-compliant
2 parents f000f3d + 8bde64e commit 9b30779

9 files changed

Lines changed: 223 additions & 38 deletions

File tree

docs/source/introduction.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,6 @@ machines on which the workflow was executed.
8080
WfFormat uses a JSON schema available in the
8181
`WfFormat Schema GitHub <https://github.com/wfcommons/WfFormat>`_ repository.
8282
The current version of the WfCommons Python package uses schema version
83-
:code:`1.5`. The schema repository provides a detailed explanation of WfFormat
83+
:code:`1.6`. The schema repository provides a detailed explanation of WfFormat
8484
(including required fields) and a validator script for verifying the
8585
compatibility of instances.

tests/unit/common/test_workflow.py

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
import pytest
1313
import requests
1414
import json
15+
import math
16+
from numbers import Real
17+
from typing import Any
1518

1619
from datetime import datetime
1720
from wfcommons.common import Task, Workflow
@@ -26,6 +29,97 @@
2629
]
2730

2831

32+
def assert_json_close(
33+
actual: Any,
34+
expected: Any,
35+
*,
36+
rel_tol: float = 1e-9,
37+
abs_tol: float = 1e-12,
38+
path: str = "$",
39+
) -> None:
40+
"""Assert that two JSON-like objects are equal, with close numerics."""
41+
42+
actual_is_number = (
43+
isinstance(actual, Real)
44+
and not isinstance(actual, bool)
45+
)
46+
expected_is_number = (
47+
isinstance(expected, Real)
48+
and not isinstance(expected, bool)
49+
)
50+
51+
if actual_is_number or expected_is_number:
52+
assert actual_is_number and expected_is_number, (
53+
f"Numeric type mismatch at {path}: "
54+
f"actual={actual!r}, expected={expected!r}"
55+
)
56+
57+
assert math.isclose(
58+
actual,
59+
expected,
60+
rel_tol=rel_tol,
61+
abs_tol=abs_tol,
62+
), (
63+
f"Numbers differ at {path}: "
64+
f"actual={actual!r}, expected={expected!r}, "
65+
f"rel_tol={rel_tol}, abs_tol={abs_tol}"
66+
)
67+
return
68+
69+
if isinstance(actual, dict) or isinstance(expected, dict):
70+
assert isinstance(actual, dict) and isinstance(expected, dict), (
71+
f"Type mismatch at {path}: "
72+
f"actual={type(actual).__name__}, "
73+
f"expected={type(expected).__name__}"
74+
)
75+
76+
assert actual.keys() == expected.keys(), (
77+
f"Dictionary keys differ at {path}: "
78+
f"actual-only={actual.keys() - expected.keys()}, "
79+
f"expected-only={expected.keys() - actual.keys()}"
80+
)
81+
82+
for key in actual:
83+
assert_json_close(
84+
actual[key],
85+
expected[key],
86+
rel_tol=rel_tol,
87+
abs_tol=abs_tol,
88+
path=f"{path}.{key}",
89+
)
90+
return
91+
92+
if isinstance(actual, list) or isinstance(expected, list):
93+
assert isinstance(actual, list) and isinstance(expected, list), (
94+
f"Type mismatch at {path}: "
95+
f"actual={type(actual).__name__}, "
96+
f"expected={type(expected).__name__}"
97+
)
98+
99+
assert len(actual) == len(expected), (
100+
f"List lengths differ at {path}: "
101+
f"actual={len(actual)}, expected={len(expected)}"
102+
)
103+
104+
for index, (actual_item, expected_item) in enumerate(
105+
zip(actual, expected)
106+
):
107+
assert_json_close(
108+
actual_item,
109+
expected_item,
110+
rel_tol=rel_tol,
111+
abs_tol=abs_tol,
112+
path=f"{path}[{index}]",
113+
)
114+
return
115+
116+
assert actual == expected, (
117+
f"Values differ at {path}: "
118+
f"actual={actual!r}, expected={expected!r}"
119+
)
120+
121+
122+
29123
class TestWorkflow:
30124

31125
@pytest.fixture
@@ -53,7 +147,11 @@ def test_workflow_creation(self, workflow: Workflow) -> None:
53147
"workflow": {
54148
"specification": {
55149
"tasks": [],
56-
"files": []
150+
"files": [],
151+
"metrics": {
152+
"numberOfTasks": 0,
153+
"numberOfFiles": 0
154+
}
57155
},
58156
"execution": {
59157
"makespanInSeconds": 100.0,
@@ -139,7 +237,9 @@ def test_workflow_json_generation(self):
139237
written_json["workflow"]["specification"]["files"] = sorted(written_json["workflow"]["specification"]["files"], key=lambda x: x['id'])
140238

141239
# Compare the two jsons!
142-
assert(original_json == written_json)
240+
# assert(original_json == written_json)
241+
# assert(original_json == pytest.approx(written_json))
242+
assert_json_close(original_json,written_json)
143243

144244
@pytest.mark.unit
145245
def test_workflow_dot_file(self):

tests/unit/wfinstances/test_instance_analyzer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def instance(self) -> pathlib.Path:
2323
"name": "workflow_test",
2424
"description": "Instance generate for WfCommons Test",
2525
"createdAt": "2020-12-30T02:19:01.238077",
26-
"schemaVersion": "1.5",
26+
"schemaVersion": "1.6",
2727
"author": {
2828
"name": "wfcommons",
2929
"email": "support@wfcommons.org"

tests/unit/wfinstances/test_instances.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def instance(self) -> pathlib.Path:
2323
"name": "workflow_test",
2424
"description": "Instance generate for WfCommons Test",
2525
"createdAt": "2020-12-30T02:19:01.238077",
26-
"schemaVersion": "1.5",
26+
"schemaVersion": "1.6",
2727
"author": {
2828
"name": "wfcommons",
2929
"email": "support@wfcommons.org"

wfcommons/common/workflow.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from ..common.task import Task, TaskType
2020
from ..version import __version__, __schema_version__
2121

22-
from ..wfchef.utils import create_graph
22+
from ..wfchef.utils import create_graph, create_graph_from_json_object
2323
import tempfile
2424

2525

@@ -210,6 +210,75 @@ def generate_json(self) -> None:
210210

211211
self.workflow_json = workflow_json
212212

213+
# Augment the workflow_json with the metrics
214+
specification_metrics, execution_metrics = self.compute_metrics(workflow_json)
215+
if specification_metrics != {}:
216+
self.workflow_json["workflow"]["specification"]["metrics"] = specification_metrics
217+
if execution_metrics != {}:
218+
self.workflow_json["workflow"]["execution"]["metrics"] = execution_metrics
219+
220+
221+
def compute_metrics(self, workflow_json: json) -> json:
222+
"""
223+
Compute workflow specification and execution metrics.
224+
:param workflow_json: Workflow instance as a JSON object.
225+
:type workflow_json: json
226+
227+
:return: specification and execution metrics.
228+
:rtype: json
229+
"""
230+
231+
# Specification metrics
232+
specification_metrics : json = {}
233+
specification_metrics["numberOfTasks"] = len(workflow_json["workflow"]["specification"]["tasks"])
234+
if "files" in workflow_json["workflow"]["specification"]:
235+
number_of_files = len(workflow_json["workflow"]["specification"]["files"])
236+
specification_metrics["numberOfFiles"] = number_of_files
237+
if number_of_files > 0:
238+
specification_metrics["sumOfFileSizesInBytes"] = sum(
239+
file.get("sizeInBytes", 0)
240+
for file in workflow_json["workflow"]["specification"]["files"]
241+
)
242+
graph = create_graph_from_json_object(workflow_json)
243+
levels = [
244+
tuple(generation)
245+
for generation in nx.topological_generations(graph)
246+
][1:-1] # Remove the fictitious SRC and DST levels
247+
248+
if len(levels) > 0:
249+
widths = [len(level) for level in levels] # Remove the fictitious SRC and DST levels
250+
251+
specification_metrics["numberOfLevels"] = len(widths)
252+
specification_metrics["minimumWidth"] = min(widths)
253+
specification_metrics["maximumWidth"] = max(widths)
254+
255+
# Execution metrics
256+
execution_metrics : json = {}
257+
if "execution" in workflow_json["workflow"]:
258+
total_runtime_in_seconds = sum(
259+
task.get("runtimeInSeconds", 0)
260+
for task in workflow_json["workflow"]["execution"]["tasks"]
261+
)
262+
if total_runtime_in_seconds > 0:
263+
execution_metrics["sumTaskRuntimesInSeconds"] = total_runtime_in_seconds
264+
265+
total_read_bytes = sum(
266+
task.get("readBytes", 0)
267+
for task in workflow_json["workflow"]["execution"]["tasks"]
268+
)
269+
if total_runtime_in_seconds > 0:
270+
execution_metrics["totalNumBytesRead"] = total_read_bytes
271+
272+
total_written_bytes = sum(
273+
task.get("writtenBytes", 0)
274+
for task in workflow_json["workflow"]["execution"]["tasks"]
275+
)
276+
if total_written_bytes > 0:
277+
execution_metrics["totalNumBytesWritten"] = total_written_bytes
278+
279+
return specification_metrics, execution_metrics
280+
281+
213282
def write_dot(self, dot_file_path: Optional[pathlib.Path] = None) -> None:
214283
"""
215284
Write a dot file of the workflow instance.
@@ -264,3 +333,8 @@ def roots(self) -> List[str]:
264333

265334
def leaves(self) -> List[str]:
266335
return [n for n,d in self.out_degree() if d==0]
336+
337+
338+
339+
340+

wfcommons/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@
99
# (at your option) any later version.
1010

1111
__version__ = "1.6-dev"
12-
__schema_version__ = "1.5"
12+
__schema_version__ = "1.6"

wfcommons/wfchef/utils.py

Lines changed: 39 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -47,42 +47,54 @@ def create_graph(path: pathlib.Path) -> nx.DiGraph:
4747
path = pathlib.Path(path)
4848
with path.open() as fp:
4949
content = json.load(fp)
50+
return create_graph_from_json_object(content)
5051

51-
graph = nx.DiGraph()
5252

53-
# Add src/dst nodes
54-
graph.add_node("SRC", label="SRC", type="SRC", id="SRC")
55-
graph.add_node("DST", label="DST", type="DST", id="DST")
53+
def create_graph_from_json_object(workflow_instance: dict) -> nx.DiGraph:
54+
"""
55+
Creates a networkX DiGraph from a JSON file in the WfFormat.
5656
57-
id_count = 0
57+
:param workflow_instance: A workflow instance as a JSON object.
58+
:type workflow_instance: json
5859
59-
for task in content["workflow"]["specification"]["tasks"]:
60+
:return: graph.
61+
:rtype: networkX DiGraph.
62+
"""
63+
graph = nx.DiGraph()
6064

61-
# specific for epigenomics -- have to think about how to do it in general
62-
if "genome-dax" in content["name"]:
63-
_type, *_ = task["name"].split("_")
64-
graph.add_node(task["name"], label=_type, type=_type, id=str(id_count))
65-
id_count += 1
66-
else:
67-
try:
68-
_type, _id = task["name"].split("_ID")
69-
except ValueError:
70-
_type, _id = task["name"].split("_0")
71-
graph.add_node(task["name"], label=_type, type=_type, id=_id)
65+
# Add src/dst nodes
66+
graph.add_node("SRC", label="SRC", type="SRC", id="SRC")
67+
graph.add_node("DST", label="DST", type="DST", id="DST")
7268

73-
for parent in task["parents"]:
74-
graph.add_edge(parent, task["name"])
69+
id_count = 0
7570

76-
for node in graph.nodes:
71+
for task in workflow_instance["workflow"]["specification"]["tasks"]:
7772

78-
if node in ["SRC", "DST"]:
79-
continue
80-
if graph.in_degree(node) <= 0:
81-
graph.add_edge("SRC", node)
82-
if graph.out_degree(node) <= 0:
83-
graph.add_edge(node, "DST")
73+
# specific for epigenomics -- have to think about how to do it in general
74+
if "genome-dax" in workflow_instance["name"]:
75+
_type, *_ = task["name"].split("_")
76+
graph.add_node(task["name"], label=_type, type=_type, id=str(id_count))
77+
id_count += 1
78+
else:
79+
try:
80+
_type, _id = task["id"].split("_ID")
81+
except ValueError:
82+
_type, _id = task["id"].split("_0")
83+
graph.add_node(task["name"], label=_type, type=_type, id=_id)
8484

85-
return graph
85+
for parent in task["parents"]:
86+
graph.add_edge(parent, task["name"])
87+
88+
for node in graph.nodes:
89+
90+
if node in ["SRC", "DST"]:
91+
continue
92+
if graph.in_degree(node) <= 0:
93+
graph.add_edge("SRC", node)
94+
if graph.out_degree(node) <= 0:
95+
graph.add_edge(node, "DST")
96+
97+
return graph
8698

8799

88100
def annotate(g: nx.DiGraph) -> None:
@@ -176,8 +188,6 @@ def draw(g: nx.DiGraph,
176188
:param subgraph: nodes that were added by replication and will be colored green.
177189
:type subgraph: Set[str].
178190
179-
180-
181191
:return: the figure and the axis used.
182192
:rtype: Tuple[plt.Figure, plt.Axes].
183193
"""

wfcommons/wfinstances/logs/makeflow.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ def _parse_workflow_file(self) -> None:
130130
elif '\t' in line:
131131
# task execution command (likely olf here)
132132
prefix = line.replace('./', '').strip().split()[1 if 'LOCAL' in line else 0]
133-
task_name = "ID{:07d}".format(task_id_counter)
133+
task_name = "{}_ID{:07d}".format(prefix, task_id_counter)
134134

135135
# create list of input and output files
136136
output_files = self._create_files(outputs, "output", task_name)

wfcommons/wfinstances/logs/taskvine.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,8 @@ def _construct_workflow(self) -> None:
276276
# Create all tasks
277277
task_map = {}
278278
for task_id in self.known_task_ids:
279-
task_name = "Task_%d" % task_id
279+
task_name = "Task_ID{:07d}".format(task_id)
280+
print(f"DEBUGHERE: {task_name} ")
280281
task = Task(name=task_name,
281282
task_id=task_name,
282283
task_type=TaskType.COMPUTE,

0 commit comments

Comments
 (0)