Skip to content

Commit aa1f9cc

Browse files
authored
MRG: Merge pull request #143 from octue/release/0.1.16
Release/0.1.16
2 parents 258f568 + 48051ca commit aa1f9cc

File tree

24 files changed

+220
-38
lines changed

24 files changed

+220
-38
lines changed

docs/source/deploying_services.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Automated deployment with Octue means:
1515

1616
All you need to enable automated deployments are the following files in your repository root:
1717

18-
* A ``requirements.txt`` file that includes ``octue>=0.1.15`` and the rest of your service's dependencies
18+
* A ``requirements.txt`` file that includes ``octue>=0.1.16`` and the rest of your service's dependencies
1919
* A ``twine.json`` file
2020
* A ``deployment_configuration.json`` file (optional)
2121

octue/cli.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ def start(app_dir, data_dir, config_dir, service_id, twine, timeout, delete_topi
224224
backend_configuration_values = runner.configuration["configuration_values"]["backend"]
225225
backend = service_backends.get_backend(backend_configuration_values.pop("name"))(**backend_configuration_values)
226226

227-
service = Service(id=service_id, backend=backend, run_function=runner.run)
227+
service = Service(service_id=service_id, backend=backend, run_function=runner.run)
228228
service.serve(timeout=timeout, delete_topic_and_subscription_on_exit=delete_topic_and_subscription_on_exit)
229229

230230

File renamed without changes.

octue/deployment/google/Dockerfile octue/cloud/deployment/google/Dockerfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,4 @@ ARG _GUNICORN_THREADS=8
2828
ENV _GUNICORN_THREADS=$_GUNICORN_THREADS
2929

3030
# Timeout is set to 0 to disable the timeouts of the workers to allow Cloud Run to handle instance scaling.
31-
CMD exec gunicorn --bind :$PORT --workers $_GUNICORN_WORKERS --threads $_GUNICORN_THREADS --timeout 0 octue.deployment.google.cloud_run:app
31+
CMD exec gunicorn --bind :$PORT --workers $_GUNICORN_WORKERS --threads $_GUNICORN_THREADS --timeout 0 octue.cloud.deployment.google.cloud_run:app
File renamed without changes.

octue/deployment/google/cloud_run.py octue/cloud/deployment/google/cloud_run.py

+14-4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from flask import Flask, request
66

77
from octue.cloud.pub_sub.service import Service
8+
from octue.exceptions import MissingServiceID
89
from octue.logging_handlers import apply_log_handler
910
from octue.resources.service_backends import GCPPubSubBackend
1011
from octue.runner import Runner
@@ -13,6 +14,10 @@
1314
logger = logging.getLogger(__name__)
1415
apply_log_handler(logger, log_level=logging.INFO)
1516

17+
18+
DEPLOYMENT_CONFIGURATION_PATH = "deployment_configuration.json"
19+
20+
1621
app = Flask(__name__)
1722

1823

@@ -64,13 +69,18 @@ def answer_question(project_name, data, question_uuid):
6469
:param str question_uuid:
6570
:return None:
6671
"""
67-
deployment_configuration_path = "deployment_configuration.json"
72+
service_id = os.environ.get("SERVICE_ID")
73+
74+
if not service_id:
75+
raise MissingServiceID(
76+
"The ID for the deployed service is missing - ensure SERVICE_ID is available as an environment variable."
77+
)
6878

6979
try:
70-
with open(deployment_configuration_path) as f:
80+
with open(DEPLOYMENT_CONFIGURATION_PATH) as f:
7181
deployment_configuration = json.load(f)
7282

73-
logger.info("Deployment configuration loaded from %r.", os.path.abspath(deployment_configuration_path))
83+
logger.info("Deployment configuration loaded from %r.", os.path.abspath(DEPLOYMENT_CONFIGURATION_PATH))
7484

7585
except FileNotFoundError:
7686
deployment_configuration = {}
@@ -91,7 +101,7 @@ def answer_question(project_name, data, question_uuid):
91101
)
92102

93103
service = Service(
94-
id=os.environ["SERVICE_ID"],
104+
service_id=service_id,
95105
backend=GCPPubSubBackend(project_name=project_name, credentials_environment_variable=None),
96106
run_function=runner.run,
97107
)

octue/cloud/pub_sub/service.py

+16-3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from octue.exceptions import FileLocationError
1414
from octue.mixins import CoolNameable
1515
from octue.resources.manifest import Manifest
16+
from octue.utils.encoders import OctueJSONEncoder
1617

1718

1819
logger = logging.getLogger(__name__)
@@ -58,10 +59,21 @@ class Service(CoolNameable):
5859
5960
Services communicate entirely via Google Pub/Sub and can ask and/or respond to questions from any other Service that
6061
has a corresponding topic on Google Pub/Sub.
62+
63+
:param octue.resources.service_backends.ServiceBackend backend:
64+
:param str|None service_id:
65+
:param callable|None run_function:
66+
:return None:
6167
"""
6268

63-
def __init__(self, backend, id=None, run_function=None):
64-
self.id = id or str(uuid.uuid4())
69+
def __init__(self, backend, service_id=None, run_function=None):
70+
if service_id is None:
71+
self.id = str(uuid.uuid4())
72+
elif not service_id:
73+
raise ValueError(f"service_id should be None or a non-falsey value; received {service_id!r} instead.")
74+
else:
75+
self.id = service_id
76+
6577
self.backend = backend
6678
self.run_function = run_function
6779

@@ -121,7 +133,8 @@ def answer(self, data, question_uuid, timeout=30):
121133
self.publisher.publish(
122134
topic=topic.path,
123135
data=json.dumps(
124-
{"output_values": analysis.output_values, "output_manifest": serialised_output_manifest}
136+
{"output_values": analysis.output_values, "output_manifest": serialised_output_manifest},
137+
cls=OctueJSONEncoder,
125138
).encode(),
126139
retry=create_custom_retry(timeout),
127140
)

octue/exceptions.py

+4
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,7 @@ class AttributeConflict(OctueSDKException):
7878
"""Raise if, when trying to set an attribute whose current value has a significantly higher confidence than the new
7979
value, the new value conflicts with the current value.
8080
"""
81+
82+
83+
class MissingServiceID(OctueSDKException):
84+
"""Raise when a specific ID for a service is expected to be provided, but is missing or None."""

octue/resources/service_backends.py

+25-4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
the "oneOf" field of the "backend" key of the children schema in `Twined`, which is located at
66
`twined/schema/children_schema.json`.
77
"""
8+
from abc import ABC
9+
810
from octue import exceptions
911

1012

@@ -17,15 +19,34 @@ def get_backend(backend_name):
1719
return AVAILABLE_BACKENDS[backend_name]
1820

1921

20-
class GCPPubSubBackend:
21-
""" A dataclass containing the details needed to use Google Cloud Platform Pub/Sub as a Service backend. """
22+
class ServiceBackend(ABC):
23+
"""A dataclass specifying the backend for an Octue Service, including any credentials and other information it
24+
needs.
25+
26+
:param str|None credentials_environment_variable:
27+
:return None:
28+
"""
29+
30+
def __init__(self, credentials_environment_variable):
31+
self.credentials_environment_variable = credentials_environment_variable
32+
33+
34+
class GCPPubSubBackend(ServiceBackend):
35+
"""A dataclass containing the details needed to use Google Cloud Platform Pub/Sub as a Service backend.
36+
37+
:param str project_name:
38+
:param str|None credentials_environment_variable:
39+
:return None:
40+
"""
2241

2342
def __init__(self, project_name, credentials_environment_variable="GOOGLE_APPLICATION_CREDENTIALS"):
2443
self.project_name = project_name
25-
self.credentials_environment_variable = credentials_environment_variable
44+
super().__init__(credentials_environment_variable)
2645

2746
def __repr__(self):
2847
return f"<{type(self).__name__}(project_name={self.project_name!r})>"
2948

3049

31-
AVAILABLE_BACKENDS = {key: value for key, value in locals().items() if key.endswith("Backend")}
50+
AVAILABLE_BACKENDS = {
51+
key: value for key, value in locals().items() if key.endswith("Backend") and key != "ServiceBackend"
52+
}

octue/runner.py

+3-9
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class Runner:
2323
The Runner class provides a set of configuration parameters for use by your application, together with a range of
2424
methods for managing input and output file parsing as well as controlling logging.
2525
26-
:param str twine: string path to the twine file, or a string containing valid twine json
26+
:param str|twined.Twine twine: path to the twine file, a string containing valid twine json, or a Twine instance
2727
:param str|dict paths: If a string, contains a single path to an existing data directory where
2828
(if not already present), subdirectories 'configuration', 'input', 'tmp', 'log' and 'output' will be created. If a
2929
dict, it should contain all of those keys, with each of their values being a path to a directory (which will be
@@ -57,15 +57,13 @@ def __init__(
5757
self.output_manifest_path = output_manifest_path
5858
self.children = children
5959
self.skip_checks = skip_checks
60-
61-
# Store the log level (same log level used for all analyses)
6260
self._log_level = log_level
6361
self.handler = handler
6462

6563
if show_twined_logs:
6664
apply_log_handler(logger=package_logger, handler=self.handler)
6765

68-
# Ensure the twine is present and instantiate it
66+
# Ensure the twine is present and instantiate it.
6967
if isinstance(twine, Twine):
7068
self.twine = twine
7169
else:
@@ -88,15 +86,11 @@ def __init__(
8886
configuration_manifest,
8987
)
9088

91-
# Store the log level (same log level used for all analyses)
92-
self._log_level = log_level
93-
self.handler = handler
94-
9589
if show_twined_logs:
9690
apply_log_handler(logger=package_logger, handler=self.handler, log_level=self._log_level)
9791
package_logger.info(
9892
"Showing package logs as well as analysis logs (the package logs are recommended for software "
99-
"engineers but may still be useful to app development by scientists."
93+
"engineers but may still be useful to app development by scientists)."
10094
)
10195

10296
self._project_name = project_name
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
octue==0.1.15
1+
octue==0.1.16
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
octue==0.1.15
1+
octue==0.1.16
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
octue==0.1.15
1+
octue==0.1.16

octue/templates/template-python-fractal/requirements.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
octue==0.1.15
1+
octue==0.1.16
22

33

44
# ----------- Some common libraries -----------------------------------------------------------------------------------

octue/templates/template-using-manifests/requirements.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
octue==0.1.15
1+
octue==0.1.16
22

33

44
# ----------- Some common libraries -----------------------------------------------------------------------------------

setup.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
setup(
1919
name="octue",
20-
version="0.1.15", # Ensure all requirements files containing octue are updated, too (e.g. docs build).
20+
version="0.1.16", # Ensure all requirements files containing octue are updated, too (e.g. docs build).
2121
py_modules=["cli"],
2222
install_requires=[
2323
"click>=7.1.2",
@@ -28,7 +28,7 @@
2828
"google-cloud-storage>=1.35.1",
2929
"google-crc32c>=1.1.2",
3030
"gunicorn",
31-
"twined>=0.0.18",
31+
"twined>=0.0.19",
3232
],
3333
url="https://www.github.com/octue/octue-sdk-python",
3434
license="MIT",
File renamed without changes.

tests/cloud/deployment/google/__init__.py

Whitespace-only changes.

tests/deployment/test_cloud_run.py tests/cloud/deployment/google/test_cloud_run.py

+18-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import base64
22
import json
3+
import os
34
import uuid
45
from unittest import TestCase, mock
56

7+
from octue.cloud.deployment.google import cloud_run
68
from octue.cloud.pub_sub.service import Service
7-
from octue.deployment.google import cloud_run
9+
from octue.exceptions import MissingServiceID
810
from octue.resources.service_backends import GCPPubSubBackend
911
from tests import TEST_PROJECT_NAME
1012

@@ -31,7 +33,7 @@ def test_post_to_index_with_invalid_payload_results_in_400_error(self):
3133
def test_post_to_index_with_valid_payload(self):
3234
"""Test that the Flask endpoint returns a 204 (ok, no content) response to a valid payload."""
3335
with cloud_run.app.test_client() as client:
34-
with mock.patch("octue.deployment.google.cloud_run.answer_question"):
36+
with mock.patch("octue.cloud.deployment.google.cloud_run.answer_question"):
3537

3638
response = client.post(
3739
"/",
@@ -48,6 +50,20 @@ def test_post_to_index_with_valid_payload(self):
4850

4951
self.assertEqual(response.status_code, 204)
5052

53+
def test_error_is_raised_if_service_id_environment_variable_is_missing_or_empty(self):
54+
"""Test that a MissingServiceID error is raised if the `SERVICE_ID` environment variable is missing or empty."""
55+
with mock.patch.dict(os.environ, clear=True):
56+
with self.assertRaises(MissingServiceID):
57+
cloud_run.answer_question(
58+
project_name="a-project-name", data={}, question_uuid="8c859f87-b594-4297-883f-cd1c7718ef29"
59+
)
60+
61+
with mock.patch.dict(os.environ, {"SERVICE_ID": ""}):
62+
with self.assertRaises(MissingServiceID):
63+
cloud_run.answer_question(
64+
project_name="a-project-name", data={}, question_uuid="8c859f87-b594-4297-883f-cd1c7718ef29"
65+
)
66+
5167
def test_cloud_run_integration(self):
5268
"""Test that the Google Cloud Run integration works, providing a service that can be asked questions and send
5369
responses.

tests/cloud/pub_sub/mocks.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -181,14 +181,14 @@ class MockService(Service):
181181
"""A mock Google Pub/Sub Service that can send and receive messages synchronously to other instances.
182182
183183
:param octue.resources.service_backends.GCPPubSubBackEnd backend:
184-
:param str id:
184+
:param str service_id:
185185
:param callable run_function:
186186
:param dict(str, MockService)|None children:
187187
:return None:
188188
"""
189189

190-
def __init__(self, backend, id=None, run_function=None, children=None):
191-
super().__init__(backend, id, run_function)
190+
def __init__(self, backend, service_id=None, run_function=None, children=None):
191+
super().__init__(backend, service_id, run_function)
192192
self.children = children or {}
193193
self.publisher = MockPublisher()
194194
self.subscriber = MockSubscriber()

tests/cloud/pub_sub/test_service.py

+23-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import concurrent.futures
12
import uuid
23
from unittest.mock import patch
34

@@ -7,7 +8,7 @@
78
from octue.resources.service_backends import GCPPubSubBackend
89
from tests import TEST_PROJECT_NAME
910
from tests.base import BaseTestCase
10-
from tests.cloud.pub_sub.mocks import MockService, MockSubscription, MockTopic
11+
from tests.cloud.pub_sub.mocks import MockPullResponse, MockService, MockSubscription, MockTopic
1112

1213

1314
class MockAnalysis:
@@ -61,13 +62,34 @@ def test_repr(self):
6162
asking_service = Service(backend=self.BACKEND)
6263
self.assertEqual(repr(asking_service), f"<Service({asking_service.name!r})>")
6364

65+
def test_service_id_cannot_be_non_none_empty_vaue(self):
66+
"""Ensure that a ValueError is raised if a non-None empty value is provided as the service_id."""
67+
with self.assertRaises(ValueError):
68+
Service(backend=self.BACKEND, service_id="")
69+
70+
with self.assertRaises(ValueError):
71+
Service(backend=self.BACKEND, service_id=[])
72+
73+
with self.assertRaises(ValueError):
74+
Service(backend=self.BACKEND, service_id={})
75+
6476
def test_ask_on_non_existent_service_results_in_error(self):
6577
"""Test that trying to ask a question to a non-existent service (i.e. one without a topic in Google Pub/Sub)
6678
results in an error."""
6779
with patch("octue.cloud.pub_sub.service.Topic", new=MockTopic):
6880
with self.assertRaises(exceptions.ServiceNotFound):
6981
MockService(backend=self.BACKEND).ask(service_id="hello", input_values=[1, 2, 3, 4])
7082

83+
def test_timeout_error_raised_if_no_messages_received_when_waiting(self):
84+
"""Test that a concurrent.futures.TimeoutError is raised if no messages are received while waiting."""
85+
service = Service(backend=self.BACKEND)
86+
mock_topic = MockTopic(name="world", namespace="hello", service=service)
87+
mock_subscription = MockSubscription(name="world", topic=mock_topic, namespace="hello", service=service)
88+
89+
with patch("octue.cloud.pub_sub.service.pubsub_v1.SubscriberClient.pull", return_value=MockPullResponse()):
90+
with self.assertRaises(concurrent.futures.TimeoutError):
91+
service.wait_for_answer(subscription=mock_subscription)
92+
7193
def test_ask(self):
7294
""" Test that a service can ask a question to another service that is serving and receive an answer. """
7395
responding_service = self.make_new_server(self.BACKEND, run_function_returnee=MockAnalysis(), use_mock=True)

0 commit comments

Comments
 (0)