intersect-sdk 0.6.1a1__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.
- intersect_sdk/__init__.py +64 -0
- intersect_sdk/_internal/__init__.py +4 -0
- intersect_sdk/_internal/compression.py.tmp +38 -0
- intersect_sdk/_internal/constants.py +10 -0
- intersect_sdk/_internal/control_plane/__init__.py +0 -0
- intersect_sdk/_internal/control_plane/brokers/__init__.py +0 -0
- intersect_sdk/_internal/control_plane/brokers/amqp_client.py +280 -0
- intersect_sdk/_internal/control_plane/brokers/broker_client.py +58 -0
- intersect_sdk/_internal/control_plane/brokers/mqtt_client.py +154 -0
- intersect_sdk/_internal/control_plane/control_plane_manager.py +147 -0
- intersect_sdk/_internal/control_plane/discovery_service.py +40 -0
- intersect_sdk/_internal/data_plane/__init__.py +0 -0
- intersect_sdk/_internal/data_plane/data_plane_manager.py +102 -0
- intersect_sdk/_internal/data_plane/minio_utils.py +149 -0
- intersect_sdk/_internal/event_metadata.py +60 -0
- intersect_sdk/_internal/exceptions.py +17 -0
- intersect_sdk/_internal/function_metadata.py +27 -0
- intersect_sdk/_internal/interfaces.py +20 -0
- intersect_sdk/_internal/logger.py +3 -0
- intersect_sdk/_internal/messages/__init__.py +0 -0
- intersect_sdk/_internal/messages/event.py +157 -0
- intersect_sdk/_internal/messages/lifecycle.py +174 -0
- intersect_sdk/_internal/messages/userspace.py +171 -0
- intersect_sdk/_internal/pydantic_schema_generator.py +504 -0
- intersect_sdk/_internal/schema.py +558 -0
- intersect_sdk/_internal/stoppable_thread.py +19 -0
- intersect_sdk/_internal/utils.py +32 -0
- intersect_sdk/_internal/version_resolver.py +58 -0
- intersect_sdk/app_lifecycle.py +147 -0
- intersect_sdk/capability/__init__.py +14 -0
- intersect_sdk/capability/base.py +101 -0
- intersect_sdk/client.py +443 -0
- intersect_sdk/client_callback_definitions.py +154 -0
- intersect_sdk/config/__init__.py +20 -0
- intersect_sdk/config/client.py +52 -0
- intersect_sdk/config/service.py +42 -0
- intersect_sdk/config/shared.py +168 -0
- intersect_sdk/constants.py +9 -0
- intersect_sdk/core_definitions.py +37 -0
- intersect_sdk/py.typed +0 -0
- intersect_sdk/schema.py +77 -0
- intersect_sdk/service.py +607 -0
- intersect_sdk/service_definitions.py +262 -0
- intersect_sdk/version.py +15 -0
- intersect_sdk-0.6.1a1.dist-info/METADATA +39 -0
- intersect_sdk-0.6.1a1.dist-info/RECORD +47 -0
- intersect_sdk-0.6.1a1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Callable, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import TypeAdapter
|
|
7
|
+
|
|
8
|
+
from ..exceptions import IntersectInvalidBrokerError
|
|
9
|
+
from ..logger import logger
|
|
10
|
+
from .brokers.mqtt_client import MQTTClient
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ...config.shared import ControlPlaneConfig
|
|
14
|
+
from .brokers.broker_client import BrokerClient
|
|
15
|
+
|
|
16
|
+
GENERIC_MESSAGE_SERIALIZER = TypeAdapter(Any)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def serialize_message(message: Any) -> bytes:
|
|
20
|
+
"""Serialize a message to bytes, in preparation for publishing it on a message broker.
|
|
21
|
+
|
|
22
|
+
Works as a generic serializer/deserializer
|
|
23
|
+
"""
|
|
24
|
+
return GENERIC_MESSAGE_SERIALIZER.dump_json(message, warnings=False)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def create_control_provider(
|
|
28
|
+
config: ControlPlaneConfig,
|
|
29
|
+
topic_handler_callback: Callable[[], defaultdict[str, set[Callable[[bytes], None]]]],
|
|
30
|
+
) -> BrokerClient:
|
|
31
|
+
if config.protocol == 'amqp0.9.1':
|
|
32
|
+
# only try to import the AMQP client if the user is using an AMQP broker
|
|
33
|
+
try:
|
|
34
|
+
from .brokers.amqp_client import AMQPClient
|
|
35
|
+
|
|
36
|
+
return AMQPClient(
|
|
37
|
+
host=config.host,
|
|
38
|
+
port=config.port or 5672,
|
|
39
|
+
username=config.username,
|
|
40
|
+
password=config.password,
|
|
41
|
+
topics_to_handlers=topic_handler_callback,
|
|
42
|
+
)
|
|
43
|
+
except ImportError as e:
|
|
44
|
+
msg = "Configuration includes AMQP broker, but AMQP dependencies were not installed. Install intersect with the 'amqp' optional dependency to use this backend. (i.e. `pip install intersect_sdk[amqp]`)"
|
|
45
|
+
raise IntersectInvalidBrokerError(msg) from e
|
|
46
|
+
# MQTT
|
|
47
|
+
return MQTTClient(
|
|
48
|
+
host=config.host,
|
|
49
|
+
port=config.port or 1883,
|
|
50
|
+
username=config.username,
|
|
51
|
+
password=config.password,
|
|
52
|
+
topics_to_handlers=topic_handler_callback,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ControlPlaneManager:
|
|
57
|
+
"""The ControlPlaneManager class allows for working with multiple brokers from a single function call."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
control_configs: list[ControlPlaneConfig] | Literal['discovery'],
|
|
62
|
+
) -> None:
|
|
63
|
+
if control_configs == 'discovery':
|
|
64
|
+
msg = 'Discovery service not implemented yet'
|
|
65
|
+
raise ValueError(msg)
|
|
66
|
+
self._control_providers = [
|
|
67
|
+
create_control_provider(config, self.get_subscription_channels)
|
|
68
|
+
for config in control_configs
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
self._ready = False
|
|
72
|
+
# topics_to_handlers are managed here and transcend connections/disconnections to the broker
|
|
73
|
+
self._topics_to_handlers: defaultdict[str, set[Callable[[bytes], None]]] = defaultdict(set)
|
|
74
|
+
|
|
75
|
+
def add_subscription_channel(
|
|
76
|
+
self, channel: str, callbacks: set[Callable[[bytes], None]]
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Start listening for userspace messages on a channel on all configured brokers.
|
|
79
|
+
|
|
80
|
+
Note that ALL channels listened to will always handle userspace messages.
|
|
81
|
+
|
|
82
|
+
Params:
|
|
83
|
+
channel: string of the channel which we should start listening to
|
|
84
|
+
"""
|
|
85
|
+
self._topics_to_handlers[channel] |= callbacks
|
|
86
|
+
if self._ready:
|
|
87
|
+
for provider in self._control_providers:
|
|
88
|
+
provider.subscribe(channel)
|
|
89
|
+
|
|
90
|
+
def remove_subscription_channel(self, channel: str) -> bool:
|
|
91
|
+
"""Stop subscribing to a channel on all configured brokers.
|
|
92
|
+
|
|
93
|
+
Params:
|
|
94
|
+
channel: string of the channel which should no longer be listened to
|
|
95
|
+
Returns: True if channel is no longer being listened to, False if the channel wasn't being listened to in the first place
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
del self._topics_to_handlers[channel]
|
|
99
|
+
except KeyError:
|
|
100
|
+
return False
|
|
101
|
+
else:
|
|
102
|
+
if self._ready:
|
|
103
|
+
for provider in self._control_providers:
|
|
104
|
+
provider.unsubscribe(channel)
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
def get_subscription_channels(self) -> defaultdict[str, set[Callable[[bytes], None]]]:
|
|
108
|
+
"""Get the subscription channels.
|
|
109
|
+
|
|
110
|
+
Note that this function gets accessed as a callback from the direct broker implementations.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
the dictionary of topics to callback functions
|
|
114
|
+
"""
|
|
115
|
+
return self._topics_to_handlers
|
|
116
|
+
|
|
117
|
+
def connect(self) -> None:
|
|
118
|
+
"""Connect to all configured brokers and subscribe to any channels configured."""
|
|
119
|
+
# TODO - when implementing discovery service, discovery and connection logic should be applied here
|
|
120
|
+
for provider in self._control_providers:
|
|
121
|
+
provider.connect()
|
|
122
|
+
for channel in self._topics_to_handlers:
|
|
123
|
+
provider.subscribe(channel)
|
|
124
|
+
self._ready = True
|
|
125
|
+
|
|
126
|
+
def disconnect(self) -> None:
|
|
127
|
+
"""Disconnect from all configured brokers."""
|
|
128
|
+
self._ready = False
|
|
129
|
+
for provider in self._control_providers:
|
|
130
|
+
provider.disconnect()
|
|
131
|
+
|
|
132
|
+
def publish_message(self, channel: str, msg: Any) -> None:
|
|
133
|
+
"""Publish message on channel for all brokers."""
|
|
134
|
+
if self._ready:
|
|
135
|
+
serialized_message = serialize_message(msg)
|
|
136
|
+
for provider in self._control_providers:
|
|
137
|
+
provider.publish(channel, serialized_message)
|
|
138
|
+
else:
|
|
139
|
+
logger.error('Cannot send message, providers are not connected')
|
|
140
|
+
|
|
141
|
+
def is_connected(self) -> bool:
|
|
142
|
+
"""Check that we are connected to ALL configured brokers.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
- True if we are currently connected to all brokers we've configured, False if not
|
|
146
|
+
"""
|
|
147
|
+
return all(control_provider.is_connected() for control_provider in self._control_providers)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""TODO: This is all old code we currently aren't using."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
from urllib.request import Request, urlopen
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def discover_broker(address: str, broker_endpoint: str) -> tuple[str, str, int]:
|
|
11
|
+
"""Get the metadata for a broker from the discovery service.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
address: A string containing the address for the discovery service.
|
|
15
|
+
broker_endpoint: specific API for broker
|
|
16
|
+
Returns:
|
|
17
|
+
Three strings. The first is the name of the broker type (as used in
|
|
18
|
+
_create_broker_client()), the second is the broker's address, and
|
|
19
|
+
the third is the broker's port number.
|
|
20
|
+
"""
|
|
21
|
+
url = f'{address}/v0.1/{broker_endpoint}'
|
|
22
|
+
|
|
23
|
+
# Get scheme associated with the `url` string
|
|
24
|
+
scheme = urlparse(url).scheme
|
|
25
|
+
|
|
26
|
+
# Only accept `http` and `https` schemes, otherwise raise error
|
|
27
|
+
if scheme not in ('http', 'https'):
|
|
28
|
+
msg = f'URL scheme is {scheme}, only http or https schemes are accepted'
|
|
29
|
+
raise ValueError(msg)
|
|
30
|
+
|
|
31
|
+
request = Request(url) # noqa: S310 (scheme checked earlier)
|
|
32
|
+
with urlopen(request) as response: # noqa: S310 (scheme checked earlier)
|
|
33
|
+
body = response.read()
|
|
34
|
+
|
|
35
|
+
broker_info = json.loads(body.decode('utf-8'))
|
|
36
|
+
endpoint = broker_info['endpoint']
|
|
37
|
+
backend_name = broker_info['backendName']
|
|
38
|
+
address, port = endpoint.split(':', 1)
|
|
39
|
+
|
|
40
|
+
return backend_name, address, port
|
|
File without changes
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from ...core_definitions import IntersectDataHandler, IntersectMimeType
|
|
7
|
+
from ..exceptions import IntersectError
|
|
8
|
+
from ..logger import logger
|
|
9
|
+
from .minio_utils import MinioPayload, create_minio_store, get_minio_object, send_minio_object
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ...config.shared import DataStoreConfigMap, HierarchyConfig
|
|
13
|
+
from ..messages.event import EventMessage
|
|
14
|
+
from ..messages.userspace import UserspaceMessage
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DataPlaneManager:
|
|
18
|
+
"""The DataPlaneManager serves as a common interface to the data plane.
|
|
19
|
+
|
|
20
|
+
The API supports extensive plug-and-play for different data providers.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, hierarchy: HierarchyConfig, data_configs: DataStoreConfigMap) -> None:
|
|
24
|
+
"""Inside the constructor, we verify that all data configuration credentials are correct.
|
|
25
|
+
|
|
26
|
+
Params:
|
|
27
|
+
hierarchy: Hierarchy configuration
|
|
28
|
+
data_configs: data configuration
|
|
29
|
+
"""
|
|
30
|
+
self._hierarchy = hierarchy
|
|
31
|
+
self._minio_providers = list(map(create_minio_store, data_configs.minio))
|
|
32
|
+
|
|
33
|
+
# warn users about missing data plane
|
|
34
|
+
if not self._minio_providers:
|
|
35
|
+
logger.warn('WARNING: This service cannot support any MINIO instances')
|
|
36
|
+
|
|
37
|
+
def incoming_message_data_handler(self, message: UserspaceMessage | EventMessage) -> bytes:
|
|
38
|
+
"""Get data from the request data provider.
|
|
39
|
+
|
|
40
|
+
Params:
|
|
41
|
+
message: the message sent externally to this location
|
|
42
|
+
Returns:
|
|
43
|
+
the actual data we want to submit to the user function
|
|
44
|
+
Raise:
|
|
45
|
+
IntersectException - if we couldn't get the data
|
|
46
|
+
"""
|
|
47
|
+
request_data_handler = message['headers']['data_handler']
|
|
48
|
+
if request_data_handler == IntersectDataHandler.MESSAGE:
|
|
49
|
+
return message['payload'] # type: ignore[return-value]
|
|
50
|
+
if request_data_handler == IntersectDataHandler.MINIO:
|
|
51
|
+
# TODO - we may want to send additional provider information in the payload
|
|
52
|
+
payload: MinioPayload = message['payload'] # type: ignore[assignment]
|
|
53
|
+
provider = None
|
|
54
|
+
for store in self._minio_providers:
|
|
55
|
+
if store._base_url._url.geturl() == payload['minio_url']: # noqa: SLF001 (only way to get URL from MINIO API)
|
|
56
|
+
provider = store
|
|
57
|
+
break
|
|
58
|
+
if not provider:
|
|
59
|
+
logger.error(
|
|
60
|
+
f"You did not configure listening to MINIO instance '{payload['minio_url']}'. You must fix this to handle this data."
|
|
61
|
+
)
|
|
62
|
+
raise IntersectError
|
|
63
|
+
return get_minio_object(provider, payload)
|
|
64
|
+
logger.warning(f'Cannot parse data handler {request_data_handler}')
|
|
65
|
+
raise IntersectError
|
|
66
|
+
|
|
67
|
+
def outgoing_message_data_handler(
|
|
68
|
+
self,
|
|
69
|
+
function_response: bytes,
|
|
70
|
+
content_type: IntersectMimeType,
|
|
71
|
+
data_handler: IntersectDataHandler,
|
|
72
|
+
) -> bytes | MinioPayload:
|
|
73
|
+
"""Send the user's response to the appropriate data provider.
|
|
74
|
+
|
|
75
|
+
Params:
|
|
76
|
+
- function_response - the return value from the user's function
|
|
77
|
+
- content_type - content type of function_response
|
|
78
|
+
- data_handler - where we're going to send the data off to (i.e. the message, MINIO...)
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
the payload of the message
|
|
82
|
+
Raise:
|
|
83
|
+
IntersectException - if there was any error in submitting the response
|
|
84
|
+
"""
|
|
85
|
+
# TODO - instead of requiring users to specify the data handler themselves, another idea could be to use
|
|
86
|
+
# sys.getsizeof(function_response) and determine the data handler dynamically
|
|
87
|
+
# users could perhaps specify T1/T2/T3 data types but not the specific implementation
|
|
88
|
+
if data_handler == IntersectDataHandler.MESSAGE:
|
|
89
|
+
return function_response
|
|
90
|
+
if data_handler == IntersectDataHandler.MINIO:
|
|
91
|
+
if not self._minio_providers:
|
|
92
|
+
logger.error(
|
|
93
|
+
'No MINIO provider configured, so you cannot set response_data_handler on @intersect_message to equal IntersectDataHandler.MINIO .'
|
|
94
|
+
)
|
|
95
|
+
raise IntersectError
|
|
96
|
+
provider = random.choice(self._minio_providers) # noqa: S311 (TODO choose a MINIO provider better than at random - this may be determined from external message params)
|
|
97
|
+
return send_minio_object(function_response, provider, content_type, self._hierarchy)
|
|
98
|
+
|
|
99
|
+
logger.error(
|
|
100
|
+
f'No support implemented for code {data_handler}, please upgrade your intersect-sdk version.'
|
|
101
|
+
)
|
|
102
|
+
raise IntersectError
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import mimetypes
|
|
2
|
+
from hashlib import sha224
|
|
3
|
+
from io import BytesIO
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
from minio import Minio
|
|
7
|
+
from minio.error import MinioException
|
|
8
|
+
from typing_extensions import TypedDict
|
|
9
|
+
from urllib3.exceptions import MaxRetryError
|
|
10
|
+
from urllib3.util import parse_url
|
|
11
|
+
|
|
12
|
+
from ...config.shared import DataStoreConfig, HierarchyConfig
|
|
13
|
+
from ...core_definitions import IntersectMimeType
|
|
14
|
+
from ..exceptions import IntersectError
|
|
15
|
+
from ..logger import logger
|
|
16
|
+
from ..utils import die
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MinioPayload(TypedDict):
|
|
20
|
+
"""This is a payload which gets sent in the actual userspace message if the data handler is "MINIO"."""
|
|
21
|
+
|
|
22
|
+
minio_url: str
|
|
23
|
+
"""
|
|
24
|
+
The complete URL of the MINIO instance. This is used for finding the correct MINIO instance when retrieving an object.
|
|
25
|
+
"""
|
|
26
|
+
minio_bucket: str
|
|
27
|
+
"""
|
|
28
|
+
The name of the bucket where the object is located. Each service should only create objects in one bucket.
|
|
29
|
+
"""
|
|
30
|
+
minio_object_id: str
|
|
31
|
+
"""
|
|
32
|
+
The name of the object. This is a random UUID.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _condense_minio_bucket_name(hierarchy: HierarchyConfig) -> str:
|
|
37
|
+
"""Condense a hierarchy string into a string less than 64 characters.
|
|
38
|
+
|
|
39
|
+
This function is needed to handle MINIO bucket names. Collisions should be extremely rare,
|
|
40
|
+
and it should be fairly straightforward to identify a specific bucket for MINIO admins.
|
|
41
|
+
|
|
42
|
+
Bucket name = first characters (up to 6) of service name + hyphen + sha224 of full hierarchy string
|
|
43
|
+
|
|
44
|
+
TODO in the future, MINIO calls should be system-level only, so only the system + facility + organization
|
|
45
|
+
should need to be hashed.
|
|
46
|
+
Also, potentially come up with a better hashing algorithm (though sha224 is compressed enough, and gives us 56 characters).
|
|
47
|
+
"""
|
|
48
|
+
return f'{hierarchy.service[:6]}-{sha224(hierarchy.hierarchy_string().encode()).hexdigest()}'
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def create_minio_store(config: DataStoreConfig) -> Minio:
|
|
52
|
+
config_uri = parse_url(config.host)
|
|
53
|
+
if not config_uri.host:
|
|
54
|
+
die(f'Minio configuration host {config.host} cannot be parsed as a valid hostname.')
|
|
55
|
+
|
|
56
|
+
client = Minio(
|
|
57
|
+
secure=config_uri.scheme == 'https',
|
|
58
|
+
access_key=config.username,
|
|
59
|
+
secret_key=config.password,
|
|
60
|
+
endpoint=config_uri.host if not config.port else f'{config_uri.host}:{config.port}',
|
|
61
|
+
)
|
|
62
|
+
try:
|
|
63
|
+
# it doesn't matter if the bucket exists, we're just checking the exception
|
|
64
|
+
# this is the fastest way in the public API to perform a HEAD request.
|
|
65
|
+
client.bucket_exists('qwop')
|
|
66
|
+
except MinioException:
|
|
67
|
+
die(f'Invalid credentials for Minio instance: {config}')
|
|
68
|
+
return client
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def send_minio_object(
|
|
72
|
+
data: bytes, provider: Minio, content_type: IntersectMimeType, hierarchy: HierarchyConfig
|
|
73
|
+
) -> MinioPayload:
|
|
74
|
+
"""Core function to save data in MINIO.
|
|
75
|
+
|
|
76
|
+
Params:
|
|
77
|
+
data: user response data, as bytes
|
|
78
|
+
provider: the Minio client
|
|
79
|
+
content_type: the content type of the body
|
|
80
|
+
hierarchy: the hierarchy configuration
|
|
81
|
+
Returns:
|
|
82
|
+
The MINIO payload which gets sent in the actual message.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
IntersectException - if any non-fatal MinIO error is caught
|
|
86
|
+
"""
|
|
87
|
+
bucket_name = _condense_minio_bucket_name(hierarchy)
|
|
88
|
+
# mimetypes.guess_extension() is a nice-to-have for MINIO preview, but isn't essential.
|
|
89
|
+
object_id = str(uuid4()) + (mimetypes.guess_extension(content_type.value) or '')
|
|
90
|
+
try:
|
|
91
|
+
if not provider.bucket_exists(bucket_name):
|
|
92
|
+
provider.make_bucket(bucket_name)
|
|
93
|
+
buff_data = BytesIO(data)
|
|
94
|
+
provider.put_object(
|
|
95
|
+
bucket_name=bucket_name,
|
|
96
|
+
object_name=object_id,
|
|
97
|
+
data=buff_data,
|
|
98
|
+
length=buff_data.getbuffer().nbytes,
|
|
99
|
+
content_type=content_type.value,
|
|
100
|
+
)
|
|
101
|
+
return MinioPayload(
|
|
102
|
+
minio_url=provider._base_url._url.geturl(), # noqa: SLF001 (only way to get URL from MINIO API)
|
|
103
|
+
minio_bucket=bucket_name,
|
|
104
|
+
minio_object_id=object_id,
|
|
105
|
+
)
|
|
106
|
+
except MaxRetryError as e:
|
|
107
|
+
logger.warning(
|
|
108
|
+
f'Non-fatal MinIO error when sending object, the server may be under stress but you should double-check your configuration. Details: \n{e}'
|
|
109
|
+
)
|
|
110
|
+
raise IntersectError from e
|
|
111
|
+
except MinioException as e:
|
|
112
|
+
logger.error(
|
|
113
|
+
f'Important MinIO error when sending object, this usually indicates a problem with your configuration. Details: \n{e}'
|
|
114
|
+
)
|
|
115
|
+
raise IntersectError from e
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_minio_object(provider: Minio, payload: MinioPayload) -> bytes:
|
|
119
|
+
"""Core function to retrieve data from MINIO.
|
|
120
|
+
|
|
121
|
+
Params:
|
|
122
|
+
provider: a pre-cached MinIO provider from the data provider store
|
|
123
|
+
payload: the payload from the message (at this point, the minio_url should exist)
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
user response data, as raw bytes
|
|
127
|
+
Raises:
|
|
128
|
+
IntersectException - if any non-fatal MinIO error is caught
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
response = provider.get_object(
|
|
132
|
+
bucket_name=payload['minio_bucket'], object_name=payload['minio_object_id']
|
|
133
|
+
)
|
|
134
|
+
# TODO - objects should ONLY be removed if they are T1
|
|
135
|
+
provider.remove_object(
|
|
136
|
+
bucket_name=payload['minio_bucket'], object_name=payload['minio_object_id']
|
|
137
|
+
)
|
|
138
|
+
except MaxRetryError as e:
|
|
139
|
+
logger.warning(
|
|
140
|
+
f'Non-fatal MinIO error when retrieving object, the server may be under stress but you should double-check your configuration. Details: \n{e}'
|
|
141
|
+
)
|
|
142
|
+
raise IntersectError from e
|
|
143
|
+
except MinioException as e:
|
|
144
|
+
logger.error(
|
|
145
|
+
f'Important MinIO error when retrieving object, this usually indicates a problem with your configuration. Details: \n{e}'
|
|
146
|
+
)
|
|
147
|
+
raise IntersectError from e
|
|
148
|
+
else:
|
|
149
|
+
return response.data
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, NamedTuple
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from pydantic import TypeAdapter
|
|
7
|
+
|
|
8
|
+
from ..core_definitions import IntersectDataHandler, IntersectMimeType
|
|
9
|
+
from ..service_definitions import IntersectEventDefinition
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EventMetadata(NamedTuple):
|
|
13
|
+
"""Internal cache of metadata associated with an event.
|
|
14
|
+
|
|
15
|
+
NOTE: both this class and all properties in it should remain immutable after creation
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
operations: set[str]
|
|
19
|
+
"""
|
|
20
|
+
A hash set of operations which advertise this event
|
|
21
|
+
"""
|
|
22
|
+
type: type
|
|
23
|
+
"""
|
|
24
|
+
The actual type of the event
|
|
25
|
+
"""
|
|
26
|
+
type_adapter: TypeAdapter[Any]
|
|
27
|
+
"""
|
|
28
|
+
The type adapter used for deserializing and validating events
|
|
29
|
+
"""
|
|
30
|
+
data_transfer_handler: IntersectDataHandler
|
|
31
|
+
"""
|
|
32
|
+
The data transfer type
|
|
33
|
+
"""
|
|
34
|
+
content_type: IntersectMimeType
|
|
35
|
+
"""
|
|
36
|
+
The content type
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def definition_metadata_differences(
|
|
41
|
+
definition: IntersectEventDefinition, metadata: EventMetadata
|
|
42
|
+
) -> list[tuple[str, str, str]]:
|
|
43
|
+
"""Return a list of differences between 'definition' and 'metadata'.
|
|
44
|
+
|
|
45
|
+
First tuple value = defintion key
|
|
46
|
+
Second tuple value = second value
|
|
47
|
+
Third tuple value = already cached value
|
|
48
|
+
"""
|
|
49
|
+
differences = []
|
|
50
|
+
if definition.event_type != metadata.type:
|
|
51
|
+
differences.append(('event_type', str(definition.event_type), str(metadata.type)))
|
|
52
|
+
if definition.content_type != metadata.content_type:
|
|
53
|
+
differences.append(
|
|
54
|
+
('content_type', str(definition.content_type), str(metadata.content_type))
|
|
55
|
+
)
|
|
56
|
+
if definition.data_handler != metadata.data_transfer_handler:
|
|
57
|
+
differences.append(
|
|
58
|
+
('data_handler', str(definition.data_handler), str(metadata.data_transfer_handler))
|
|
59
|
+
)
|
|
60
|
+
return differences
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class IntersectError(Exception):
|
|
2
|
+
"""Generic marker for INTERSECT-specific exceptions."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class IntersectApplicationError(IntersectError):
|
|
6
|
+
"""This is a special IntersectException, thrown if user application logic throws ANY kind of Exception.
|
|
7
|
+
|
|
8
|
+
In general, validation should be expressed through JSON schema as much as possible; however, JSON schema is NOT a complete prescription for input validation.
|
|
9
|
+
When this exception is thrown, however, we do not leak any exception information in the error message. On the other hand, if the input fails
|
|
10
|
+
JSON schema validation, Pydantic will throw a specific ValidationError, and that exception information will deliberately be exposed in the error message.
|
|
11
|
+
|
|
12
|
+
This exception should strictly be used for control flow - it should NEVER be a fatal exception
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IntersectInvalidBrokerError(IntersectError):
|
|
17
|
+
"""Exception when invalid broker backend used."""
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Callable, NamedTuple
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from pydantic import TypeAdapter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FunctionMetadata(NamedTuple):
|
|
10
|
+
"""Internal cache of public function metadata.
|
|
11
|
+
|
|
12
|
+
NOTE: both this class and all properties in it should remain immutable after creation
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
method: Callable[[Any], Any]
|
|
16
|
+
"""
|
|
17
|
+
The raw method of the function. The function itself is useless and should not be called,
|
|
18
|
+
but will store user-defined attributes needed for internal handling of data.
|
|
19
|
+
"""
|
|
20
|
+
request_adapter: TypeAdapter[Any] | None
|
|
21
|
+
"""
|
|
22
|
+
Type adapter for serializing and validating requests. Should only be null if user did not specify a request parameter.
|
|
23
|
+
"""
|
|
24
|
+
response_adapter: TypeAdapter[Any]
|
|
25
|
+
"""
|
|
26
|
+
Type adapter for serializing and validating responses.
|
|
27
|
+
"""
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class IntersectEventObserver(ABC):
|
|
6
|
+
"""Abstract definition of an entity which observes an INTERSECT event (i.e. IntersectService).
|
|
7
|
+
|
|
8
|
+
Used as the common interface for event emitters (i.e. CapabilityImplementations).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def _on_observe_event(self, event_name: str, event_value: Any, operation: str) -> None:
|
|
13
|
+
"""How to react to an event being fired.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
event_name: The key of the event which is fired.
|
|
17
|
+
event_value: The value of the event which is fired.
|
|
18
|
+
operation: The source of the event (generally the function name, not directly invoked by application devs)
|
|
19
|
+
"""
|
|
20
|
+
...
|
|
File without changes
|