diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..861adde5736f2cb21011fdc3927bb1b8a194ee86 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: local + hooks: + - id: ruff-lint + name: ruff-lint + language: system + entry: ruff check + pass_filenames: false + always_run: true + - id: ruff-format + name: ruff-format + language: system + entry: ruff format --check + pass_filenames: false + always_run: true \ No newline at end of file diff --git a/__init__.py b/__init__.py deleted file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000 diff --git a/dev-requirements.txt b/dev-requirements.txt index 1c987370aa90bfc3b2a5a0ddd6d11527e8c44b57..a4a71c5808094cbc10413dc186e00f0a4ac50ace 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1 +1,3 @@ -pytest==8.3.4 \ No newline at end of file +pytest==8.3.4 +ruff==0.8.3 +pre-commit==4.0.1 diff --git a/events.py b/events.py index f73538747a497901f328ddc622561f1c39ba3cfc..dfcb6314ac805399192f6edafaa910d970215ad3 100644 --- a/events.py +++ b/events.py @@ -7,15 +7,18 @@ from cloudevents.http.event import CloudEvent def get_event_type(cloud_event: CloudEvent) -> str: - return cloud_event.data['message']['attributes']['eventType'] + return cloud_event.data["message"]["attributes"]["eventType"] + def get_event_version(cloud_event: CloudEvent) -> str: - return cloud_event.data['message']['attributes']['eventVersion'] + return cloud_event.data["message"]["attributes"]["eventVersion"] + def _validate_event_type(cloud_event: CloudEvent, expected_event_type: str) -> None: event_type = get_event_type(cloud_event) if event_type != expected_event_type: - raise ValueError(f'Expected event of type {expected_event_type}, got {event_type}') + raise ValueError(f"Expected event of type {expected_event_type}, got {event_type}") + @dataclass class UserPayload: @@ -28,13 +31,13 @@ class UserPayload: location: str def __init__(self, data: dict): - self.id = data['user']['id'] - self.name = data['user']['name'] - self.email = data['user']['email'] - self.identity = data['user']['identity'] - self.avatar = data['user']['avatar'] - self.about_me = data['user']['aboutMe'] - self.location = data['user']['location'] + self.id = data["user"]["id"] + self.name = data["user"]["name"] + self.email = data["user"]["email"] + self.identity = data["user"]["identity"] + self.avatar = data["user"]["avatar"] + self.about_me = data["user"]["aboutMe"] + self.location = data["user"]["location"] @dataclass @@ -46,44 +49,46 @@ class UserEvent: def __init__(self, cloud_event: CloudEvent): self.event_type = get_event_type(cloud_event) self.event_version = get_event_version(cloud_event) - self.user = UserPayload(json.loads(base64.b64decode(cloud_event.data['message']['data']))) + self.user = UserPayload(json.loads(base64.b64decode(cloud_event.data["message"]["data"]))) def __str__(self): return f'{type(self).__name__}(id="{self.user.id}",name="{self.user.name}",email="***",identity="{self.user.identity}",avatar="{self.user.avatar}")' + @dataclass class UserNameUpdatedEvent(UserEvent): def __init__(self, cloud_event: CloudEvent): - _validate_event_type(cloud_event, 'UserNameUpdated') + _validate_event_type(cloud_event, "UserNameUpdated") super().__init__(cloud_event) def as_typesense_document(self) -> object: return { - 'id': f'profile_{self.user.id}', - 'type': 'profile', - 'title_de': self.user.name, - 'title_en': self.user.name, - 'description_de': self.user.about_me, - 'description_en': self.user.about_me, - 'location': self.user.location, - 'location_lat_lng': None, # TODO maybe enrich? - 'image_url': self.user.avatar, - 'link_locators': { - 'profile': self.user.id - } + "id": f"profile_{self.user.id}", + "type": "profile", + "title_de": self.user.name, + "title_en": self.user.name, + "description_de": self.user.about_me, + "description_en": self.user.about_me, + "location": self.user.location, + "location_lat_lng": None, # TODO maybe enrich? + "image_url": self.user.avatar, + "link_locators": {"profile": self.user.id}, } + @dataclass class UserDeletedEvent(UserEvent): def __init__(self, cloud_event: CloudEvent): - _validate_event_type(cloud_event, 'UserDeleted') + _validate_event_type(cloud_event, "UserDeleted") super().__init__(cloud_event) + @dataclass class LatLng: latitude: float longitude: float + @dataclass class SpacePayload: id: str @@ -96,15 +101,24 @@ class SpacePayload: location_lat_lng: LatLng def __init__(self, data: dict): - self.id = data['space']['id'] - self.name = data['space']['name'] - self.slug = data['space']['slug'] - self.avatar = data['space']['avatar'] - self.avatar_default_color = data['space']['avatarDefaultColor'] - self.description = data['space']['description'] - self.location = data['space']['location'] - contains_lat_lng = data['space']['locationLatLng'] is not None and data['space']['locationLatLng']['latitude'] is not None and data['space']['locationLatLng']['longitude'] is not None - self.location_lat_lng = LatLng(data['space']['locationLatLng'].get('latitude'), data['space']['locationLatLng'].get('longitude')) if contains_lat_lng else None + self.id = data["space"]["id"] + self.name = data["space"]["name"] + self.slug = data["space"]["slug"] + self.avatar = data["space"]["avatar"] + self.avatar_default_color = data["space"]["avatarDefaultColor"] + self.description = data["space"]["description"] + self.location = data["space"]["location"] + contains_lat_lng = ( + data["space"]["locationLatLng"] is not None + and data["space"]["locationLatLng"]["latitude"] is not None + and data["space"]["locationLatLng"]["longitude"] is not None + ) + self.location_lat_lng = ( + LatLng(data["space"]["locationLatLng"].get("latitude"), data["space"]["locationLatLng"].get("longitude")) + if contains_lat_lng + else None + ) + @dataclass class SpaceEvent: @@ -115,38 +129,41 @@ class SpaceEvent: def __init__(self, cloud_event: CloudEvent): self.event_type = get_event_type(cloud_event) self.event_version = get_event_version(cloud_event) - self.space = SpacePayload(json.loads(base64.b64decode(cloud_event.data['message']['data']))) + self.space = SpacePayload(json.loads(base64.b64decode(cloud_event.data["message"]["data"]))) def as_typesense_document(self) -> object: return { - 'id': f'space_{self.space.id}', - 'type': 'space', - 'title_de': self.space.name, - 'title_en': self.space.name, - 'description_de': self.space.description, - 'description_en': self.space.description, - 'location': self.space.location, - 'location_lat_lng': [self.space.location_lat_lng.latitude, self.space.location_lat_lng.longitude] if self.space.location_lat_lng is not None else None, - 'image_url': self.space.avatar, - 'link_locators': { - 'space': self.space.slug - } + "id": f"space_{self.space.id}", + "type": "space", + "title_de": self.space.name, + "title_en": self.space.name, + "description_de": self.space.description, + "description_en": self.space.description, + "location": self.space.location, + "location_lat_lng": [self.space.location_lat_lng.latitude, self.space.location_lat_lng.longitude] + if self.space.location_lat_lng is not None + else None, + "image_url": self.space.avatar, + "link_locators": {"space": self.space.slug}, } + @dataclass class SpaceCreatedEvent(SpaceEvent): def __init__(self, cloud_event: CloudEvent): - _validate_event_type(cloud_event, 'SpaceCreated') + _validate_event_type(cloud_event, "SpaceCreated") super().__init__(cloud_event) + @dataclass class SpaceUpdatedEvent(SpaceEvent): def __init__(self, cloud_event: CloudEvent): - _validate_event_type(cloud_event, 'SpaceUpdated') + _validate_event_type(cloud_event, "SpaceUpdated") super().__init__(cloud_event) + @dataclass class SpaceDeletedEvent(SpaceEvent): def __init__(self, cloud_event: CloudEvent): - _validate_event_type(cloud_event, 'SpaceDeleted') - super().__init__(cloud_event) \ No newline at end of file + _validate_event_type(cloud_event, "SpaceDeleted") + super().__init__(cloud_event) diff --git a/main.py b/main.py index 72c7151a198319583bc52df90d66813731b832d1..8e4e34b5c9e103192cc03e8c510afad8553386b6 100644 --- a/main.py +++ b/main.py @@ -1,14 +1,19 @@ -import os import re import functions_framework from typing import Union -from dataclasses import asdict from cloudevents.http.event import CloudEvent from typesense.exceptions import ObjectNotFound -from events import UserNameUpdatedEvent, get_event_type, get_event_version, UserDeletedEvent, SpaceCreatedEvent, \ - SpaceUpdatedEvent, SpaceDeletedEvent +from events import ( + UserNameUpdatedEvent, + get_event_type, + get_event_version, + UserDeletedEvent, + SpaceCreatedEvent, + SpaceUpdatedEvent, + SpaceDeletedEvent, +) from typesense_client import TypesenseClient from logging import getLogger @@ -17,62 +22,69 @@ logger = getLogger(__name__) SEMVER_1XX_SERIES = r"^1\.\d+\.\d+$" + def process_user_name_updated_event(client: TypesenseClient, event: UserNameUpdatedEvent): - logger.debug(f'Processing {event}') + logger.debug(f"Processing {event}") client.upsert(event.as_typesense_document()) - logger.info(f'Upserted user {event.user.id} in Typesense') + logger.info(f"Upserted user {event.user.id} in Typesense") + def process_user_deleted_event(client: TypesenseClient, event: UserDeletedEvent): - logger.debug(f'Processing {event}') + logger.debug(f"Processing {event}") try: client.delete(f"profile_{event.user.id}") except ObjectNotFound: pass - logger.info(f'Deleted user {event.user.id} from Typesense') + logger.info(f"Deleted user {event.user.id} from Typesense") + def process_space_upserting_event(client: TypesenseClient, event: Union[SpaceCreatedEvent, SpaceUpdatedEvent]): - logger.debug(f'Processing {event}') + logger.debug(f"Processing {event}") client.upsert(event.as_typesense_document()) - logger.info(f'Upserted space {event.space.id} in Typesense') + logger.info(f"Upserted space {event.space.id} in Typesense") + def process_space_deleted_event(client: TypesenseClient, event: SpaceDeletedEvent): - logger.debug(f'Processing {event}') + logger.debug(f"Processing {event}") try: client.delete(f"space_{event.space.id}") except ObjectNotFound: pass - logger.info(f'Deleted space {event.space.id} in Typesense') + logger.info(f"Deleted space {event.space.id} in Typesense") + def process_event(client: TypesenseClient, event: CloudEvent): type_version = (get_event_type(event), re.match(SEMVER_1XX_SERIES, get_event_version(event)) is not None) match type_version: - case ('UserNameUpdated', True): + case ("UserNameUpdated", True): process_user_name_updated_event(client, UserNameUpdatedEvent(event)) - case ('UserDeleted', True): + case ("UserDeleted", True): process_user_deleted_event(client, UserDeletedEvent(event)) - case ('UserEmailUpdated', True): + case ("UserEmailUpdated", True): # no need to process, it is sufficient to handle UserNameUpdated - logger.debug('Ignoring UserEmailUpdated event') + logger.debug("Ignoring UserEmailUpdated event") pass - case ('SpaceCreated', True): + case ("SpaceCreated", True): process_space_upserting_event(client, SpaceCreatedEvent(event)) pass - case ('SpaceUpdated', True): + case ("SpaceUpdated", True): process_space_upserting_event(client, SpaceUpdatedEvent(event)) pass - case ('SpaceDeleted', True): + case ("SpaceDeleted", True): process_space_deleted_event(client, SpaceDeletedEvent(event)) pass case (eventType, eventVersion): # UserEmailUpdated: no need to process, it is sufficient to handle UserNameUpdated because this is emitted # during signup and search should not display email addresses (PII) # All other events are assumed to (currently) not be of interest in search - logger.debug(f'Ignoring {eventType} ({eventVersion}) event.') + logger.debug(f"Ignoring {eventType} ({eventVersion}) event.") pass + @functions_framework.cloud_event def process_message(event: CloudEvent): client = TypesenseClient() process_event(client, event) -# TODO ensure that UserUpdated or similar is emitted on updates of fields we're interested in \ No newline at end of file + +# TODO ensure that UserUpdated or similar is emitted on updates of fields we're interested in diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..39705f784cb27e58d676cb8cd24a9ab3a1243cb4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[tool.ruff] +line-length = 119 +extend-exclude = [ + ".docker", + ".docker-cache", + "media", + "migrations", + "reports", + "static", + "terraform", +] + +[tool.ruff.lint] +select = [ + "F", # pyflakes https://docs.astral.sh/ruff/rules/#pyflakes-f + "E", # pycodestyle errors https://docs.astral.sh/ruff/rules/#error-e + "B", # bugbear https://docs.astral.sh/ruff/rules/#flake8-bugbear-b + "S", # bandit https://docs.astral.sh/ruff/rules/#flake8-bandit-s + "N", # pep8-naming https://docs.astral.sh/ruff/rules/#pep8-naming-n + "T20", # forbid print statements https://docs.astral.sh/ruff/rules/#flake8-print-t20 + "ERA", # forbid commented out code https://docs.astral.sh/ruff/rules/#eradicate-era + # "ANN", # enforce type annotations https://docs.astral.sh/ruff/rules/#flake8-annotations-ann +] +extend-ignore = [ + "E501", # "line too long" - only triggers in edge cases of the formatter + "N806", # "non-lowercase-variable-in-function" - we use that to reference model classes in functions +] +[tool.ruff.lint.extend-per-file-ignores] +# S101 use of assert - allowed in tests +"**/test_*.py" = ["S101"] +"**/tests/*.py" = ["S101"] + +[tool.ruff.format] +exclude = [ + ".docker", + ".docker-cache", + ".git", + ".venv", + "media", + "migrations", + "reports", + "static", + "terraform", + "venv", +] diff --git a/test_main.py b/test_main.py index 2793692a11b0e6d01d23f97d18a4d3fcb8a6beb0..61eeef3b4ab136d83a5566a1f22914adf01a0714 100644 --- a/test_main.py +++ b/test_main.py @@ -8,109 +8,128 @@ from cloudevents.http.event import CloudEvent from main import process_event attributes = { - 'id': '12833783708309476', - 'time': '2024-12-07T16:21:48.022Z', - 'specversion': '1.0', - 'datacontenttype': 'application/json', - 'type': 'google.cloud.pubsub.topic.v1.messagePublished', - 'source': '//pubsub.googleapis.com/' + "id": "12833783708309476", + "time": "2024-12-07T16:21:48.022Z", + "specversion": "1.0", + "datacontenttype": "application/json", + "type": "google.cloud.pubsub.topic.v1.messagePublished", + "source": "//pubsub.googleapis.com/", } user_payload = { - 'id': '149a3d29-a0da-4f52-8e1c-660376b7084b', - 'name': 'Daniel holi team', - 'email': 'daniel@holi.team', - 'identity': 'danielholi.team', - 'avatar': 'https://ik.imagekit.io/holi/_DEV_/avatar/149a3d29-a0da-4f52-8e1c-660376b7084b_yv31UW7lD0.jpg', - 'aboutMe': 'Daniel about himself', - 'location': 'Klitmøller, Denmark' + "id": "149a3d29-a0da-4f52-8e1c-660376b7084b", + "name": "Daniel holi team", + "email": "daniel@holi.team", + "identity": "danielholi.team", + "avatar": "https://ik.imagekit.io/holi/_DEV_/avatar/149a3d29-a0da-4f52-8e1c-660376b7084b_yv31UW7lD0.jpg", + "aboutMe": "Daniel about himself", + "location": "Klitmøller, Denmark", } space_payload = { - 'id': '149a3d29-a0da-4f52-8e1c-660376b7084b', - 'name': 'Daniels Space', - 'slug': 'daniels-space', - 'avatar': 'https://ik.imagekit.io/holi/_DEV_/avatar/149a3d29-a0da-4f52-8e1c-660376b7084b_yv31UW7lD0.jpg', - 'avatarDefaultColor': 'ffffff', - 'description': 'This is Daniels Space', - 'location': 'Klitmøller, Denmark', - 'locationLatLng': { - 'latitude': 57.0393497, - 'longitude': 8.474686, - } + "id": "149a3d29-a0da-4f52-8e1c-660376b7084b", + "name": "Daniels Space", + "slug": "daniels-space", + "avatar": "https://ik.imagekit.io/holi/_DEV_/avatar/149a3d29-a0da-4f52-8e1c-660376b7084b_yv31UW7lD0.jpg", + "avatarDefaultColor": "ffffff", + "description": "This is Daniels Space", + "location": "Klitmøller, Denmark", + "locationLatLng": { + "latitude": 57.0393497, + "longitude": 8.474686, + }, } + def message_data(event_type, event_version, data): return { - 'message': { - '@type': 'type.googleapis.com/google.pubsub.v1.PubsubMessage', - 'data': base64.b64encode(json.dumps(data).encode('utf-8')), - 'attributes': { - 'eventType': event_type, - 'eventVersion': event_version, + "message": { + "@type": "type.googleapis.com/google.pubsub.v1.PubsubMessage", + "data": base64.b64encode(json.dumps(data).encode("utf-8")), + "attributes": { + "eventType": event_type, + "eventVersion": event_version, }, - 'messageId': '12833783708309476', - 'publishTime': '2024-12-07T16:21:48.022Z' + "messageId": "12833783708309476", + "publishTime": "2024-12-07T16:21:48.022Z", } -} + } + -@patch('typesense.client.Client') +@patch("typesense.client.Client") def test_user_name_updated(mock_client): - process_event(mock_client, CloudEvent(attributes, message_data('UserNameUpdated', '1.0.0', {'user': user_payload}))) - mock_client.upsert.assert_called_with({ - 'id': f'profile_{user_payload["id"]}', - 'type': 'profile', - 'title_de': user_payload['name'], - 'title_en': user_payload['name'], - 'description_de': user_payload['aboutMe'], - 'description_en': user_payload['aboutMe'], - 'location': user_payload['location'], - 'location_lat_lng': None, - 'image_url': user_payload['avatar'], - 'link_locators': { - 'profile': user_payload['id'], + process_event( + mock_client, CloudEvent(attributes, message_data("UserNameUpdated", "1.0.0", {"user": user_payload})) + ) + mock_client.upsert.assert_called_with( + { + "id": f'profile_{user_payload["id"]}', + "type": "profile", + "title_de": user_payload["name"], + "title_en": user_payload["name"], + "description_de": user_payload["aboutMe"], + "description_en": user_payload["aboutMe"], + "location": user_payload["location"], + "location_lat_lng": None, + "image_url": user_payload["avatar"], + "link_locators": { + "profile": user_payload["id"], + }, } - }) + ) -@patch('typesense.client.Client') + +@patch("typesense.client.Client") def test_user_deleted(mock_client): - process_event(mock_client, CloudEvent(attributes, message_data('UserDeleted', '1.0.0', {'user': user_payload}))) + process_event(mock_client, CloudEvent(attributes, message_data("UserDeleted", "1.0.0", {"user": user_payload}))) mock_client.delete.assert_called_with(f"profile_{user_payload['id']}") + # noinspection DuplicatedCode -@patch('typesense.client.Client') +@patch("typesense.client.Client") def test_space_created(mock_client): - process_event(mock_client, CloudEvent(attributes, message_data('SpaceCreated', '1.0.0', {'space': space_payload}))) - mock_client.upsert.assert_called_with({ - 'id': f'space_{space_payload['id']}', - 'type': 'space', - 'title_de': space_payload['name'], - 'title_en': space_payload['name'], - 'description_de': space_payload['description'], - 'description_en': space_payload['description'], - 'location': space_payload['location'], - 'location_lat_lng': [space_payload['locationLatLng']['latitude'], space_payload['locationLatLng']['longitude']], - 'image_url': space_payload['avatar'], - 'link_locators': { - 'space': space_payload['slug'], + process_event(mock_client, CloudEvent(attributes, message_data("SpaceCreated", "1.0.0", {"space": space_payload}))) + mock_client.upsert.assert_called_with( + { + "id": f'space_{space_payload['id']}', + "type": "space", + "title_de": space_payload["name"], + "title_en": space_payload["name"], + "description_de": space_payload["description"], + "description_en": space_payload["description"], + "location": space_payload["location"], + "location_lat_lng": [ + space_payload["locationLatLng"]["latitude"], + space_payload["locationLatLng"]["longitude"], + ], + "image_url": space_payload["avatar"], + "link_locators": { + "space": space_payload["slug"], + }, } - }) + ) + # noinspection DuplicatedCode -@patch('typesense.client.Client') +@patch("typesense.client.Client") def test_space_updated(mock_client): - process_event(mock_client, CloudEvent(attributes, message_data('SpaceUpdated', '1.0.0', {'space': space_payload}))) - mock_client.upsert.assert_called_with({ - 'id': f'space_{space_payload['id']}', - 'type': 'space', - 'title_de': space_payload['name'], - 'title_en': space_payload['name'], - 'description_de': space_payload['description'], - 'description_en': space_payload['description'], - 'location': space_payload['location'], - 'location_lat_lng': [space_payload['locationLatLng']['latitude'], space_payload['locationLatLng']['longitude']], - 'image_url': space_payload['avatar'], - 'link_locators': { - 'space': space_payload['slug'], + process_event(mock_client, CloudEvent(attributes, message_data("SpaceUpdated", "1.0.0", {"space": space_payload}))) + mock_client.upsert.assert_called_with( + { + "id": f'space_{space_payload['id']}', + "type": "space", + "title_de": space_payload["name"], + "title_en": space_payload["name"], + "description_de": space_payload["description"], + "description_en": space_payload["description"], + "location": space_payload["location"], + "location_lat_lng": [ + space_payload["locationLatLng"]["latitude"], + space_payload["locationLatLng"]["longitude"], + ], + "image_url": space_payload["avatar"], + "link_locators": { + "space": space_payload["slug"], + }, } - }) + ) diff --git a/typesense_client.py b/typesense_client.py index 87502653a777fddf679c1888a75e0e70a8859a16..672fc34eb69b433a7d59dfdfdef1fa9cacd6e209 100644 --- a/typesense_client.py +++ b/typesense_client.py @@ -2,24 +2,28 @@ import os from typesense.client import Client + class TypesenseClient(object): """ Thin wrapper around the Typesense client to simplify call site and testing/mocking code. """ + client: Client def __init__(self): - self.client = Client({ - "api_key": (os.getenv("TYPESENSE_ADMIN_API_KEY")), - "nodes": [ - { - "host": (os.getenv("TYPESENSE_HOST")), - "port": (os.getenv("TYPESENSE_PORT")), - "protocol": (os.getenv("TYPESENSE_PROTOCOL")), - } - ], - "connection_timeout_seconds": 60 * 60, - }) + self.client = Client( + { + "api_key": (os.getenv("TYPESENSE_ADMIN_API_KEY")), + "nodes": [ + { + "host": (os.getenv("TYPESENSE_HOST")), + "port": (os.getenv("TYPESENSE_PORT")), + "protocol": (os.getenv("TYPESENSE_PROTOCOL")), + } + ], + "connection_timeout_seconds": 60 * 60, + } + ) def upsert(self, document) -> object: """ @@ -37,4 +41,4 @@ class TypesenseClient(object): :param id: the id of the document to delete :return: deserialized JSON document """ - return self.client.collections[os.getenv("TYPESENSE_COLLECTION_NAME")].documents[id].delete() \ No newline at end of file + return self.client.collections[os.getenv("TYPESENSE_COLLECTION_NAME")].documents[id].delete()