From f82af35e1f73fe913c5fab4f8a3cdcb6c5ad4290 Mon Sep 17 00:00:00 2001 From: Roboto Bot-o Date: Mon, 29 Jul 2024 15:26:42 -0700 Subject: [PATCH] Update SDK to version 0.5.9 --- src/roboto/__init__.py | 9 ++ src/roboto/association.py | 29 ++++++ src/roboto/domain/events/__init__.py | 19 ++++ src/roboto/domain/events/event.py | 127 +++++++++++++++++++++++++ src/roboto/domain/events/operations.py | 63 ++++++++++++ src/roboto/domain/events/record.py | 79 +++++++++++++++ src/roboto/time.py | 46 +++++++++ src/roboto/version.py | 2 +- 8 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 src/roboto/domain/events/__init__.py create mode 100644 src/roboto/domain/events/event.py create mode 100644 src/roboto/domain/events/operations.py create mode 100644 src/roboto/domain/events/record.py diff --git a/src/roboto/__init__.py b/src/roboto/__init__.py index d95a066..a093ba6 100644 --- a/src/roboto/__init__.py +++ b/src/roboto/__init__.py @@ -8,6 +8,10 @@ ActionRuntime, FilesChangesetFileManager, ) +from .association import ( + Association, + AssociationType, +) from .config import RobotoConfig from .domain.actions import ( Accessibility, @@ -87,6 +91,7 @@ Device, DeviceRecord, ) +from .domain.events import Event, EventRecord from .domain.files import ( CredentialProvider, DeleteFileRequest, @@ -137,6 +142,8 @@ "ActionRuntime", "AddMessagePathRepresentationRequest", "AddMessagePathRequest", + "Association", + "AssociationType", "BeginManifestTransactionRequest", "BeginSingleFileUploadRequest", "CanonicalDataType", @@ -176,6 +183,8 @@ "EvaluateTriggersRequest", "ExecutableProvenance", "ExecutorContainer", + "Event", + "EventRecord", "File", "FilesChangesetFileManager", "FileRecord", diff --git a/src/roboto/association.py b/src/roboto/association.py index 4182536..43d6cd6 100644 --- a/src/roboto/association.py +++ b/src/roboto/association.py @@ -4,6 +4,7 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. +import collections.abc import enum import typing import urllib.parse @@ -20,6 +21,7 @@ class AssociationType(enum.Enum): Dataset = "dataset" File = "file" + Topic = "topic" class Association(pydantic.BaseModel): @@ -27,6 +29,21 @@ class Association(pydantic.BaseModel): URL_ENCODING_SEP: typing.ClassVar[str] = ":" + @staticmethod + def group_by_type( + associations: collections.abc.Collection["Association"], + ) -> collections.abc.Mapping[ + AssociationType, collections.abc.Sequence["Association"] + ]: + response: dict[AssociationType, list[Association]] = {} + + for association in associations: + if association.association_type not in response: + response[association.association_type] = [] + response[association.association_type].append(association) + + return response + @classmethod def from_url_encoded_value(cls, encoded: str) -> "Association": """Reverse of Association::url_encode.""" @@ -42,6 +59,18 @@ def from_url_encoded_value(cls, encoded: str) -> "Association": f"Invalid association type '{association_type}'" ) from None + @classmethod + def dataset(cls, dataset_id: str): + return cls(association_id=dataset_id, association_type=AssociationType.Dataset) + + @classmethod + def file(cls, file_id: str): + return cls(association_id=file_id, association_type=AssociationType.File) + + @classmethod + def topic(cls, topic_id: typing.Union[str, int]): + return cls(association_id=str(topic_id), association_type=AssociationType.Topic) + association_id: str """Roboto identifier""" diff --git a/src/roboto/domain/events/__init__.py b/src/roboto/domain/events/__init__.py new file mode 100644 index 0000000..8f5b1d5 --- /dev/null +++ b/src/roboto/domain/events/__init__.py @@ -0,0 +1,19 @@ +# Copyright (c) 2024 Roboto Technologies, Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +from .event import Event +from .operations import ( + CreateEventRequest, + QueryEventsForAssociationsRequest, +) +from .record import EventRecord + +__all__ = [ + "Event", + "CreateEventRequest", + "EventRecord", + "QueryEventsForAssociationsRequest", +] diff --git a/src/roboto/domain/events/event.py b/src/roboto/domain/events/event.py new file mode 100644 index 0000000..fcecc86 --- /dev/null +++ b/src/roboto/domain/events/event.py @@ -0,0 +1,127 @@ +# Copyright (c) 2024 Roboto Technologies, Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import collections.abc +import datetime +import typing + +from ...association import Association +from ...http import RobotoClient +from ...time import to_epoch_nanoseconds +from .operations import ( + CreateEventRequest, + QueryEventsForAssociationsRequest, +) +from .record import EventRecord + + +class Event: + """ + An event is a "time anchor" which allows you to relate first class Roboto entities (datasets, files, and topics), + as well as a timespan in which they occurred. + """ + + __roboto_client: RobotoClient + __record: EventRecord + + @classmethod + def create( + cls, + associations: collections.abc.Sequence[Association], + start_time: typing.Union[int, datetime.datetime], + end_time: typing.Optional[typing.Union[int, datetime.datetime]] = None, + description: typing.Optional[str] = None, + metadata: typing.Optional[dict[str, typing.Any]] = None, + tags: typing.Optional[list[str]] = None, + caller_org_id: typing.Optional[str] = None, + roboto_client: typing.Optional[RobotoClient] = None, + ) -> "Event": + roboto_client = RobotoClient.defaulted(roboto_client) + request = CreateEventRequest( + associations=list(associations), + start_time=to_epoch_nanoseconds(start_time), + end_time=to_epoch_nanoseconds(end_time or start_time), + description=description, + metadata=metadata or {}, + tags=tags or [], + ) + record = roboto_client.post( + "v1/events/create", caller_org_id=caller_org_id, data=request + ).to_record(EventRecord) + return cls(record=record, roboto_client=roboto_client) + + @classmethod + def for_association( + cls, + association: Association, + roboto_client: typing.Optional[RobotoClient] = None, + ) -> collections.abc.Generator["Event", None, None]: + """ + Returns all events associated with the provided association. Any events which you don't have access to will be + filtered out of the response rather than throwing an exception. + """ + return Event.for_associations([association], roboto_client=roboto_client) + + @classmethod + def for_associations( + cls, + associations: collections.abc.Collection[Association], + roboto_client: typing.Optional[RobotoClient] = None, + ) -> collections.abc.Generator["Event", None, None]: + """ + Returns all events associated with the provided association. Any events which you don't have access to will be + filtered out of the response rather than throwing an exception. + """ + roboto_client = RobotoClient.defaulted(roboto_client) + + next_token: typing.Optional[str] = None + while True: + request = QueryEventsForAssociationsRequest( + associations=list(associations), page_token=next_token + ) + + results = roboto_client.post( + "v1/events/query/for_associations", + data=request, + ).to_paginated_list(EventRecord) + + for item in results.items: + yield cls(record=item, roboto_client=roboto_client) + + next_token = results.next_token + if not next_token: + break + + @classmethod + def from_id( + cls, event_id: str, roboto_client: typing.Optional[RobotoClient] = None + ): + roboto_client = RobotoClient.defaulted(roboto_client) + record = roboto_client.get(f"v1/events/id/{event_id}").to_record(EventRecord) + return cls(record, roboto_client) + + def __init__( + self, record: EventRecord, roboto_client: typing.Optional[RobotoClient] = None + ) -> None: + self.__roboto_client = RobotoClient.defaulted(roboto_client) + self.__record = record + + def __repr__(self) -> str: + return self.__record.model_dump_json() + + @property + def event_id(self) -> str: + return self.__record.event_id + + @property + def record(self) -> EventRecord: + return self.__record + + def delete(self) -> None: + self.__roboto_client.delete(f"v1/events/id/{self.event_id}") + + def to_dict(self) -> dict[str, typing.Any]: + return self.__record.model_dump(mode="json") diff --git a/src/roboto/domain/events/operations.py b/src/roboto/domain/events/operations.py new file mode 100644 index 0000000..73fcf2a --- /dev/null +++ b/src/roboto/domain/events/operations.py @@ -0,0 +1,63 @@ +# Copyright (c) 2024 Roboto Technologies, Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import typing + +import pydantic + +from ...association import Association + + +class CreateEventRequest(pydantic.BaseModel): + """ + Request payload for the Create Event operation. + """ + + associations: list[Association] = pydantic.Field(default_factory=list) + """ + Datasets, files, and topics which this event pertains to. At least one must be provided. All referenced + datasets, files, and topics must be owned by the same organization. + """ + + description: typing.Optional[str] = None + """ + An optional human-readable description of the event. + """ + + end_time: int + """ + The end time of the event, in nanoseconds since epoch (assumed Unix epoch). This can be equal to start_time if + the event is discrete, but can never be less than start_time. + """ + + metadata: dict[str, typing.Any] = pydantic.Field( + default_factory=dict, + ) + """ + Initial key-value pairs to associate with this event for discovery and search. + """ + + start_time: int + """ + The start time of the event, in nanoseconds since epoch (assumed Unix epoch). + """ + + tags: list[str] = pydantic.Field(default_factory=list) + """ + Initial tags to associate with this event for discovery and search. + """ + + +class QueryEventsForAssociationsRequest(pydantic.BaseModel): + """ + Request payload for the Query Events for Associations operation. + """ + + associations: list[Association] + """Associations to query events for.""" + + page_token: typing.Optional[str] = None + """Token to use to fetch the next page of results, use None for the first page.""" diff --git a/src/roboto/domain/events/record.py b/src/roboto/domain/events/record.py new file mode 100644 index 0000000..47bb258 --- /dev/null +++ b/src/roboto/domain/events/record.py @@ -0,0 +1,79 @@ +# Copyright (c) 2024 Roboto Technologies, Inc. +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import datetime +import typing + +import pydantic + +from ...association import Association + + +class EventRecord(pydantic.BaseModel): + """ + A wire-transmissible representation of an event. + """ + + associations: list[Association] = pydantic.Field(default_factory=list) + """ + Datasets, files, and topics which this event pertains to. + """ + + created: datetime.datetime + """ + Date/time when this event was created. + """ + + created_by: str = pydantic.Field(description="The user who registered this device.") + """ + The user who created this event. + """ + + description: typing.Optional[str] = None + """ + An optional human-readable description of the event. + """ + + end_time: int + """ + The end time of the event, in nanoseconds since epoch (assumed Unix epoch). This can be equal to start_time if + the event is discrete, but can never be less than start_time. + """ + + event_id: str + """ + A globally unique ID used to reference an event. + """ + + metadata: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + """ + Key-value pairs to associate with this event for discovery and search. + """ + + modified: datetime.datetime + """ + Date/time when this device record was last modified. + """ + + modified_by: str + """ + The user who last modified this device record. + """ + + org_id: str = pydantic.Field(description="The org to which this device belongs.") + """ + The org to which this device belongs. + """ + + start_time: int + """ + The start time of the event, in nanoseconds since epoch (assumed Unix epoch). + """ + + tags: list[str] = pydantic.Field(default_factory=list) + """ + Tags to associate with this event for discovery and search. + """ diff --git a/src/roboto/time.py b/src/roboto/time.py index 937168f..9ca8dfe 100644 --- a/src/roboto/time.py +++ b/src/roboto/time.py @@ -5,6 +5,52 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. import datetime +import typing + +# 2038-01-19Z +MAX_32BIT_EPOCH_SECONDS = 2_147_483_647 + + +def to_epoch_nanoseconds(value: typing.Union[int, datetime.datetime]): + """ + Takes a time value in any of the following formats, and converts it to unix epoch nanoseconds: + + * Python datetime.datetime + * Unix epoch seconds + * Unix epoch milliseconds + * Unix epoch microseconds + * Unix epoch nanoseconds + """ + if isinstance(value, int): + if value < 0: + raise ValueError( + f"Cannot convert a negative number to epoch nanoseconds, got {value}" + ) + + # Assume epoch seconds, convert to nanos + elif 0 <= value <= MAX_32BIT_EPOCH_SECONDS: + return value * 1_000_000_000 + + # Assume epoch millis, convert to nanos + elif value < MAX_32BIT_EPOCH_SECONDS * 1_000: + return value * 1_000_000 + + # Assume epoch micros, convert to nanos + elif value < MAX_32BIT_EPOCH_SECONDS * 1_000_000: + return value * 1_000 + + # Already in epoch nanos + else: + return value + + elif isinstance(value, datetime.datetime): + epoch = datetime.datetime(1970, 1, 1) + return int((value - epoch).total_seconds() * 1e9) + + else: + raise TypeError( + "Input must be either an int (epoch nanoseconds) or a datetime.datetime object" + ) def utcnow() -> datetime.datetime: diff --git a/src/roboto/version.py b/src/roboto/version.py index e7d395b..8be8492 100644 --- a/src/roboto/version.py +++ b/src/roboto/version.py @@ -1,4 +1,4 @@ -__version__ = "0.5.8" +__version__ = "0.5.9" __all__= ("__version__",) \ No newline at end of file