Skip to content

Commit e586b00

Browse files
authored
MRG: Merge pull request #468 from octue/enhancement/improve-service-compatibility-checks
Ensure outputs of analyses are always validated against the twine
2 parents adef26e + 5b7b992 commit e586b00

File tree

11 files changed

+69
-25
lines changed

11 files changed

+69
-25
lines changed

octue/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ def run(service_config, input_dir, output_file, output_manifest_file, monitor_me
152152
handle_monitor_message=monitor_message_handler,
153153
)
154154

155-
click.echo(analysis.output_values)
155+
click.echo(json.dumps(analysis.output_values))
156156

157157
if analysis.output_values and output_file:
158158
if not os.path.exists(os.path.dirname(output_file)):

octue/resources/analysis.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,18 @@ def __init__(self, twine, handle_monitor_message=None, **kwargs):
8585
self.output_location = kwargs.pop("output_location", None)
8686

8787
self._calculate_strand_hashes(strands=strand_kwargs)
88+
self._finalised = False
8889
super().__init__(**kwargs)
8990

91+
@property
92+
def finalised(self):
93+
"""Check whether the analysis has been finalised (i.e. whether its outputs have been validated and, if an output
94+
manifest is produced, its datasets uploaded).
95+
96+
:return bool:
97+
"""
98+
return self._finalised
99+
90100
def send_monitor_message(self, data):
91101
"""Send a monitor message to the parent that requested the analysis.
92102
@@ -120,6 +130,7 @@ def finalise(self, upload_output_datasets_to=None):
120130
serialised_strands["output_manifest"] = self.output_manifest.to_primitive()
121131

122132
self.twine.validate(**serialised_strands)
133+
self._finalised = True
123134
logger.info("Validated output values and output manifest against the twine.")
124135

125136
if not (upload_output_datasets_to and hasattr(self, "output_manifest")):

octue/runner.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,9 @@ def run(
189189
logger.error(str(e))
190190
raise e
191191

192+
if not analysis.finalised:
193+
analysis.finalise()
194+
192195
return analysis
193196

194197
def _populate_environment_with_google_cloud_secrets(self):

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "octue"
3-
version = "0.29.5"
3+
version = "0.29.6"
44
description = "A package providing template applications for data services, and a python SDK to the Octue API."
55
readme = "README.md"
66
authors = ["Marcus Lugg <[email protected]>", "Thomas Clark <[email protected]>"]

tests/cloud/pub_sub/test_service.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ def mock_app(analysis):
4747
"input_values_schema": {
4848
"type": "object",
4949
"required": []
50-
}
50+
},
51+
"output_values_schema": {}
5152
}
5253
"""
5354

tests/test_app_modules/app_class/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
class App:
2-
"""A mock app.
2+
"""A mock app that doesn't call `self.analysis.finalise`.
33
44
:param octue.resources.analysis.Analysis analysis:
55
:return None:

tests/test_app_modules/app_module/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ def run(analysis):
44
:param octue.resources.analysis.Analysis analysis:
55
:return None:
66
"""
7-
analysis.output_values = [1, 2, 3, 4]
7+
analysis.output_values = {"width": 3}

tests/test_app_modules/app_module_with_output_manifest/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@ def run(analysis):
77
:param octue.resources.analysis.Analysis analysis:
88
:return None:
99
"""
10-
analysis.output_values = [1, 2, 3, 4]
10+
analysis.output_values = {"width": 3}
1111
analysis.output_manifest = Manifest()

tests/test_app_modules/app_with_monitor_message/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@ def run(analysis):
55
:return None:
66
"""
77
analysis.send_monitor_message({"status": "hello"})
8-
analysis.output_values = [1, 2, 3, 4]
8+
analysis.output_values = {"width": 3}

tests/test_cli.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,15 @@ class TestCLI(BaseTestCase):
2222
def test_version(self):
2323
"""Ensure the version command works in the CLI."""
2424
result = CliRunner().invoke(octue_cli, ["--version"])
25-
assert "version" in result.output
25+
self.assertIn("version", result.output)
2626

2727
def test_help(self):
2828
"""Ensure the help commands works in the CLI."""
2929
help_result = CliRunner().invoke(octue_cli, ["--help"])
30-
assert help_result.output.startswith("Usage")
30+
self.assertTrue(help_result.output.startswith("Usage"))
3131

3232
h_result = CliRunner().invoke(octue_cli, ["-h"])
33-
assert help_result.output == h_result.output
33+
self.assertEqual(help_result.output, h_result.output)
3434

3535

3636
class TestRunCommand(BaseTestCase):
@@ -55,7 +55,7 @@ def test_run(self):
5555
],
5656
)
5757

58-
assert json.dumps([1, 2, 3, 4]) in result.output
58+
self.assertIn(json.dumps({"width": 3}), result.output)
5959

6060
def test_run_with_output_values_file(self):
6161
"""Test that the `run` CLI command runs the given service and stores the output values in a file if the `-o`
@@ -74,20 +74,19 @@ def test_run_with_output_values_file(self):
7474
)
7575

7676
with open(temporary_file.name) as f:
77-
self.assertEqual(json.load(f), [1, 2, 3, 4])
77+
self.assertEqual(json.load(f), {"width": 3})
7878

79-
assert json.dumps([1, 2, 3, 4]) in result.output
79+
self.assertIn(json.dumps({"width": 3}), result.output)
8080

8181
def test_run_with_output_manifest(self):
8282
"""Test that the `run` CLI command runs the given service and stores the output manifest in a file."""
8383
mock_configurations = (
8484
ServiceConfiguration(
8585
name="test-app",
8686
app_source_path=os.path.join(TESTS_DIR, "test_app_modules", "app_module_with_output_manifest"),
87-
twine_path=TWINE_FILE_PATH,
88-
app_configuration_path="blah.json",
87+
twine_path={"input_values_schema": {}, "output_manifest": {"datasets": {}}, "output_values_schema": {}},
8988
),
90-
AppConfiguration(configuration_values={"n_iterations": 5}),
89+
AppConfiguration(),
9190
)
9291

9392
with tempfile.NamedTemporaryFile(delete=False) as temporary_file:
@@ -104,7 +103,7 @@ def test_run_with_output_manifest(self):
104103
with open(temporary_file.name) as f:
105104
self.assertIn("datasets", json.load(f))
106105

107-
assert json.dumps([1, 2, 3, 4]) in result.output
106+
self.assertIn(json.dumps({"width": 3}), result.output)
108107

109108
def test_run_with_monitor_messages_sent_to_file(self):
110109
"""Test that, when the `--monitor-messages-file` is provided, any monitor messages are written to it."""
@@ -132,7 +131,7 @@ def test_run_with_monitor_messages_sent_to_file(self):
132131
with open(monitor_messages_file.name) as f:
133132
self.assertEqual(json.load(f), [{"status": "hello"}])
134133

135-
assert json.dumps([1, 2, 3, 4]) in result.output
134+
self.assertIn(json.dumps({"width": 3}), result.output)
136135

137136
def test_remote_logger_uri_can_be_set(self):
138137
"""Test that remote logger URI can be set via the CLI and that this is logged locally."""

tests/test_runner.py

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -225,18 +225,45 @@ def test_invalid_app_directory(self):
225225

226226
def test_app_can_be_provided_as_a_class(self):
227227
"""Test that apps can be written and provided as a class."""
228-
analysis = Runner(app_src=App, twine="{}").run()
228+
analysis = Runner(app_src=App, twine={"output_values_schema": {}}).run()
229229
self.assertEqual(analysis.output_values, "App as a class works!")
230230

231231
def test_app_can_be_provided_as_path_to_module_containing_class_named_app(self):
232232
"""Test that apps can be provided as a path to a module containing a class named "App"."""
233-
analysis = Runner(app_src=os.path.join(TESTS_DIR, "test_app_modules", "app_class"), twine="{}").run()
233+
analysis = Runner(
234+
app_src=os.path.join(TESTS_DIR, "test_app_modules", "app_class"),
235+
twine={"output_values_schema": {}},
236+
).run()
237+
234238
self.assertEqual(analysis.output_values, "App as a class works!")
235239

236240
def test_app_can_be_provided_as_a_module_containing_function_named_run(self):
237241
"""Test that apps can be provided as a module containing a function named "run"."""
238-
analysis = Runner(app_src=app, twine="{}").run()
239-
self.assertEqual(analysis.output_values, [1, 2, 3, 4])
242+
analysis = Runner(app_src=app, twine={"output_values_schema": {}}).run()
243+
self.assertEqual(analysis.output_values, {"width": 3})
244+
245+
def test_analysis_finalised_by_runner_if_not_finalised_in_app(self):
246+
"""Test that analyses are finalised automatically if they're not finalised within their app."""
247+
analysis = Runner(app_src=App, twine={"output_values_schema": {}}).run()
248+
self.assertTrue(analysis.finalised)
249+
250+
def test_analysis_not_re_finalised_by_runner_if_finalised_in_app(self):
251+
"""Test that the `Analysis.finalise` method is not called again if an analysis has already been finalised."""
252+
253+
def app(analysis):
254+
analysis.output_values = {"hello": "world"}
255+
256+
self.assertFalse(analysis.finalised)
257+
258+
# Simulate the analysis being finalised while still being able to mock `Analysis.finalise` to count how
259+
# many times it's been called.
260+
analysis._finalised = True
261+
262+
with patch("octue.resources.analysis.Analysis.finalise") as mock_finalise:
263+
analysis = Runner(app_src=app, twine={"output_values_schema": {}}).run()
264+
265+
self.assertEqual(mock_finalise.call_count, 0)
266+
self.assertTrue(analysis.finalised)
240267

241268

242269
class TestRunnerWithRequiredDatasetFileTags(BaseTestCase):
@@ -272,7 +299,8 @@ class TestRunnerWithRequiredDatasetFileTags(BaseTestCase):
272299
},
273300
}
274301
}
275-
}
302+
},
303+
"output_values_schema": {},
276304
}
277305
)
278306

@@ -355,7 +383,8 @@ def test_validate_input_manifest_with_required_tags_for_remote_tag_template_sche
355383
"file_tags_template": {"$ref": schema_url},
356384
}
357385
}
358-
}
386+
},
387+
"output_values_schema": {},
359388
}
360389

361390
remote_schema = {
@@ -412,7 +441,8 @@ def test_validate_input_manifest_with_required_tags_in_several_datasets(self):
412441
}
413442
},
414443
}
415-
}
444+
},
445+
"output_values_schema": {},
416446
}
417447

418448
with tempfile.TemporaryDirectory() as temporary_directory:

0 commit comments

Comments
 (0)