From 1885c91f8a90966117c2c0c8fd957dfb20a0726f Mon Sep 17 00:00:00 2001 From: MultiJohnen Date: Mon, 12 Feb 2024 21:28:59 +0100 Subject: [PATCH] [UPD] Multiple Changes - Added the `id` and `method` arguments from the `read` method of the `DirecrusResponse` class to the cache key - Changed the translation related methods and the `parse_translations` function - Added translation examples and documentation - Minor code changes --- docs/docs/translations.md | 41 +++++++++++ examples/translations.py | 35 +++++++++ py_directus/__init__.py | 43 ++++++----- py_directus/directus.py | 123 +++++++++++++++++-------------- py_directus/directus_request.py | 30 ++++---- py_directus/directus_response.py | 4 + py_directus/fast_api/lifespan.py | 2 +- py_directus/utils.py | 14 ++-- 8 files changed, 195 insertions(+), 97 deletions(-) create mode 100644 docs/docs/translations.md create mode 100644 examples/translations.py diff --git a/docs/docs/translations.md b/docs/docs/translations.md new file mode 100644 index 0000000..3e1e73a --- /dev/null +++ b/docs/docs/translations.md @@ -0,0 +1,41 @@ +# Custom Translations + +We provide the `get_translations` and `create_translations` methods of the `Directus` class client +for you to retrieve and create new translation records on the `Directus` backend. + +## Retrieve Translations + +Retrieving translation records + +```python +... +# A list of translation records in dictionary format +translations = await directus.get_translations() + +# A dictionary of translation records grouped by the `key` field +# { +# "": { +# "": "" +# } +# } +translations = await directus.get_translations(clean=True) +... +``` + +> Note: The automatic retrieval of all Directus translation records is supported by the `async_init` function +> when the `load_translations` argument is set to `True`. +> You can access the translations from the `pydirectus.translations` global. The global is in `clean` format. + +## Create Translations + +Creating a new translation record + +```python +... +# Register a translation record for the given values (language: 'en-GB') +directus_response = await directus.create_translations("some") + +# Register a translation record for the given values with specific language +directus_response = await directus.create_translations(tuple(["some", "el-GR"])) +... +``` diff --git a/examples/translations.py b/examples/translations.py new file mode 100644 index 0000000..171be85 --- /dev/null +++ b/examples/translations.py @@ -0,0 +1,35 @@ +import asyncio + +from dotenv import dotenv_values + +from py_directus import DirectusUser, Directus + + +config = dotenv_values(".env") + + +async def main(): + # directus = await Directus.create(config["DIRECTUS_URL"], email=config["DIRECTUS_EMAIL"], password=config["DIRECTUS_PASSWORD"]) + directus = await Directus(config["DIRECTUS_URL"], email=config["DIRECTUS_EMAIL"], password=config["DIRECTUS_PASSWORD"]) + + # Retrieving translation records + translations = await directus.get_translations() + print(f"TRANSLATIONS: {translations}") + + # Creating a new translation record + directus_response = await directus.create_translations(tuple(["some", "el-GR"])) + print(f"DIRECTUS RESPONSE: {directus_response}") + + # Retrieving translation records again + translations = await directus.get_translations() + print(f"TRANSLATIONS (AGAIN): {translations}") + + # Logout + await directus.logout() + + # Manually close connection + await directus.close_connection() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/py_directus/__init__.py b/py_directus/__init__.py index 8329bea..1195cc9 100644 --- a/py_directus/__init__.py +++ b/py_directus/__init__.py @@ -14,20 +14,20 @@ except ImportError: pass -DirectusActivity: Type[BaseDirectusActivity] = BaseDirectusActivity -DirectusRevision: Type[BaseDirectusRevision] = BaseDirectusRevision -DirectusRole: Type[BaseDirectusRole] = BaseDirectusRole -DirectusRoles: Type[BaseDirectusRoles] = BaseDirectusRoles -DirectusUser: Type[BaseDirectusUser] = BaseDirectusUser -DirectusFile: Type[BaseDirectusFile] = BaseDirectusFile -DirectusFolder: Type[BaseDirectusFolder] = BaseDirectusFolder -DirectusPermission: Type[BaseDirectusPermission] = BaseDirectusPermission -DirectusRelationSchema: Type[BaseDirectusRelationSchema] = BaseDirectusRelationSchema -DirectusRelationMeta: Type[BaseDirectusRelationMeta] = BaseDirectusRelationMeta -DirectusRelation: Type[BaseDirectusRelation] = BaseDirectusRelation -DirectusSettings: Type[BaseDirectusSettings] = BaseDirectusSettings -DirectusTranslation: Type[BaseDirectusTranslation] = BaseDirectusTranslation -DirectusVersion: Type[BaseDirectusVersion] = BaseDirectusVersion +DirectusActivity: Type['BaseDirectusActivity'] = BaseDirectusActivity +DirectusRevision: Type['BaseDirectusRevision'] = BaseDirectusRevision +DirectusRole: Type['BaseDirectusRole'] = BaseDirectusRole +DirectusRoles: Type['BaseDirectusRoles'] = BaseDirectusRoles +DirectusUser: Type['BaseDirectusUser'] = BaseDirectusUser +DirectusFile: Type['BaseDirectusFile'] = BaseDirectusFile +DirectusFolder: Type['BaseDirectusFolder'] = BaseDirectusFolder +DirectusPermission: Type['BaseDirectusPermission'] = BaseDirectusPermission +DirectusRelationSchema: Type['BaseDirectusRelationSchema'] = BaseDirectusRelationSchema +DirectusRelationMeta: Type['BaseDirectusRelationMeta'] = BaseDirectusRelationMeta +DirectusRelation: Type['BaseDirectusRelation'] = BaseDirectusRelation +DirectusSettings: Type['BaseDirectusSettings'] = BaseDirectusSettings +DirectusTranslation: Type['BaseDirectusTranslation'] = BaseDirectusTranslation +DirectusVersion: Type['BaseDirectusVersion'] = BaseDirectusVersion cached_directus_instances = dict[str, Directus]() @@ -41,6 +41,7 @@ # Public directus directus_public: Optional[Directus] = None +# Directus Translations translations = dict[str, dict[str, str]]() @@ -75,7 +76,7 @@ def rebuild_models(): DirectusVersion.model_rebuild(raise_errors=False) -def setup_models(directus_models: Type[BaseDirectusModels] = BaseDirectusModels): +def setup_models(directus_models: Type['BaseDirectusModels'] = BaseDirectusModels): global DirectusActivity global DirectusRevision global DirectusRole @@ -137,15 +138,16 @@ def setup_models(directus_models: Type[BaseDirectusModels] = BaseDirectusModels) rebuild_models() -async def async_init(directus_base_url: str, directus_admin_token: str = None, - load_translations: bool = False, - directus_models: Type[BaseDirectusModels] = BaseDirectusModels): +async def async_init(directus_base_url: str, directus_admin_token: str = None, + directus_models: Type[BaseDirectusModels] = BaseDirectusModels, + load_translations: bool = False): global directus_admin global directus_public global directus_url global translations + # Setup defaults setup_models(directus_models) directus_url = directus_base_url @@ -154,8 +156,9 @@ async def async_init(directus_base_url: str, directus_admin_token: str = None, if directus_admin_token: directus_admin = await Directus(directus_url, token=directus_admin_token, connection=directus_session) - # if load_translations: - # translations = await directus_public.get_translations()# TODO + # Load Directus translations at startup + if load_translations: + translations = await directus_public.get_translations(clean=True) rebuild_models() diff --git a/py_directus/directus.py b/py_directus/directus.py index 188fb2c..a031e54 100755 --- a/py_directus/directus.py +++ b/py_directus/directus.py @@ -5,7 +5,7 @@ import re from io import BytesIO # import aiofiles -from typing import Union, Optional, Type +from typing import Union, Optional, Type, List, Tuple import magic from httpx import AsyncClient, Auth, Response @@ -22,7 +22,6 @@ try: # from fastapi import UploadFile from starlette.datastructures import UploadFile - except ImportError: pass @@ -43,8 +42,9 @@ class Directus: """ def __init__( - self, url: str, email: str = None, password: str = None, token: str = None, refresh_token: str = None, - connection: AsyncClient = None + self, url: str, email: str = None, password: str = None, + token: str = None, refresh_token: str = None, + connection: AsyncClient = None ): self.expires = None self.expiration_time = None @@ -69,15 +69,7 @@ def __init__( self.cache: Union[SimpleMemoryCache, None] = None # Any async tasks for later gathering - self.tasks: list[DirectusResponse] = [] - - async def gather(self): - """ - Gather all async tasks. - """ - print("Gathering tasks", self.tasks) - await asyncio.gather(*[task.gather_response() for task in self.tasks]) - self.tasks.clear() + self.tasks: List[DirectusResponse] = [] def __await__(self): async def closure(): @@ -92,6 +84,14 @@ async def closure(): return closure().__await__() + async def gather(self): + """ + Gather all async tasks. + """ + print("Gathering tasks", self.tasks) + await asyncio.gather(*[task.gather_response() for task in self.tasks]) + self.tasks.clear() + def collection(self, collection: Union[Type[BaseModel], str]) -> DirectusRequest: """ Set collection to be used. @@ -129,39 +129,6 @@ async def roles(self) -> DirectusResponse: response_obj = await self.collection(py_directus.DirectusRole).read() return response_obj - async def read_settings(self) -> DirectusResponse: - collection_name = py_directus.DirectusSettings.model_config.get("collection", None) - - assert collection_name is not None - - response_obj = await DirectusRequest(self, collection_name).read(method='get') - return response_obj - - async def update_settings(self, data) -> DirectusResponse: - collection_name = py_directus.DirectusSettings.model_config.get("collection", None) - - assert collection_name is not None - - response_obj = await DirectusRequest(self, collection_name).update(None, data) - return response_obj - - async def read_translations(self) -> dict[str, dict[str, str]]: - """ - NOTE: TO BE REDESIGNED - """ - items = await self.collection("translations").fields( - 'key', 'translations.languages_code', 'translations.translation' - ).read().items - - return parse_translations(items) - - async def create_translations(self, keys: list[str]): - """ - NOTE: TO BE REDESIGNED - """ - response_obj = await self.collection("translations").create([{"key": key} for key in keys]) - return response_obj - @classmethod def get_file_url(cls, file_id: str, img_format: Optional[str] = None, **kwargs) -> str: url = f"{py_directus.directus_url}/assets/{file_id}" @@ -274,16 +241,47 @@ async def upload_file(self, to_upload: Union[str, UploadFile], folder: str = Non return DirectusResponse(response) - async def clear_cache(self, clear_all: bool = False): + async def get_translations(self, clean: bool = False) -> dict[str, dict[str, str]]: """ - Clear cache: + Retrieve dictionary with all translation records. - :param clear_all: If set to `True`, absolutely ALL records will be deleted. + :param clean: Returns the records grouped by `key` value when set as `True`. """ - return await self.cache.clear(clear_all) + items = (await self.collection(py_directus.DirectusTranslation).read()).items_as_dict() - async def __aenter__(self): - return self + return parse_translations(items) if clean else items + + async def create_translations(self, *keys: Union[str, Tuple[str, str]]): + """ + Create translation records for given keys. + """ + # Payload + payload = [( + lambda x: ( + {"key": x, "language": "en-GB", "value": ""} + if isinstance(x, str) else + {"key": x[0], "language": x[1], "value": ""} + ) + )(key) for key in keys] + + response_obj = await self.collection(py_directus.DirectusTranslation).create(payload) + return response_obj + + async def read_settings(self) -> DirectusResponse: + collection_name = py_directus.DirectusSettings.model_config.get("collection", None) + + assert collection_name is not None + + response_obj = await DirectusRequest(self, collection_name).read(method='get') + return response_obj + + async def update_settings(self, data) -> DirectusResponse: + collection_name = py_directus.DirectusSettings.model_config.get("collection", None) + + assert collection_name is not None + + response_obj = await DirectusRequest(self, collection_name).update(None, data) + return response_obj @property def token(self): @@ -328,11 +326,6 @@ async def login(self): } await self.auth_request(endpoint, payload) - async def start_cache(self): - assert self._token is not None - - self.cache = SimpleMemoryCache(self._token) - async def refresh(self): endpoint = "auth/refresh" payload = { @@ -342,6 +335,19 @@ async def refresh(self): await self.auth_request(endpoint, payload) await self.clear_cache(True) + async def start_cache(self): + assert self._token is not None + + self.cache = SimpleMemoryCache(self._token) + + async def clear_cache(self, clear_all: bool = False): + """ + Clear cache: + + :param clear_all: If set to `True`, absolutely ALL records will be deleted. + """ + return await self.cache.clear(clear_all) + async def logout(self) -> bool: url = f"{self.url}/auth/logout" response = await self.connection.post(url) @@ -361,6 +367,9 @@ async def logout(self) -> bool: async def close_connection(self): await self.connection.aclose() + async def __aenter__(self): + return self + async def __aexit__(self, *args): print("Closing connection") # Exception handling here diff --git a/py_directus/directus_request.py b/py_directus/directus_request.py index 58b4c0a..8274ea7 100755 --- a/py_directus/directus_request.py +++ b/py_directus/directus_request.py @@ -152,8 +152,8 @@ def include_count(self): return self async def read( - self, id: Optional[Union[int, str]] = None, method: str = "search", cache: bool = False, - as_task: bool = False + self, id: Optional[Union[int, str]] = None, method: str = "search", + cache: bool = False, as_task: bool = False ) -> DirectusResponse: """ Request data. @@ -165,9 +165,12 @@ async def read( :return: The DirectusResponse object - IMPORTANT: cache and as_task cannot be used together, if both are set to True, the cache will take precedence and the request will be awaited. + IMPORTANT: cache and as_task cannot be used together, if both are set to True, + the cache will take precedence and the request will be awaited. """ - cache = False # TODO: Temporary disable until cache is fixed + + method = "get" if id is not None else method + if cache: d_response = await self._read_cache(id=id, method=method) else: @@ -176,14 +179,13 @@ async def read( return d_response async def _read( - self, id: Optional[Union[int, str]] = None, method: str = "search", renew_cache: bool = False, - as_task: bool = False + self, id: Optional[Union[int, str]] = None, method: str = "search", + renew_cache: bool = False, as_task: bool = False ) -> DirectusResponse: """ Send query to server. """ - method = "get" if id is not None else method if method == "search": response = self.directus.connection.request( "search", self.uri, @@ -206,7 +208,7 @@ async def _read( # Check for existing cache and renew it if not renew_cache: async with self._lock_2: - query_key_str = self._get_query_string_key() + query_key_str = self._get_query_string_key(id=id, method=method) # Try to find query in cache if self.directus.cache: @@ -219,13 +221,13 @@ async def _read( return d_response async def _read_cache( - self, id: Optional[Union[int, str]] = None, method: str = "search" + self, id: Optional[Union[int, str]] = None, method: str = "search" ) -> DirectusResponse: """ Get response from cache. """ async with self._lock: - query_key_str = self._get_query_string_key() + query_key_str = self._get_query_string_key(id=id, method=method) # Try to find query in cache cached_response = await self.directus.cache.get(query_key_str) @@ -242,21 +244,21 @@ async def _read_cache( return d_response - async def clear_cache(self): - query_key_str = self._get_query_string_key() + async def clear_cache(self, id: Optional[Union[int, str]] = None, method: str = "search"): + query_key_str = self._get_query_string_key(id=id, method=method) # Try to find query in cache d_res = await self.directus.cache.delete(query_key_str) return d_res - def _get_query_string_key(self): + def _get_query_string_key(self, id: Optional[Union[int, str]] = None, method: str = "search"): """ Generate request key for cache. """ query_str = jsonlib.dumps(self.params) - return f"{self.collection}_{query_str}" + return f"{self.collection}_{id}_{method}_{query_str}" async def subscribe(self, uri: str, event_type: Optional[str] = None, uid: Optional[str] = None) -> Tuple[ 'Data', 'WebSocketClientProtocol']: diff --git a/py_directus/directus_response.py b/py_directus/directus_response.py index d86ade3..baa6915 100755 --- a/py_directus/directus_response.py +++ b/py_directus/directus_response.py @@ -124,6 +124,10 @@ def errors(self) -> list: else: return [] + def __str__(self): + def_str = super().__str__() + return f"{def_str} ({self.status_code})" + def to_json(self): data = { "response_status": self.response_status, diff --git a/py_directus/fast_api/lifespan.py b/py_directus/fast_api/lifespan.py index ad118b2..d840284 100644 --- a/py_directus/fast_api/lifespan.py +++ b/py_directus/fast_api/lifespan.py @@ -15,7 +15,7 @@ async def _lifespan(app: Union['FastAPI', None] = None, directus_base_url: str = """ # Initialize global clients - await glob_vars.async_init(directus_base_url, directus_admin_token) + await glob_vars.async_init(directus_base_url, directus_admin_token=directus_admin_token) try: yield diff --git a/py_directus/utils.py b/py_directus/utils.py index 289cba5..3fba081 100644 --- a/py_directus/utils.py +++ b/py_directus/utils.py @@ -1,4 +1,5 @@ import secrets +import itertools from typing import Union, List RANDOM_STRING_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" @@ -23,12 +24,15 @@ def get_random_string(length=None, allowed_chars=RANDOM_STRING_CHARS): def parse_translations(all_translations: List[dict]) -> Union[dict[str, dict[str, str]], None]: - if all_translations is None or not all_translations: + if not all_translations: return None + grouper = itertools.groupby(all_translations, lambda x: x['key']) + return { - translations['key']: { - translation['languages_code']: translation['translation'] - for translation in translations['translations'] - } for translations in all_translations + f"{key}": { + translation['language']: translation['value'] + for translation in group_recs + + } for key, group_recs in grouper }