From 2a9b7383253daf78e7f00ae060790156a516cdb7 Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Mon, 5 May 2025 17:01:31 -0300 Subject: [PATCH 01/26] Implement caching mechanism and add Dictionaries module with CRUD operations --- src/tagoio_sdk/common/Cache.py | 64 ++++ src/tagoio_sdk/common/Hash_Generator.py | 50 +++ .../modules/Resources/AccountDeprecated.py | 3 + .../modules/Resources/Dictionaries.py | 299 ++++++++++++++++++ .../modules/Resources/Dictionaries_Types.py | 42 +++ src/tagoio_sdk/modules/Resources/Resources.py | 2 + 6 files changed, 460 insertions(+) create mode 100644 src/tagoio_sdk/common/Cache.py create mode 100644 src/tagoio_sdk/common/Hash_Generator.py create mode 100644 src/tagoio_sdk/modules/Resources/Dictionaries.py create mode 100644 src/tagoio_sdk/modules/Resources/Dictionaries_Types.py diff --git a/src/tagoio_sdk/common/Cache.py b/src/tagoio_sdk/common/Cache.py new file mode 100644 index 0000000..808ec21 --- /dev/null +++ b/src/tagoio_sdk/common/Cache.py @@ -0,0 +1,64 @@ +import time +from typing import Dict, Any, Optional, Tuple + +from tagoio_sdk.common.Hash_Generator import generateRequestID + +# Cache structure: {(request_id, expire_timestamp): cached_object} +cache_obj = {} + + +def clear_cache_ttl() -> None: + """ + Clear expired items from the cache + """ + current_time = int(time.time() * 1000) # Current time in milliseconds + expired_keys = [] + + for item in cache_obj.keys(): + if item[1] < current_time: + expired_keys.append(item) + + for key in expired_keys: + del cache_obj[key] + + +def add_cache(request_obj: Dict, obj: Any, ttl_ms: int = 5000) -> None: + """ + Add an object to the cache with specified TTL + + Args: + request_obj: The request object to generate a key from + obj: The object to cache + ttl_ms: Time-to-live in milliseconds (default: 5000) + """ + clear_cache_ttl() + key = generateRequestID(request_obj) + expiration = int(time.time() * 1000) + ttl_ms + cache_obj[(key, expiration)] = obj + + +def get_cache(request_obj: Dict) -> Optional[Any]: + """ + Retrieve an object from the cache if it exists + + Args: + request_obj: The request object to generate a key from + + Returns: + The cached object or None if not found + """ + clear_cache_ttl() + key = generateRequestID(request_obj) + + for item in list(cache_obj.keys()): + if item[0] == key: + return cache_obj[item] + + return None + + +def clear_cache() -> None: + """ + Clear all items from the cache + """ + cache_obj.clear() diff --git a/src/tagoio_sdk/common/Hash_Generator.py b/src/tagoio_sdk/common/Hash_Generator.py new file mode 100644 index 0000000..130d6b0 --- /dev/null +++ b/src/tagoio_sdk/common/Hash_Generator.py @@ -0,0 +1,50 @@ +import json +from typing import Dict, Any + + +def hash_generator(obj: Any) -> int: + """ + Generate a hash from any object by converting it to JSON string + + Args: + obj: Any object that can be serialized to JSON + + Returns: + A 32-bit integer hash + """ + obj_string = json.dumps(obj, sort_keys=True) + + hash_value = 0 + + if len(obj_string) == 0: + return hash_value + + for i in range(len(obj_string)): + char = ord(obj_string[i]) + hash_value = ((hash_value << 5) - hash_value) + char + hash_value = hash_value & 0xFFFFFFFF # Convert to 32bit integer + + return hash_value + + +def generateRequestID(request_obj: Dict) -> int: + """ + Generate a unique ID for a request object + + Args: + request_obj: A dictionary containing request information + + Returns: + A unique request ID as integer + """ + obj_key = { + "url": request_obj.get("url"), + "token": request_obj.get("headers", {}).get("token"), + "params": request_obj.get("params"), + "body": request_obj.get("data") or request_obj.get("body"), + "method": request_obj.get("method"), + } + + request_id = hash_generator(obj_key) + + return request_id diff --git a/src/tagoio_sdk/modules/Resources/AccountDeprecated.py b/src/tagoio_sdk/modules/Resources/AccountDeprecated.py index 322af05..c1fdccd 100644 --- a/src/tagoio_sdk/modules/Resources/AccountDeprecated.py +++ b/src/tagoio_sdk/modules/Resources/AccountDeprecated.py @@ -7,6 +7,7 @@ from .Billing import Billing from .Buckets import Buckets from .Dashboards import Dashboards +from .Dictionaries import Dictionaries from .Devices import Devices from .Integration import Integration from .Profile import Profile @@ -32,6 +33,8 @@ def __init__(self, params: GenericModuleParams): """@deprecated moved to Resources().buckets""" self.dashboards = Dashboards(params) """@deprecated moved to Resources().dashboards""" + self.dictionaries = Dictionaries(params) + """@deprecated moved to Resources().dictionaries""" self.devices = Devices(params) """@deprecated moved to Resources().devices""" self.billing = Billing(params) diff --git a/src/tagoio_sdk/modules/Resources/Dictionaries.py b/src/tagoio_sdk/modules/Resources/Dictionaries.py new file mode 100644 index 0000000..e8c50f7 --- /dev/null +++ b/src/tagoio_sdk/modules/Resources/Dictionaries.py @@ -0,0 +1,299 @@ +from typing import Dict, List, Optional + +from tagoio_sdk.common.Common_Type import GenericID +from tagoio_sdk.common.tagoio_module import TagoIOModule +from tagoio_sdk.modules.Utils.dateParser import dateParser, dateParserList +from tagoio_sdk.modules.Resources.Dictionaries_Types import ( + DictionaryCreateInfo, + DictionaryInfo, + DictionaryQuery, + LanguageData, + LanguageEditData, + LanguageInfoQuery, +) +from tagoio_sdk.common import Cache + + +class Dictionaries(TagoIOModule): + def list(self, queryObj: Optional[DictionaryQuery] = None) -> List[DictionaryInfo]: + """ + @description: + Lists all dictionaries from your application with pagination support. + + @see: + https://help.tago.io/portal/en/kb/articles/487-dictionaries Dictionaries + + @example: + If receive an error "Authorization Denied", check policy **Dictionary** / **Access** in Access Management. + ```python + resources = Resources() + result = resources.dictionaries.list({ + "page": 1, + "fields": ["id", "name", "slug"], + "amount": 10, + "orderBy": ["name", "asc"] + }) + print(result) # [{'id': 'dictionary-id-123', 'name': 'My Dictionary', 'slug': 'DICT'}, ...] + ``` + """ + queryObj = queryObj or {} + + orderBy = "name,asc" + if "orderBy" in queryObj: + orderBy = f"{queryObj['orderBy'][0]},{queryObj['orderBy'][1]}" + + result = self.doRequest( + { + "path": "/dictionary", + "method": "GET", + "params": { + "page": queryObj.get("page", 1), + "fields": queryObj.get("fields", ["id", "name", "slug", "languages"]), + "filter": queryObj.get("filter", {}), + "amount": queryObj.get("amount", 20), + "orderBy": orderBy, + }, + } + ) + + result = dateParserList(result, ["created_at", "updated_at"]) + + return result + + def create(self, dictionaryObj: DictionaryCreateInfo) -> Dict[str, str]: + """ + @description: + Creates a new dictionary in your application. + + @see: + https://help.tago.io/portal/en/kb/articles/489-using-dictionaries-multi-language Using Dictionaries (Multi-Language) + + @example: + If receive an error "Authorization Denied", check policy **Dictionary** / **Create** in Access Management. + ```python + resources = Resources() + result = resources.dictionaries.create({ + "name": "My Dictionary", + "slug": "DICT", + }) + print(result["dictionary"]) # dictionary-id-123 + ``` + """ + result = self.doRequest( + { + "path": "/dictionary", + "method": "POST", + "body": dictionaryObj, + } + ) + + return result + + def edit(self, dictionaryID: GenericID, dictionaryObj: dict) -> str: + """ + @description: + Modifies an existing dictionary's properties. + + @see: + https://help.tago.io/portal/en/kb/articles/489-using-dictionaries-multi-language Using Dictionaries (Multi-Language) + + @example: + If receive an error "Authorization Denied", check policy **Dictionary** / **Edit** in Access Management. + ```python + resources = Resources() + result = resources.dictionaries.edit("dictionary-id-123", { + "name": "Updated Dictionary", + }) + print(result) # Successfully Updated + ``` + """ + result = self.doRequest( + { + "path": f"/dictionary/{dictionaryID}", + "method": "PUT", + "body": dictionaryObj, + } + ) + + return result + + def delete(self, dictionaryID: GenericID) -> str: + """ + @description: + Deletes a dictionary from your application. + + @see: + https://help.tago.io/portal/en/kb/articles/489-using-dictionaries-multi-language Using Dictionaries (Multi-Language) + + @example: + If receive an error "Authorization Denied", check policy **Dictionary** / **Delete** in Access Management. + ```python + resources = Resources() + result = resources.dictionaries.delete("dictionary-id-123") + print(result) # Successfully Removed + ``` + """ + result = self.doRequest( + { + "path": f"/dictionary/{dictionaryID}", + "method": "DELETE", + } + ) + + Cache.clear_cache() + + return result + + def info(self, dictionaryID: GenericID) -> DictionaryInfo: + """ + @description: + Retrieves detailed information about a specific dictionary. + + @see: + https://help.tago.io/portal/en/kb/articles/487-dictionaries Dictionaries + + @example: + If receive an error "Authorization Denied", check policy **Dictionary** / **Access** in Access Management. + ```python + resources = Resources() + result = resources.dictionaries.info("dictionary-id-123") + print(result) # {'id': 'dictionary-id-123', 'name': 'My Dictionary', 'slug': 'DICT', 'languages': ['en-US'], ...} + ``` + """ + result = self.doRequest( + { + "path": f"/dictionary/{dictionaryID}", + "method": "GET", + } + ) + + result = dateParser(result, ["created_at", "updated_at"]) + + return result + + def languageEdit(self, dictionaryID: GenericID, locale: str, languageObj: LanguageEditData) -> str: + """ + @description: + Edits a language's content in a dictionary. + + @see: + https://help.tago.io/portal/en/kb/articles/489-using-dictionaries-multi-language Using Dictionaries (Multi-Language) + + @example: + ```python + resources = Resources() + result = resources.dictionaries.languageEdit("dictionary-id-123", "en-US", { + "dictionary": {"HELLO": "Hello"}, + "active": True + }) + print(result) # Dictionary language Successfully Updated + ``` + """ + result = self.doRequest( + { + "path": f"/dictionary/{dictionaryID}/{locale}", + "method": "PUT", + "body": languageObj, + } + ) + + Cache.clear_cache() + + return result + + def languageDelete(self, dictionaryID: GenericID, locale: str) -> str: + """ + @description: + Removes a language from a dictionary. + + @see: + https://help.tago.io/portal/en/kb/articles/489-using-dictionaries-multi-language Using Dictionaries (Multi-Language) + + @example: + If receive an error "Authorization Denied", check policy **Dictionary** / **Edit** in Access Management. + ```python + resources = Resources() + result = resources.dictionaries.languageDelete("dictionary-id-123", "en-US") + print(result) + ``` + """ + result = self.doRequest( + { + "path": f"/dictionary/{dictionaryID}/{locale}", + "method": "DELETE", + } + ) + + Cache.clear_cache() + + return result + + def languageInfo( + self, dictionaryID: GenericID, locale: str, queryObj: Optional[LanguageInfoQuery] = None + ) -> LanguageData: + """ + @description: + Retrieves language-specific content from a dictionary by ID. + + @see: + https://help.tago.io/portal/en/kb/articles/487-dictionaries Dictionaries + + @example: + If receive an error "Authorization Denied", check policy **Dictionary** / **Access** in Access Management. + ```python + resources = Resources() + result = resources.dictionaries.languageInfo("dictionary-id-123", "en-US", { + "fallback": True + }) + print(result) # {'ACCEPT': 'Accept', 'ACCEPTED': 'Accepted', ...} + ``` + """ + queryObj = queryObj or {} + + result = self.doRequest( + { + "path": f"/dictionary/{dictionaryID}/{locale}", + "method": "GET", + "params": { + # Default to not getting the fallback language info if language is not found + # as this route is mainly used to edit a dictionary + "fallback": queryObj.get("fallback", False), + }, + } + ) + + return result + + def languageInfoBySlug(self, slug: str, locale: str, queryObj: Optional[LanguageInfoQuery] = None) -> LanguageData: + """ + @description: + Retrieves language-specific content from a dictionary by its slug. + + @see: + https://help.tago.io/portal/en/kb/articles/487-dictionaries Dictionaries + + @example: + If receive an error "Authorization Denied", check policy **Dictionary** / **Access** in Access Management. + ```python + resources = Resources() + result = resources.dictionaries.languageInfoBySlug("SLUG", "en-US", { + "fallback": True + }) + print(result) + ``` + """ + queryObj = queryObj or {} + + result = self.doRequest( + { + "path": f"/dictionary/{slug}/{locale}", + "method": "GET", + "params": { + # Default to getting the fallback language info if language is not found + # as this route is mainly used to use the dictionary strings in applications + "fallback": queryObj.get("fallback", True), + }, + } + ) + + return result diff --git a/src/tagoio_sdk/modules/Resources/Dictionaries_Types.py b/src/tagoio_sdk/modules/Resources/Dictionaries_Types.py new file mode 100644 index 0000000..0769c87 --- /dev/null +++ b/src/tagoio_sdk/modules/Resources/Dictionaries_Types.py @@ -0,0 +1,42 @@ +from datetime import datetime +from typing import Dict, List, Literal, Optional, TypedDict + +from tagoio_sdk.common.Common_Type import GenericID, Query + + +class DictionaryCreateInfo(TypedDict): + name: str + slug: str + fallback: str + """First dictionary language E.g "en-US" """ + + +class DictionaryLanguage(TypedDict): + code: str + """Language code E.g "en-US" """ + active: bool + + +class DictionaryInfo(DictionaryCreateInfo): + id: GenericID + languages: List[DictionaryLanguage] + created_at: datetime + updated_at: datetime + + +class LanguageData(TypedDict): + Dict[str, str] + + +class LanguageEditData(TypedDict): + dictionary: LanguageData + active: bool + + +class LanguageInfoQuery(TypedDict, total=False): + fallback: Optional[bool] + + +class DictionaryQuery(Query): + fields: Optional[Literal["name", "slug", "languages", "fallback", "created_at", "updated_at"]] + filter: Optional[DictionaryInfo] diff --git a/src/tagoio_sdk/modules/Resources/Resources.py b/src/tagoio_sdk/modules/Resources/Resources.py index 35cddcd..47c1d73 100644 --- a/src/tagoio_sdk/modules/Resources/Resources.py +++ b/src/tagoio_sdk/modules/Resources/Resources.py @@ -8,6 +8,7 @@ from .Billing import Billing from .Buckets import Buckets from .Dashboards import Dashboards +from .Dictionaries import Dictionaries from .Devices import Devices from .Profile import Profile from .Run import Run @@ -26,6 +27,7 @@ def __init__(self, params: Optional[GenericModuleParams] = None): self.billing = Billing(params) self.buckets = Buckets(params) self.dashboards = Dashboards(params) + self.dictionaries = Dictionaries(params) self.devices = Devices(params) self.profile = Profile(params) self.run = Run(params) From e18a37ff16e21684c4b969e980ab388a4300eff0 Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Mon, 5 May 2025 17:01:38 -0300 Subject: [PATCH 02/26] Add unit tests for Dictionaries module methods in test_dictionaries.py --- tests/Resources/test_dictionaries.py | 277 +++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 tests/Resources/test_dictionaries.py diff --git a/tests/Resources/test_dictionaries.py b/tests/Resources/test_dictionaries.py new file mode 100644 index 0000000..7c69a9c --- /dev/null +++ b/tests/Resources/test_dictionaries.py @@ -0,0 +1,277 @@ +import os +from requests_mock.mocker import Mocker + +from tagoio_sdk.modules.Resources.Resources import Resources +from tagoio_sdk.modules.Resources.Dictionaries_Types import DictionaryInfo, LanguageData + +os.environ["T_ANALYSIS_TOKEN"] = "your_token_value" + + +def mockDictionaryList() -> list[DictionaryInfo]: + return { + "status": True, + "result": [ + { + "id": "dictionary_id_1", + "name": "Dictionary 1", + "slug": "DICT1", + "fallback": "en-US", + "languages": [ + {"code": "en-US", "active": True}, + {"code": "pt-BR", "active": True}, + ], + "created_at": "2023-02-21T15:17:35.759Z", + "updated_at": "2023-02-21T15:17:35.759Z", + }, + { + "id": "dictionary_id_2", + "name": "Dictionary 2", + "slug": "DICT2", + "fallback": "en-US", + "languages": [{"code": "en-US", "active": True}], + "created_at": "2023-02-21T16:17:35.759Z", + "updated_at": "2023-02-21T16:17:35.759Z", + }, + ], + } + + +def mockDictionaryInfo() -> DictionaryInfo: + return { + "status": True, + "result": { + "id": "dictionary_id_1", + "name": "Dictionary 1", + "slug": "DICT1", + "fallback": "en-US", + "languages": [ + {"code": "en-US", "active": True}, + {"code": "pt-BR", "active": True}, + ], + "created_at": "2023-02-21T15:17:35.759Z", + "updated_at": "2023-02-21T15:17:35.759Z", + }, + } + + +def mockLanguageInfo() -> LanguageData: + return { + "status": True, + "result": { + "HELLO": "Hello", + "GOODBYE": "Goodbye", + "WELCOME": "Welcome $0", + }, + } + + +def mockCreateDictionary() -> dict: + return { + "status": True, + "result": {"dictionary": "dictionary_id_new"}, + } + + +def testDictionariesMethodList(requests_mock: Mocker) -> None: + """Test list method of Dictionaries class.""" + mock_response = mockDictionaryList() + requests_mock.get("https://api.tago.io/dictionary", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + query = { + "page": 1, + "fields": ["id", "name", "slug", "languages"], + "amount": 20, + "orderBy": ["name", "asc"], + } + + result = resources.dictionaries.list(query) + + # Check if the result is a list + assert isinstance(result, list) + # Check if the result has the expected items + assert len(result) == 2 + # Check if items have expected properties + assert result[0]["id"] == "dictionary_id_1" + assert result[1]["id"] == "dictionary_id_2" + assert result[0]["slug"] == "DICT1" + assert result[1]["slug"] == "DICT2" + assert len(result[0]["languages"]) == 2 + assert len(result[1]["languages"]) == 1 + + +def testDictionariesMethodCreate(requests_mock: Mocker) -> None: + """Test create method of Dictionaries class.""" + mock_response = mockCreateDictionary() + requests_mock.post("https://api.tago.io/dictionary", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + dictionary_data = { + "name": "New Dictionary", + "slug": "NEWDICT", + "fallback": "en-US", + } + + result = resources.dictionaries.create(dictionary_data) + + # Check if result has expected structure + assert result["dictionary"] == "dictionary_id_new" + + +def testDictionariesMethodEdit(requests_mock: Mocker) -> None: + """Test edit method of Dictionaries class.""" + mock_response = { + "status": True, + "result": "Successfully Updated", + } + + requests_mock.put("https://api.tago.io/dictionary/dictionary_id_1", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + dictionary_data = { + "name": "Updated Dictionary Name", + } + + result = resources.dictionaries.edit("dictionary_id_1", dictionary_data) + + # Check if result has expected message + assert result == "Successfully Updated" + + +def testDictionariesMethodDelete(requests_mock: Mocker) -> None: + """Test delete method of Dictionaries class.""" + mock_response = { + "status": True, + "result": "Successfully Removed", + } + + requests_mock.delete("https://api.tago.io/dictionary/dictionary_id_1", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + result = resources.dictionaries.delete("dictionary_id_1") + + # Check if result has expected message + assert result == "Successfully Removed" + + +def testDictionariesMethodInfo(requests_mock: Mocker) -> None: + """Test info method of Dictionaries class.""" + mock_response = mockDictionaryInfo() + requests_mock.get("https://api.tago.io/dictionary/dictionary_id_1", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + result = resources.dictionaries.info("dictionary_id_1") + + # Check if result has expected properties + assert result["id"] == "dictionary_id_1" + assert result["name"] == "Dictionary 1" + assert result["slug"] == "DICT1" + assert len(result["languages"]) == 2 + assert result["languages"][0]["code"] == "en-US" + assert result["languages"][1]["code"] == "pt-BR" + + +def testDictionariesMethodLanguageEdit(requests_mock: Mocker) -> None: + """Test languageEdit method of Dictionaries class.""" + mock_response = { + "status": True, + "result": "Dictionary language Successfully Updated", + } + + requests_mock.put("https://api.tago.io/dictionary/dictionary_id_1/en-US", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + language_data = { + "dictionary": {"HELLO": "Hello", "GOODBYE": "Goodbye"}, + "active": True, + } + + result = resources.dictionaries.languageEdit("dictionary_id_1", "en-US", language_data) + + # Check if result has expected message + assert result == "Dictionary language Successfully Updated" + + +def testDictionariesMethodLanguageDelete(requests_mock: Mocker) -> None: + """Test languageDelete method of Dictionaries class.""" + mock_response = { + "status": True, + "result": "Successfully Removed", + } + + requests_mock.delete("https://api.tago.io/dictionary/dictionary_id_1/pt-BR", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + result = resources.dictionaries.languageDelete("dictionary_id_1", "pt-BR") + + # Check if result has expected message + assert result == "Successfully Removed" + + +def testDictionariesMethodLanguageInfo(requests_mock: Mocker) -> None: + """Test languageInfo method of Dictionaries class.""" + mock_response = mockLanguageInfo() + requests_mock.get("https://api.tago.io/dictionary/dictionary_id_1/en-US?fallback=False", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + result = resources.dictionaries.languageInfo("dictionary_id_1", "en-US") + + # Check if result has expected properties + assert "HELLO" in result + assert "GOODBYE" in result + assert "WELCOME" in result + assert result["HELLO"] == "Hello" + assert result["WELCOME"] == "Welcome $0" + + +def testDictionariesMethodLanguageInfoWithFallback(requests_mock: Mocker) -> None: + """Test languageInfo method of Dictionaries class with fallback parameter.""" + mock_response = mockLanguageInfo() + requests_mock.get("https://api.tago.io/dictionary/dictionary_id_1/en-US?fallback=True", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + result = resources.dictionaries.languageInfo("dictionary_id_1", "en-US", {"fallback": True}) + + # Check if result has expected properties + assert "HELLO" in result + assert "GOODBYE" in result + assert result["HELLO"] == "Hello" + + +def testDictionariesMethodLanguageInfoBySlug(requests_mock: Mocker) -> None: + """Test languageInfoBySlug method of Dictionaries class.""" + mock_response = mockLanguageInfo() + requests_mock.get("https://api.tago.io/dictionary/DICT1/en-US?fallback=True", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + result = resources.dictionaries.languageInfoBySlug("DICT1", "en-US") + + # Check if result has expected properties + assert "HELLO" in result + assert "GOODBYE" in result + assert result["HELLO"] == "Hello" + + +def testDictionariesMethodLanguageInfoBySlugWithoutFallback(requests_mock: Mocker) -> None: + """Test languageInfoBySlug method of Dictionaries class without fallback.""" + mock_response = mockLanguageInfo() + requests_mock.get("https://api.tago.io/dictionary/DICT1/en-US?fallback=False", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + result = resources.dictionaries.languageInfoBySlug("DICT1", "en-US", {"fallback": False}) + + # Check if result has expected properties + assert "HELLO" in result + assert "GOODBYE" in result + assert result["HELLO"] == "Hello" From 9c8379183fb367322ac0d159f3d84e8c47bf07ac Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Mon, 5 May 2025 17:02:04 -0300 Subject: [PATCH 03/26] Add Dictionaries documentation and update Resources index --- .../Dictionaries/Dictionaries_Type.rst | 103 +++++++ docs/source/Resources/Dictionaries/index.rst | 289 ++++++++++++++++++ docs/source/Resources/index.rst | 1 + 3 files changed, 393 insertions(+) create mode 100644 docs/source/Resources/Dictionaries/Dictionaries_Type.rst create mode 100644 docs/source/Resources/Dictionaries/index.rst diff --git a/docs/source/Resources/Dictionaries/Dictionaries_Type.rst b/docs/source/Resources/Dictionaries/Dictionaries_Type.rst new file mode 100644 index 0000000..9e0d1b2 --- /dev/null +++ b/docs/source/Resources/Dictionaries/Dictionaries_Type.rst @@ -0,0 +1,103 @@ +**Dictionaries Type** +===================== + + +.. _DictionaryCreateInfo: + +DictionaryCreateInfo +------------------- + + **Attributes:** + + | name: str + | Name of the dictionary. + + | slug: str + | Unique identifier slug for the dictionary. + + | fallback: str + | First dictionary language E.g "en-US" + + +.. _DictionaryLanguage: + +DictionaryLanguage +----------------- + + **Attributes:** + + | code: str + | Language code E.g "en-US" + + | active: bool + | Indicates if the language is active. + + +.. _DictionaryInfo: + +DictionaryInfo(:ref:`DictionaryCreateInfo`) +------------- + + **Attributes:** + + | id: :ref:`GenericID` + | Unique identifier for the dictionary. + + | languages: List[:ref:`DictionaryLanguage`] + | List of languages supported by the dictionary. + + | created_at: datetime + | Date and time when the dictionary was created. + + | updated_at: datetime + | Date and time when the dictionary was last updated. + + +.. _LanguageData: + +LanguageData +----------- + + **Attributes:** + + | Dict[str, str] + | Dictionary of key-value pairs for translations. + + +.. _LanguageEditData: + +LanguageEditData +-------------- + + **Attributes:** + + | dictionary: :ref:`LanguageData` + | The dictionary containing the translations. + + | active: bool + | Indicates if the language is active. + + +.. _LanguageInfoQuery: + +LanguageInfoQuery +--------------- + + **Attributes:** + + | fallback: Optional[bool] + | Whether to return fallback language data if requested language is not found. + + +.. _DictionaryQuery: + +DictionaryQuery(:ref:`Query`) +-------------- + + **Attributes:** + + | fields: Optional[Literal["name", "slug", "languages", "fallback", "created_at", "updated_at"]] + | List of fields to include in the query results. + + | filter: Optional[:ref:`DictionaryInfo`] + | Filter criteria for the query. diff --git a/docs/source/Resources/Dictionaries/index.rst b/docs/source/Resources/Dictionaries/index.rst new file mode 100644 index 0000000..ad4bc2b --- /dev/null +++ b/docs/source/Resources/Dictionaries/index.rst @@ -0,0 +1,289 @@ +**Dictionaries** +========== + +Manage dictionaries in your application. + +======== +list +======== + +Lists all dictionaries from your application with pagination support. + +See: `Dictionaries `_ + + **Parameters:** + + | *Optional* **queryObj**: :ref:`DictionaryQuery` + | Query parameters to filter the results. + + .. code-block:: + :caption: **Default queryObj:** + + queryObj = { + "page": 1, + "fields": ["id", "name", "slug", "languages"], + "filter": {}, + "amount": 20, + "orderBy": ["name", "asc"] + } + + **Returns:** + + | list[:ref:`DictionaryInfo`] + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.dictionaries.list({ + "page": 1, + "fields": ["id", "name", "slug"], + "amount": 10, + "orderBy": ["name", "asc"] + }) + print(result) # [{'id': 'dictionary-id-123', 'name': 'My Dictionary', 'slug': 'DICT'}, ...] + + +======== +create +======== + +Creates a new dictionary in your application. + +See: `Using Dictionaries (Multi-Language) `_ + + **Parameters:** + + | **dictionaryObj**: :ref:`DictionaryCreateInfo` + | Dictionary information + + **Returns:** + + | dict + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.dictionaries.create({ + "name": "My Dictionary", + "slug": "DICT", + }) + print(result["dictionary"]) # dictionary-id-123 + + +======== +edit +======== + +Modifies an existing dictionary's properties. + +See: `Using Dictionaries (Multi-Language) `_ + + **Parameters:** + + | **dictionaryID**: str + | Dictionary ID + + | **dictionaryObj**: dict + | Dictionary information to update + + **Returns:** + + | string + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.dictionaries.edit("dictionary-id-123", { + "name": "Updated Dictionary", + }) + print(result) # Successfully Updated + + +======== +delete +======== + +Deletes a dictionary from your application. + +See: `Using Dictionaries (Multi-Language) `_ + + **Parameters:** + + | **dictionaryID**: str + | Dictionary ID + + **Returns:** + + | string + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.dictionaries.delete("dictionary-id-123") + print(result) # Successfully Removed + + +======== +info +======== + +Retrieves detailed information about a specific dictionary. + +See: `Dictionaries `_ + + **Parameters:** + + | **dictionaryID**: str + | Dictionary ID + + **Returns:** + + | :ref:`DictionaryInfo` + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.dictionaries.info("dictionary-id-123") + print(result) # {'id': 'dictionary-id-123', 'name': 'My Dictionary', 'slug': 'DICT', 'languages': ['en-US'], ...} + + +=========== +languageEdit +=========== + +Edits a language's content in a dictionary. + +See: `Using Dictionaries (Multi-Language) `_ + + **Parameters:** + + | **dictionaryID**: str + | Dictionary ID + + | **locale**: str + | Language code + + | **languageObj**: :ref:`LanguageEditData` + | Language data to update + + **Returns:** + + | string + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.dictionaries.languageEdit("dictionary-id-123", "en-US", { + "dictionary": {"HELLO": "Hello"}, + "active": True + }) + print(result) # Dictionary language Successfully Updated + + +=========== +languageDelete +=========== + +Removes a language from a dictionary. + +See: `Using Dictionaries (Multi-Language) `_ + + **Parameters:** + + | **dictionaryID**: str + | Dictionary ID + + | **locale**: str + | Language code + + **Returns:** + + | string + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.dictionaries.languageDelete("dictionary-id-123", "en-US") + print(result) + + +=========== +languageInfo +=========== + +Retrieves language-specific content from a dictionary by ID. + +See: `Dictionaries `_ + + **Parameters:** + + | **dictionaryID**: str + | Dictionary ID + + | **locale**: str + | Language code + + | *Optional* **queryObj**: :ref:`LanguageInfoQuery` + | Query options + + **Returns:** + + | :ref:`LanguageData` + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.dictionaries.languageInfo("dictionary-id-123", "en-US", { + "fallback": True + }) + print(result) # {'ACCEPT': 'Accept', 'ACCEPTED': 'Accepted', ...} + + +================= +languageInfoBySlug +================= + +Retrieves language-specific content from a dictionary by its slug. + +See: `Dictionaries `_ + + **Parameters:** + + | **slug**: str + | Dictionary slug + + | **locale**: str + | Language code + + | *Optional* **queryObj**: :ref:`LanguageInfoQuery` + | Query options + + **Returns:** + + | :ref:`LanguageData` + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.dictionaries.languageInfoBySlug("SLUG", "en-US", { + "fallback": True + }) + print(result) diff --git a/docs/source/Resources/index.rst b/docs/source/Resources/index.rst index 707fc47..9abacce 100644 --- a/docs/source/Resources/index.rst +++ b/docs/source/Resources/index.rst @@ -32,6 +32,7 @@ Instance Billing/index Buckets/index Dashboards/index + Dictionaries/index Devices/index IntegrationNetwork/index Profile/index From 547357bf9dec5aa3e2b7f896f6aa96e237fbb054 Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Mon, 5 May 2025 17:02:12 -0300 Subject: [PATCH 04/26] Fix parameter filtering in _converter_dict_param_filter method to ensure correct key formatting --- src/tagoio_sdk/common/tagoio_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tagoio_sdk/common/tagoio_module.py b/src/tagoio_sdk/common/tagoio_module.py index bf27b1c..8066746 100644 --- a/src/tagoio_sdk/common/tagoio_module.py +++ b/src/tagoio_sdk/common/tagoio_module.py @@ -47,7 +47,7 @@ def _converter_dict_param_filter(self, params: dict) -> None: converted_key = f"filter[{key}][{sub_key}]" params[converted_key] = sub_value else: - params[key] = value + params[f"filter[{key}]"] = value def doRequest(self, params: DoRequestParams) -> dict[str, any]: url = getConnectionURI(self.region)["api"] From 836d3276a3d6984d3ad6e2ab772385358606a35d Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Mon, 5 May 2025 17:06:43 -0300 Subject: [PATCH 05/26] Remove unused Tuple import from Cache.py --- src/tagoio_sdk/common/Cache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tagoio_sdk/common/Cache.py b/src/tagoio_sdk/common/Cache.py index 808ec21..782bac9 100644 --- a/src/tagoio_sdk/common/Cache.py +++ b/src/tagoio_sdk/common/Cache.py @@ -1,5 +1,5 @@ import time -from typing import Dict, Any, Optional, Tuple +from typing import Dict, Any, Optional from tagoio_sdk.common.Hash_Generator import generateRequestID From 0def38fc134b90aac91c9be35da33ae4f1033198 Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Tue, 6 May 2025 16:35:35 -0300 Subject: [PATCH 06/26] Add Files module and integrate into Resources; update TagoIOModule parameters --- src/tagoio_sdk/common/tagoio_module.py | 3 +- .../modules/Resources/AccountDeprecated.py | 3 + src/tagoio_sdk/modules/Resources/Files.py | 615 ++++++++++++++++++ .../modules/Resources/Files_Types.py | 85 +++ src/tagoio_sdk/modules/Resources/Resources.py | 2 + 5 files changed, 707 insertions(+), 1 deletion(-) create mode 100644 src/tagoio_sdk/modules/Resources/Files.py create mode 100644 src/tagoio_sdk/modules/Resources/Files_Types.py diff --git a/src/tagoio_sdk/common/tagoio_module.py b/src/tagoio_sdk/common/tagoio_module.py index 8066746..8656fe2 100644 --- a/src/tagoio_sdk/common/tagoio_module.py +++ b/src/tagoio_sdk/common/tagoio_module.py @@ -1,4 +1,4 @@ -from typing import TypedDict +from typing import Optional, TypedDict from tagoio_sdk.infrastructure.api_request import RequestParams, apiRequest from tagoio_sdk.regions import Regions, getConnectionURI @@ -6,6 +6,7 @@ class DoRequestParams(RequestParams): url: None + maxContentLength: Optional[float] class GenericModuleParams(TypedDict): diff --git a/src/tagoio_sdk/modules/Resources/AccountDeprecated.py b/src/tagoio_sdk/modules/Resources/AccountDeprecated.py index c1fdccd..5558ec2 100644 --- a/src/tagoio_sdk/modules/Resources/AccountDeprecated.py +++ b/src/tagoio_sdk/modules/Resources/AccountDeprecated.py @@ -9,6 +9,7 @@ from .Dashboards import Dashboards from .Dictionaries import Dictionaries from .Devices import Devices +from .Files import Files from .Integration import Integration from .Profile import Profile from .Run import Run @@ -37,6 +38,8 @@ def __init__(self, params: GenericModuleParams): """@deprecated moved to Resources().dictionaries""" self.devices = Devices(params) """@deprecated moved to Resources().devices""" + self.files = Files(params) + """@deprecated moved to Resources().files""" self.billing = Billing(params) """@deprecated moved to Resources().billing""" self.integration = Integration(params) diff --git a/src/tagoio_sdk/modules/Resources/Files.py b/src/tagoio_sdk/modules/Resources/Files.py new file mode 100644 index 0000000..189ad28 --- /dev/null +++ b/src/tagoio_sdk/modules/Resources/Files.py @@ -0,0 +1,615 @@ +import time +from typing import Dict, List, Optional, Any + +from tagoio_sdk.common.Common_Type import GenericID +from tagoio_sdk.common.tagoio_module import TagoIOModule +from tagoio_sdk.modules.Utils.dateParser import dateParserList +from tagoio_sdk.modules.Resources.Files_Types import ( + Base64File, + CopyFiles, + FileListInfo, + FileQuery, + FilesPermission, + MoveFiles, + UploadOptions, +) + + +class Files(TagoIOModule): + """Manage files in TagoIO.""" + + def list(self, queryObj: Optional[FileQuery] = None) -> FileListInfo: + """ + @description: + Lists all files in the application with pagination support. + + @see: + https://help.tago.io/portal/en/kb/articles/127-files Files + + @example: + If receive an error "Authorization Denied", check policy **File** / **Access** in Access Management. + ```python + resources = Resources() + result = resources.files.list({ + "path": "/my/folder", + "quantity": 100 + }) + print(result) # { 'total': 200, 'usage': 0.05, 'files': [{ 'size': 7812, ...}], 'folders': ['my-folder'] } + ``` + """ + queryObj = queryObj or {} + + result = self.doRequest( + { + "path": "/files", + "method": "GET", + "params": { + "path": queryObj.get("path", "/"), + "pagination_token": queryObj.get("paginationToken"), + "qty": queryObj.get("quantity", 300), + }, + } + ) + + if "files" in result: + result["files"] = dateParserList(result["files"], ["last_modified"]) + + return result + + def uploadBase64(self, fileList: List[Base64File]) -> str: + """ + @description: + Uploads base64 encoded files to TagoIO storage. + + @see: + https://help.tago.io/portal/en/kb/articles/140-uploading-files Uploading Files + + @example: + If receive an error "Authorization Denied", check policy **File** / **Upload** in Access Management. + ```python + resources = Resources() + result = resources.files.uploadBase64([{ + "filename": "/my-files/document.pdf", + "file": "base64EncodedContent", + "public": True, + }]) + print(result) + ``` + """ + result = self.doRequest( + { + "path": "/files", + "method": "POST", + "body": fileList, + } + ) + + return result + + def move(self, fileList: List[MoveFiles]) -> str: + """ + @description: + Moves or renames files in TagoIO storage. + + @see: + https://help.tago.io/portal/en/kb/articles/127-files Files + https://help.tago.io/portal/en/kb/articles/140-uploading-files Uploading Files + + @example: + If receive an error "Authorization Denied", check policy **File** / **Edit** in Access Management. + ```python + resources = Resources() + result = resources.files.move([{ + "from": "/old/path/file.txt", + "to": "/new/path/renamed.txt" + }]) + print(result) # Successfully Updated + ``` + """ + # Convert from->to dictionaries to match Python structure where "from" is a keyword + mapped_files = [] + for file_item in fileList: + if hasattr(file_item, "from") and hasattr(file_item, "to"): + mapped_files.append({"from": getattr(file_item, "from"), "to": file_item.to}) + elif "_from" in file_item: + mapped_files.append({"from": file_item["_from"], "to": file_item["to"]}) + else: + # Try to extract from __annotations__ if using that pattern + mapped_files.append(file_item) + + result = self.doRequest( + { + "path": "/files", + "method": "PUT", + "body": mapped_files, + } + ) + + return result + + def copy(self, fileList: List[CopyFiles]) -> str: + """ + @description: + Copies files in TagoIO files. + + @see: + https://help.tago.io/portal/en/kb/articles/127-files Files + https://help.tago.io/portal/en/kb/articles/140-uploading-files Uploading Files + + @example: + ```python + resources = Resources() + result = resources.files.copy([{ + "from": "/source/file.txt", + "to": "/destination/copy.txt" + }]) + print(result) + ``` + """ + # Convert from->to dictionaries to match Python structure + mapped_files = [] + for file_item in fileList: + if hasattr(file_item, "from") and hasattr(file_item, "to"): + mapped_files.append({"from": getattr(file_item, "from"), "to": file_item.to}) + elif "_from" in file_item: + mapped_files.append({"from": file_item["_from"], "to": file_item["to"]}) + else: + # Try to extract from __annotations__ if using that pattern + mapped_files.append(file_item) + + result = self.doRequest( + { + "path": "/files/copy", + "method": "PUT", + "body": mapped_files, + } + ) + + return result + + def delete(self, files: List[str]) -> str: + """ + @description: + Deletes files or folders from TagoIO storage. + + @see: + https://help.tago.io/portal/en/kb/articles/127-files Files + https://help.tago.io/portal/en/kb/articles/140-uploading-files Uploading Files + + @example: + If receive an error "Authorization Denied", check policy **File** / **Upload** in Access Management. + ```python + resources = Resources() + result = resources.files.delete([ + "/path/to/file.txt", + "/folder/to/delete" + ]) + print(result) # Successfully Removed + ``` + """ + result = self.doRequest( + { + "path": "/files", + "method": "DELETE", + "body": files, + } + ) + + return result + + def checkPermission(self, file: str) -> Dict[str, bool]: + """ + @description: + Checks if a file is public or private. + + @see: + https://help.tago.io/portal/en/kb/articles/127-files Files + https://help.tago.io/portal/en/kb/articles/140-uploading-files Uploading Files + + @example: + If receive an error "Authorization Denied", check policy **File** / **Access** in Access Management. + ```python + resources = Resources() + permission = resources.files.checkPermission("/path/to/file.txt") + print(permission["public"]) # True or False + ``` + """ + result = self.doRequest( + { + "path": "/files/permission", + "method": "GET", + "params": { + "file": file, + }, + } + ) + + return result + + def changePermission(self, filesVisibility: List[FilesPermission]) -> str: + """ + @description: + Changes visibility settings for multiple files. + + @see: + https://help.tago.io/portal/en/kb/articles/127-files Files + https://help.tago.io/portal/en/kb/articles/140-uploading-files Uploading Files + + @example: + If receive an error "Authorization Denied", check policy **File** / **Edit** in Access Management. + ```python + resources = Resources() + result = resources.files.changePermission([{ + "file": "/path/to/file.txt", + "public": True + }]) + print(result) # Successfully Updated + ``` + """ + result = self.doRequest( + { + "path": "/files/permission", + "method": "PUT", + "body": filesVisibility, + } + ) + + return result + + def _getPathFromUrl(self, url: str) -> str: + tago_url = url.find(".tago.io/file/") + + if tago_url == -1: + raise ValueError(f"{url} is not a TagoIO files url") + + return url[tago_url + 8 :] + + def getFileURLSigned(self, url: str) -> str: + """ + @description: + Gets a signed URL with temporary authentication token. + + @see: + https://help.tago.io/portal/en/kb/articles/127-files Files + https://help.tago.io/portal/en/kb/articles/140-uploading-files Uploading Files + + @example: + If receive an error "Authorization Denied", check policy **File** / **Access** in Access Management. + ```python + resources = Resources() + signed_url = resources.files.getFileURLSigned("https://api.tago.io/file/...") + print(signed_url) + ``` + """ + path = self._getPathFromUrl(url) + + result = self.doRequest( + { + "path": path, + "method": "GET", + "params": { + "noRedirect": True, + }, + } + ) + + return result + + def getFileMD5(self, url: str) -> str: + """ + @description: + Gets the MD5 hash of a file with authentication for private files. + This hash can be used to verify file integrity. + + @see: + https://help.tago.io/portal/en/kb/articles/127-files Files + https://help.tago.io/portal/en/kb/articles/140-uploading-files Uploading Files + + @example: + If receive an error "Authorization Denied", check policy **File** / **Access** in Access Management + ```python + resources = Resources() + md5_hash = resources.files.getFileMD5("https://storage.tago.io/file/path/document.pdf") + print(md5_hash) # e.g. "d41d8cd98f00b204e9800998ecf8427e" + ``` + """ + path = self._getPathFromUrl(url) + + result = self.doRequest( + { + "path": path, + "method": "GET", + "params": { + "md5": True, + "noRedirect": True, + }, + } + ) + + return result + + def _createMultipartUpload(self, filename: str, options: Optional[UploadOptions] = None) -> Dict[str, Any]: + options = options or {} + dashboard = options.get("dashboard") + widget = options.get("widget") + field_id = options.get("fieldId") + is_public = options.get("isPublic") + content_type = options.get("contentType") + + path = f"/data/files/{dashboard}/{widget}" if dashboard and widget and field_id else "/files" + + result = self.doRequest( + { + "path": path, + "method": "POST", + "body": { + "multipart_action": "start", + "filename": filename, + "public": is_public, + "contentType": content_type, + **({"field_id": field_id} if field_id else {}), + }, + } + ) + + return result + + def _uploadPart( + self, filename: str, upload_id: str, part_number: int, blob: bytes, options: Optional[UploadOptions] = None + ) -> Dict[str, Any]: + options = options or {} + field_id = options.get("fieldId") + dashboard = options.get("dashboard") + widget = options.get("widget") + + path = f"/data/files/{dashboard}/{widget}" if dashboard and widget else "/files" + + # Prepare the multipart form data + form_data = { + "filename": filename, + "upload_id": upload_id, + "part": str(part_number), + "file": (filename, io.BytesIO(blob), "application/octet-stream"), + "multipart_action": "upload", + } + + if field_id: + form_data["field_id"] = field_id + + import io + + files = {"file": (filename, io.BytesIO(blob), "application/octet-stream")} + + headers = {"Content-Type": "multipart/form-data"} + + result = self.doRequest( + { + "path": path, + "method": "POST", + "body": form_data, + "files": files, + "maxContentLength": float("infinity"), + "headers": headers, + } + ) + + return { + "ETag": result["ETag"], + "PartNumber": part_number, + } + + def _addToQueue( + self, + filename: str, + upload_id: GenericID, + part_number: int, + blob: bytes, + options: Optional[UploadOptions], + ) -> Dict[str, Any]: + options = options or {} + max_tries = options.get("maxTriesForEachChunk", 5) + timeout = options.get("timeoutForEachFailedChunk", 2000) + + tries = 0 + + while tries < max_tries: + try: + result = self._uploadPart(filename, upload_id, part_number, blob, options) + return result + except Exception as ex: + if is_limit_error(ex): + raise ValueError(str(ex)) + + time.sleep(timeout / 1000) # Convert ms to seconds + + tries += 1 + if tries >= max_tries: + raise ValueError(f"Could not upload part number {part_number}: {str(ex)}") + + # This should never be reached due to the exception above + return {} + + def _completeMultipartUpload( + self, filename: str, upload_id: str, parts: List[Dict[str, Any]], options: Optional[UploadOptions] = None + ) -> Dict[str, str]: + options = options or {} + field_id = options.get("fieldId") + dashboard = options.get("dashboard") + widget = options.get("widget") + + path = f"/data/files/{dashboard}/{widget}" if dashboard and widget else "/files" + + # Sort parts by part number + parts_ordered = sorted(parts, key=lambda x: x["PartNumber"]) + + headers = {"Content-Type": "multipart/form-data"} + + body = { + "multipart_action": "end", + "upload_id": upload_id, + "filename": filename, + "parts": parts_ordered, + "headers": headers, + } + + if field_id: + body["field_id"] = field_id + + result = self.doRequest( + { + "path": path, + "method": "POST", + "body": body, + } + ) + + return result + + def uploadFile(self, file: bytes, filename: str, options: Optional[UploadOptions] = None) -> Dict[str, str]: + """ + @description: + Uploads a single file to TagoIO using multipart upload. + The file is divided into chunks and uploaded in parallel for better performance. + + @see: + https://help.tago.io/portal/en/kb/articles/127-files Files + https://help.tago.io/portal/en/kb/articles/140-uploading-files Uploading Files + + @example: + If receive an error "Authorization Denied", check policy **File** / **Upload** in Access Management. + ```python + resources = Resources() + with open('myfile.txt', 'rb') as f: + file_data = f.read() + result = resources.files.uploadFile(file_data, "/uploads/myfile.txt", { + "chunkSize": 5 * 1024 * 1024, # 5MB chunks + "onProgress": lambda progress: print(f"Upload progress: {progress}%") + }) + print(result["file"]) # https://api.tago.io/file/.../uploads/myfile.txt + ``` + """ + options = options or {} + MB = 2**20 + + # Setup cancellation if provided + cancelled = False + if options.get("onCancelToken"): + options["onCancelToken"](lambda: setattr(locals(), "cancelled", True)) + + self._is_canceled(cancelled) + + # Start the multipart upload + upload_id = self._createMultipartUpload(filename, options) + + # Calculate chunk sizes + bytes_per_chunk = options.get("chunkSize", 7 * MB) + file_size = len(file) + chunk_amount = (file_size // bytes_per_chunk) + 1 + parts_per_time = 3 + + # Check minimum chunk size for multipart uploads + if chunk_amount > 1 and bytes_per_chunk < 5 * MB: + raise ValueError("Chunk sizes cannot be lower than 5mb if the upload will have multiple parts") + + # Initialize tracking variables + offset_start = 0 + offset_end = bytes_per_chunk + part_number = 1 + error = None + parts = [] + promises = [] + + import threading + + self._is_canceled(cancelled) + + # Function to process chunks and update progress + def process_chunk(offset_start, offset_end, part_number): + try: + sliced = file[offset_start:offset_end] + part_data = self._addToQueue(filename, upload_id, part_number, sliced, options) + parts.append(part_data) + + # Update progress if callback provided + if options.get("onProgress"): + percentage = (len(parts) * 100) / chunk_amount + limited_percentage = min(percentage, 100) + rounded_percentage = round(limited_percentage, 2) + options["onProgress"](rounded_percentage) + + return part_data + except Exception as e: + nonlocal error + error = e + return None + + # Upload each chunk + while offset_start < file_size: + # Check if we're at the maximum parallel uploads + while len(promises) >= parts_per_time: + self._is_canceled(cancelled) + + if error: + raise error + + time.sleep(1) + + # Check if any threads have completed + promises = [p for p in promises if p.is_alive()] + + # Start a new upload thread + thread = threading.Thread(target=process_chunk, args=(offset_start, offset_end, part_number)) + thread.start() + promises.append(thread) + + self._is_canceled(cancelled) + + time.sleep(0.5) + + # Move to the next chunk + offset_start = offset_end + offset_end = offset_start + bytes_per_chunk + part_number += 1 + + # Wait for all uploads to complete + while promises: + self._is_canceled(cancelled) + + if error: + raise error + + time.sleep(1) + + # Update the list of active threads + promises = [p for p in promises if p.is_alive()] + + self._is_canceled(cancelled) + + # Complete the multipart upload with retries + for i in range(3): + try: + return self._completeMultipartUpload(filename, upload_id, parts, options) + except Exception as ex: + if is_limit_error(ex): + raise ValueError(str(ex)) + + time.sleep(1) + if i == 2: # Last attempt failed + raise ex + + # This should never be reached due to the exception above + return {} + + def _is_canceled(self, cancelled: bool): + if cancelled: + raise ValueError("Cancelled request") + + +def is_limit_error(error: Exception) -> bool: + if not hasattr(error, "message") and not isinstance(error, Exception): + return False + + message = str(error) + + # TODO: Use status code instead of string error message when available. + return message.startswith("You have exceeded the maximum limit") diff --git a/src/tagoio_sdk/modules/Resources/Files_Types.py b/src/tagoio_sdk/modules/Resources/Files_Types.py new file mode 100644 index 0000000..58f147c --- /dev/null +++ b/src/tagoio_sdk/modules/Resources/Files_Types.py @@ -0,0 +1,85 @@ +from datetime import datetime +from typing import List, Dict, Literal, Optional, TypedDict, Union, Callable, Any + +from tagoio_sdk.common.Common_Type import GenericID, Query + + +class FileQuery(TypedDict, total=False): + path: str + paginationToken: str + quantity: int + + +class FileInfo(TypedDict): + filename: str + size: int + last_modified: Optional[datetime] + + +class FileListInfo(TypedDict): + files: List[FileInfo] + folders: List[str] + + +class Base64File(TypedDict, total=False): + filename: str + """Name of file""" + file: str + """String of Base64""" + public: bool + """ + Make file public + default: False + """ + + +class CopyFiles(TypedDict): + __annotations__ = {"from": str} + """Using '__annotations__' to define this field because 'from' is a Python reserved keyword.""" + to: str + + +class MoveFiles(TypedDict): + __annotations__ = {"from": str} + """Using '__annotations__' to define this field because 'from' is a Python reserved keyword.""" + to: str + + +class FilesPermission(TypedDict): + file: str + public: bool + + +class UploadOptions(TypedDict, total=False): + maxTriesForEachChunk: int + """the maximum amount of tries to upload each chunk to TagoIO. After this many unsuccessful tries of a single chunk, the upload is aborted""" + timeoutForEachFailedChunk: int + """timeout before trying to upload the same chunk if the request failed""" + contentType: str + """The file's content type. This is optional""" + isPublic: bool + """if the file can be accessed by anybody with a link or not""" + dashboard: str + """ + Dashboard ID. + + Uploading files from a widget requires `dashboard`, `widget`, and `fieldId` to be provided. + """ + widget: str + """ + Widget ID. + + Uploading files from a widget requires `dashboard`, `widget`, and `fieldId` to be provided. + """ + fieldId: str + """ + ID of the field from the widget where the file is selected. + + Uploading files from a widget requires `dashboard`, `widget`, and `fieldId` to be provided. + """ + onCancelToken: Callable[[Callable[[], Any]], Any] + """will provide a cancel token for you to cancel the request""" + chunkSize: int + """the byte size of each chunk sent to TagoIO. This will influence how many requests this function will perform""" + onProgress: Callable[[float], Any] + """will provide the upload percentage for this file""" diff --git a/src/tagoio_sdk/modules/Resources/Resources.py b/src/tagoio_sdk/modules/Resources/Resources.py index 47c1d73..962d97e 100644 --- a/src/tagoio_sdk/modules/Resources/Resources.py +++ b/src/tagoio_sdk/modules/Resources/Resources.py @@ -10,6 +10,7 @@ from .Dashboards import Dashboards from .Dictionaries import Dictionaries from .Devices import Devices +from .Files import Files from .Profile import Profile from .Run import Run from .Integration import Integration @@ -29,6 +30,7 @@ def __init__(self, params: Optional[GenericModuleParams] = None): self.dashboards = Dashboards(params) self.dictionaries = Dictionaries(params) self.devices = Devices(params) + self.files = Files(params) self.profile = Profile(params) self.run = Run(params) self.integration = Integration(params) From 26b4978b63b97b76dfdd0b88647e9e52fc530841 Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Tue, 6 May 2025 16:37:01 -0300 Subject: [PATCH 07/26] Add test suite for Files module with various mock methods and assertions --- tests/Resources/test_files.py | 245 ++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 tests/Resources/test_files.py diff --git a/tests/Resources/test_files.py b/tests/Resources/test_files.py new file mode 100644 index 0000000..11b79c9 --- /dev/null +++ b/tests/Resources/test_files.py @@ -0,0 +1,245 @@ +import os +from typing import Dict +from requests_mock.mocker import Mocker + +from tagoio_sdk.modules.Resources.Resources import Resources +from tagoio_sdk.modules.Resources.Files_Types import FileListInfo + +os.environ["T_ANALYSIS_TOKEN"] = "your_token_value" + + +def mockFileList() -> FileListInfo: + return { + "status": True, + "result": { + "total": 200, + "usage": 0.05, + "files": [ + {"filename": "document.pdf", "size": 7812, "last_modified": "2023-02-21T15:17:35.759Z"}, + {"filename": "image.jpg", "size": 12345, "last_modified": "2023-02-22T10:30:20.123Z"}, + ], + "folders": ["folder1", "folder2"], + }, + } + + +def mockUploadBase64() -> Dict[str, str]: + return {"status": True, "result": "Files successfully uploaded"} + + +def mockCheckPermission() -> Dict[str, bool]: + return {"status": True, "result": {"public": True}} + + +def mockFileURLSigned() -> Dict[str, str]: + return {"status": True, "result": "https://storage.tago.io/file/signed/path/file.txt?token=abc123"} + + +def mockFileMD5() -> Dict[str, str]: + return {"status": True, "result": "d41d8cd98f00b204e9800998ecf8427e"} + + +def mockMultipartStart() -> Dict[str, str]: + return {"status": True, "result": "upload_id_123456"} + + +def mockMultipartUpload() -> Dict[str, str]: + return {"status": True, "result": {"ETag": "part_etag_123"}} + + +def mockMultipartComplete() -> Dict[str, str]: + return {"status": True, "result": {"file": "https://api.tago.io/file/path/to/uploaded_file.txt"}} + + +def testFilesMethodList(requests_mock: Mocker) -> None: + """Test list method of Files class.""" + mock_response = mockFileList() + requests_mock.get("https://api.tago.io/files", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + query = {"path": "/my/folder", "quantity": 100} + + result = resources.files.list(query) + + # Check if result has expected properties + assert "total" in result + assert "usage" in result + assert "files" in result + assert "folders" in result + + # Check specific values + assert result["total"] == 200 + assert result["usage"] == 0.05 + assert len(result["files"]) == 2 + assert len(result["folders"]) == 2 + assert result["files"][0]["filename"] == "document.pdf" + assert result["files"][0]["size"] == 7812 + + +def testFilesMethodUploadBase64(requests_mock: Mocker) -> None: + """Test uploadBase64 method of Files class.""" + mock_response = mockUploadBase64() + requests_mock.post("https://api.tago.io/files", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + files_to_upload = [{"filename": "/my-files/document.pdf", "file": "base64EncodedContent", "public": True}] + + result = resources.files.uploadBase64(files_to_upload) + + # Check if result has expected message + assert result == "Files successfully uploaded" + + +def testFilesMethodMove(requests_mock: Mocker) -> None: + """Test move method of Files class.""" + mock_response = {"status": True, "result": "Successfully Updated"} + + requests_mock.put("https://api.tago.io/files", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + files_to_move = [{"_from": "/old/path/file.txt", "to": "/new/path/renamed.txt"}] + + result = resources.files.move(files_to_move) + + # Check if result has expected message + assert result == "Successfully Updated" + + +def testFilesMethodCopy(requests_mock: Mocker) -> None: + """Test copy method of Files class.""" + mock_response = {"status": True, "result": "Successfully Copied"} + + requests_mock.put("https://api.tago.io/files/copy", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + files_to_copy = [{"_from": "/source/file.txt", "to": "/destination/copy.txt"}] + + result = resources.files.copy(files_to_copy) + + # Check if result has expected message + assert result == "Successfully Copied" + + +def testFilesMethodDelete(requests_mock: Mocker) -> None: + """Test delete method of Files class.""" + mock_response = {"status": True, "result": "Successfully Removed"} + + requests_mock.delete("https://api.tago.io/files", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + files_to_delete = ["/path/to/file.txt", "/folder/to/delete"] + + result = resources.files.delete(files_to_delete) + + # Check if result has expected message + assert result == "Successfully Removed" + + +def testFilesMethodCheckPermission(requests_mock: Mocker) -> None: + """Test checkPermission method of Files class.""" + mock_response = mockCheckPermission() + requests_mock.get("https://api.tago.io/files/permission", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + result = resources.files.checkPermission("/path/to/file.txt") + + # Check if result has expected properties + assert "public" in result + assert result["public"] is True + + +def testFilesMethodChangePermission(requests_mock: Mocker) -> None: + """Test changePermission method of Files class.""" + mock_response = {"status": True, "result": "Successfully Updated"} + + requests_mock.put("https://api.tago.io/files/permission", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + files_visibility = [{"file": "/path/to/file.txt", "public": True}] + + result = resources.files.changePermission(files_visibility) + + # Check if result has expected message + assert result == "Successfully Updated" + + +def testFilesMethodGetFileURLSigned(requests_mock: Mocker) -> None: + """Test getFileURLSigned method of Files class.""" + mock_response = mockFileURLSigned() + file_path = "/path/to/file.txt" + + # Mock the URL with a path that would be extracted by _getPathFromUrl + requests_mock.get(f"https://api.tago.io/file{file_path}", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + # The class would extract this path from the URL + mock_url = f"https://api.tago.io/file{file_path}" + result = resources.files.getFileURLSigned(mock_url) + + # Check if result returns expected URL + assert result == "https://storage.tago.io/file/signed/path/file.txt?token=abc123" + + +def testFilesMethodGetFileMD5(requests_mock: Mocker) -> None: + """Test getFileMD5 method of Files class.""" + mock_response = mockFileMD5() + file_path = "/path/to/file.txt" + + # Mock the URL with a path that would be extracted by _getPathFromUrl + requests_mock.get(f"https://api.tago.io/file{file_path}", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + # The class would extract this path from the URL + mock_url = f"https://api.tago.io/file{file_path}" + result = resources.files.getFileMD5(mock_url) + + # Check if result returns expected MD5 hash + assert result == "d41d8cd98f00b204e9800998ecf8427e" + + +def testFilesMethodUploadFile(requests_mock: Mocker) -> None: + """Test uploadFile method of Files class.""" + # Mock the three API calls made during an upload + requests_mock.post( + "https://api.tago.io/files", + [ + {"json": {"status": True, "result": "upload_id_123456"}, "status_code": 200}, + {"json": {"status": True, "result": {"ETag": "part_etag_123"}}, "status_code": 200}, + { + "json": {"status": True, "result": {"file": "https://api.tago.io/file/path/to/uploaded_file.txt"}}, + "status_code": 200, + }, + ], + ) + + resources = Resources({"token": "your_token_value"}) + + # Create a small file for testing + file_data = b"This is a test file content" + filename = "/uploads/test_file.txt" + + # Mock onProgress callback + progress_values = [] + + def on_progress(progress): + progress_values.append(progress) + + # Upload the file + result = resources.files.uploadFile(file_data, filename, {"onProgress": on_progress}) + + # Check if result has expected properties + assert "file" in result + assert result["file"] == "https://api.tago.io/file/path/to/uploaded_file.txt" + + # Check that progress was reported + assert len(progress_values) > 0 + assert progress_values[-1] == 100.0 From dfbecf7d78ced7fec514abaf7215a94b13b6aeaf Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Tue, 6 May 2025 16:37:41 -0300 Subject: [PATCH 08/26] Add Files Type documentation and update Resources index --- docs/source/Resources/Files/Files_Type.rst | 148 ++++++++++ docs/source/Resources/Files/index.rst | 302 +++++++++++++++++++++ docs/source/Resources/index.rst | 1 + 3 files changed, 451 insertions(+) create mode 100644 docs/source/Resources/Files/Files_Type.rst create mode 100644 docs/source/Resources/Files/index.rst diff --git a/docs/source/Resources/Files/Files_Type.rst b/docs/source/Resources/Files/Files_Type.rst new file mode 100644 index 0000000..b8ffd7e --- /dev/null +++ b/docs/source/Resources/Files/Files_Type.rst @@ -0,0 +1,148 @@ +**Files Type** +=============== + + +.. _FileQuery: + +FileQuery +--------- + + **Attributes:** + + | path: str + | Path to search for files + + | paginationToken: str + | Token for paginated results + + | quantity: int + | Number of files to return + + +.. _FileInfo: + +FileInfo +-------- + + **Attributes:** + + | filename: str + | Name of the file + + | size: int + | Size of the file in bytes + + | last_modified: Optional[datetime] + | Date and time when the file was last modified + + +.. _FileListInfo: + +FileListInfo +----------- + + **Attributes:** + + | files: List[:ref:`FileInfo`] + | List of file information + + | folders: List[str] + | List of folder names + + +.. _Base64File: + +Base64File +--------- + + **Attributes:** + + | filename: str + | Name of file + + | file: str + | String of Base64 + + | public: bool + | Make file public + | default: False + + +.. _CopyFiles: + +CopyFiles +-------- + + **Attributes:** + + | from: str + | Source path of the file to be copied + + | to: str + | Destination path for the copied file + + +.. _MoveFiles: + +MoveFiles +-------- + + **Attributes:** + + | from: str + | Source path of the file to be moved + + | to: str + | Destination path for the moved file + + +.. _FilesPermission: + +FilesPermission +------------- + + **Attributes:** + + | file: str + | Path to the file + + | public: bool + | Whether the file should be publicly accessible + + +.. _UploadOptions: + +UploadOptions +----------- + + **Attributes:** + + | maxTriesForEachChunk: int + | The maximum amount of tries to upload each chunk to TagoIO. After this many unsuccessful tries of a single chunk, the upload is aborted + + | timeoutForEachFailedChunk: int + | Timeout before trying to upload the same chunk if the request failed + + | contentType: str + | The file's content type. This is optional + + | isPublic: bool + | If the file can be accessed by anybody with a link or not + + | dashboard: str + | Dashboard ID. Uploading files from a widget requires `dashboard`, `widget`, and `fieldId` to be provided. + + | widget: str + | Widget ID. Uploading files from a widget requires `dashboard`, `widget`, and `fieldId` to be provided. + + | fieldId: str + | ID of the field from the widget where the file is selected. Uploading files from a widget requires `dashboard`, `widget`, and `fieldId` to be provided. + + | onCancelToken: Callable[[Callable[[], Any]], Any] + | Will provide a cancel token for you to cancel the request + + | chunkSize: int + | The byte size of each chunk sent to TagoIO. This will influence how many requests this function will perform + + | onProgress: Callable[[float], Any] + | Will provide the upload percentage for this file diff --git a/docs/source/Resources/Files/index.rst b/docs/source/Resources/Files/index.rst new file mode 100644 index 0000000..9588b11 --- /dev/null +++ b/docs/source/Resources/Files/index.rst @@ -0,0 +1,302 @@ +**Files** +========== + +Manage files in TagoIO. + +======= +list +======= + +Lists all files in the application with pagination support. + +See: `Files `_ + + **Parameters:** + + | *Optional* **queryObj**: :ref:`FileQuery` + | Query parameters to filter the results. + + .. code-block:: + :caption: **Default queryObj:** + + queryObj = { + "path": "/", + "paginationToken": None, + "quantity": 300, + } + + **Returns:** + + | :ref:`FileListInfo` + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.files.list({ + "path": "/my/folder", + "quantity": 100 + }) + print(result) # { 'total': 200, 'usage': 0.05, 'files': [{ 'size': 7812, ...}], 'folders': ['my-folder'] } + + +============ +uploadBase64 +============ + +Uploads base64 encoded files to TagoIO storage. + +See: `Uploading Files `_ + + **Parameters:** + + | **fileList**: list[:ref:`Base64File`] + | List of files to upload in base64 format + + **Returns:** + + | string + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.files.uploadBase64([{ + "filename": "/my-files/document.pdf", + "file": "base64EncodedContent", + "public": True, + }]) + print(result) + + +======= +move +======= + +Moves or renames files in TagoIO storage. + +See: `Files `_ | `Uploading Files `_ + + **Parameters:** + + | **fileList**: list[:ref:`MoveFiles`] + | List of file paths to move/rename + + **Returns:** + + | string + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.files.move([{ + "from": "/old/path/file.txt", + "to": "/new/path/renamed.txt" + }]) + print(result) # Successfully Updated + + +======= +copy +======= + +Copies files in TagoIO files. + +See: `Files `_ | `Uploading Files `_ + + **Parameters:** + + | **fileList**: list[:ref:`CopyFiles`] + | List of file paths to copy + + **Returns:** + + | string + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.files.copy([{ + "from": "/source/file.txt", + "to": "/destination/copy.txt" + }]) + print(result) + + +======= +delete +======= + +Deletes files or folders from TagoIO storage. + +See: `Files `_ | `Uploading Files `_ + + **Parameters:** + + | **files**: list[str] + | List of file paths to delete + + **Returns:** + + | string + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.files.delete([ + "/path/to/file.txt", + "/folder/to/delete" + ]) + print(result) # Successfully Removed + + +=============== +checkPermission +=============== + +Checks if a file is public or private. + +See: `Files `_ | `Uploading Files `_ + + **Parameters:** + + | **file**: str + | File path to check permissions + + **Returns:** + + | Dict[str, bool] + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + permission = resources.files.checkPermission("/path/to/file.txt") + print(permission["public"]) # True or False + + +================= +changePermission +================= + +Changes visibility settings for multiple files. + +See: `Files `_ | `Uploading Files `_ + + **Parameters:** + + | **filesVisibility**: list[:ref:`FilesPermission`] + | List of file permission settings + + **Returns:** + + | string + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.files.changePermission([{ + "file": "/path/to/file.txt", + "public": True + }]) + print(result) # Successfully Updated + + +================ +getFileURLSigned +================ + +Gets a signed URL with temporary authentication token. + +See: `Files `_ | `Uploading Files `_ + + **Parameters:** + + | **url**: str + | URL of the file to get signed URL + + **Returns:** + + | string + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + signed_url = resources.files.getFileURLSigned("https://api.tago.io/file/...") + print(signed_url) + + +============ +getFileMD5 +============ + +Gets the MD5 hash of a file with authentication for private files. + +See: `Files `_ | `Uploading Files `_ + + **Parameters:** + + | **url**: str + | URL of the file to get MD5 hash + + **Returns:** + + | string + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + md5_hash = resources.files.getFileMD5("https://storage.tago.io/file/path/document.pdf") + print(md5_hash) # e.g. "d41d8cd98f00b204e9800998ecf8427e" + + +============ +uploadFile +============ + +Uploads a single file to TagoIO using multipart upload. + +See: `Files `_ | `Uploading Files `_ + + **Parameters:** + + | **file**: bytes + | Binary file data to upload + + | **filename**: str + | Path and filename for the file + + | *Optional* **options**: :ref:`UploadOptions` + | Options for the file upload + + **Returns:** + + | Dict[str, str] + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + with open('myfile.txt', 'rb') as f: + file_data = f.read() + result = resources.files.uploadFile(file_data, "/uploads/myfile.txt", { + "chunkSize": 5 * 1024 * 1024, # 5MB chunks + "onProgress": lambda progress: print(f"Upload progress: {progress}%") + }) + print(result["file"]) # https://api.tago.io/file/.../uploads/myfile.txt diff --git a/docs/source/Resources/index.rst b/docs/source/Resources/index.rst index 9abacce..5ce2736 100644 --- a/docs/source/Resources/index.rst +++ b/docs/source/Resources/index.rst @@ -34,6 +34,7 @@ Instance Dashboards/index Dictionaries/index Devices/index + Files/index IntegrationNetwork/index Profile/index Run/index From feaeda81578c1733a510252eab6e77b9bebe5b26 Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Tue, 6 May 2025 16:52:40 -0300 Subject: [PATCH 09/26] Refactor file upload handling in Files module and improve UploadOptions documentation --- src/tagoio_sdk/modules/Resources/Files.py | 18 +++++++----------- .../modules/Resources/Files_Types.py | 10 ++++++---- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/tagoio_sdk/modules/Resources/Files.py b/src/tagoio_sdk/modules/Resources/Files.py index 189ad28..a6e2de6 100644 --- a/src/tagoio_sdk/modules/Resources/Files.py +++ b/src/tagoio_sdk/modules/Resources/Files.py @@ -364,21 +364,19 @@ def _uploadPart( path = f"/data/files/{dashboard}/{widget}" if dashboard and widget else "/files" + import io + # Prepare the multipart form data - form_data = { + fields = { "filename": filename, "upload_id": upload_id, "part": str(part_number), - "file": (filename, io.BytesIO(blob), "application/octet-stream"), "multipart_action": "upload", + "file": (filename, io.BytesIO(blob), "application/octet-stream"), } if field_id: - form_data["field_id"] = field_id - - import io - - files = {"file": (filename, io.BytesIO(blob), "application/octet-stream")} + fields["field_id"] = field_id headers = {"Content-Type": "multipart/form-data"} @@ -386,9 +384,7 @@ def _uploadPart( { "path": path, "method": "POST", - "body": form_data, - "files": files, - "maxContentLength": float("infinity"), + "body": fields, "headers": headers, } ) @@ -524,7 +520,7 @@ def uploadFile(self, file: bytes, filename: str, options: Optional[UploadOptions self._is_canceled(cancelled) # Function to process chunks and update progress - def process_chunk(offset_start, offset_end, part_number): + def process_chunk(offset_start: int, offset_end: int, part_number: int) -> Dict[str, Any] | None: try: sliced = file[offset_start:offset_end] part_data = self._addToQueue(filename, upload_id, part_number, sliced, options) diff --git a/src/tagoio_sdk/modules/Resources/Files_Types.py b/src/tagoio_sdk/modules/Resources/Files_Types.py index 58f147c..6f7ff3e 100644 --- a/src/tagoio_sdk/modules/Resources/Files_Types.py +++ b/src/tagoio_sdk/modules/Resources/Files_Types.py @@ -1,7 +1,5 @@ from datetime import datetime -from typing import List, Dict, Literal, Optional, TypedDict, Union, Callable, Any - -from tagoio_sdk.common.Common_Type import GenericID, Query +from typing import List, Optional, TypedDict, Callable, Any class FileQuery(TypedDict, total=False): @@ -52,7 +50,11 @@ class FilesPermission(TypedDict): class UploadOptions(TypedDict, total=False): maxTriesForEachChunk: int - """the maximum amount of tries to upload each chunk to TagoIO. After this many unsuccessful tries of a single chunk, the upload is aborted""" + """ + The maximum amount of tries to upload each chunk to TagoIO. + + After this many unsuccessful tries of a single chunk, the upload is aborted + """ timeoutForEachFailedChunk: int """timeout before trying to upload the same chunk if the request failed""" contentType: str From 86b02cf12502ab4b0cfa43f591c1d15936f216be Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Tue, 6 May 2025 18:40:57 -0300 Subject: [PATCH 10/26] Add IntegrationConnector and IntegrationConnectorType modules with connector management methods --- .../modules/Resources/Integration.py | 2 + .../modules/Resources/IntegrationConnector.py | 166 ++++++++++++++++++ .../Resources/IntegrationConnectorType.py | 65 +++++++ 3 files changed, 233 insertions(+) create mode 100644 src/tagoio_sdk/modules/Resources/IntegrationConnector.py create mode 100644 src/tagoio_sdk/modules/Resources/IntegrationConnectorType.py diff --git a/src/tagoio_sdk/modules/Resources/Integration.py b/src/tagoio_sdk/modules/Resources/Integration.py index 126a8a8..a8e321d 100644 --- a/src/tagoio_sdk/modules/Resources/Integration.py +++ b/src/tagoio_sdk/modules/Resources/Integration.py @@ -1,7 +1,9 @@ from tagoio_sdk.common.tagoio_module import GenericModuleParams, TagoIOModule +from tagoio_sdk.modules.Resources.IntegrationConnector import Connectors from tagoio_sdk.modules.Resources.IntegrationNetwork import Networks class Integration(TagoIOModule): def __init__(self, params: GenericModuleParams): self.networks = Networks(params) + self.connectors = Connectors(params) diff --git a/src/tagoio_sdk/modules/Resources/IntegrationConnector.py b/src/tagoio_sdk/modules/Resources/IntegrationConnector.py new file mode 100644 index 0000000..6f4effe --- /dev/null +++ b/src/tagoio_sdk/modules/Resources/IntegrationConnector.py @@ -0,0 +1,166 @@ +from typing import Dict, List, Optional, Any + +from tagoio_sdk.common.Common_Type import GenericID +from tagoio_sdk.common.tagoio_module import TagoIOModule +from tagoio_sdk.modules.Utils.dateParser import dateParser, dateParserList +from tagoio_sdk.modules.Resources.IntegrationConnectorType import ( + ConnectorCreateInfo, + ConnectorInfo, + ConnectorQuery, +) + + +class Connectors(TagoIOModule): + def list(self, queryObj: Optional[ConnectorQuery] = None) -> List[ConnectorInfo]: + """ + @description: + Lists all connectors from the application with pagination support. + + @see: + https://help.tago.io/portal/en/kb/articles/466-connector-overview Connector Overview + + @example: + If receive an error "Authorization Denied", check policy **Connector** / **Access** in Access Management. + ```python + resources = Resources() + result = resources.integration.connectors.list({ + "page": 1, + "fields": ["id", "name"], + "amount": 10, + "orderBy": ["name", "asc"] + }) + print(result) # [{'id': 'connector-id-123', 'name': 'My Connector'}, ...] + ``` + """ + queryObj = queryObj or {} + + orderBy = "name,asc" + if "orderBy" in queryObj: + orderBy = f"{queryObj['orderBy'][0]},{queryObj['orderBy'][1]}" + + result = self.doRequest( + { + "path": "/integration/connector/", + "method": "GET", + "params": { + "page": queryObj.get("page", 1), + "fields": queryObj.get("fields", ["id", "name"]), + "filter": queryObj.get("filter", {}), + "amount": queryObj.get("amount", 20), + "orderBy": orderBy, + }, + } + ) + + result = dateParserList(result, ["created_at", "updated_at"]) + + return result + + def info(self, connectorID: GenericID, fields: Optional[List[str]] = None) -> ConnectorInfo: + """ + @description: + Retrieves detailed information about a specific connector. + + @see: + https://help.tago.io/portal/en/kb/articles/466-connector-overview Connector Overview + + @example: + If receive an error "Authorization Denied", check policy **Connector** / **Access** in Access Management. + ```python + resources = Resources() + result = resources.integration.connectors.info("connector-id-123", ["id", "name"]) + print(result) # {'id': 'connector-id-123', 'name': 'My Connector', 'profile': 'profile-id-123'} + ``` + """ + result = self.doRequest( + { + "path": f"/integration/connector/{connectorID}", + "method": "GET", + "params": { + "fields": fields, + }, + } + ) + + result = dateParser(result, ["created_at", "updated_at"]) + + return result + + def create(self, connectorObj: ConnectorCreateInfo) -> Dict[str, GenericID]: + """ + @description: + Creates a new connector in the application. + + @see: + https://help.tago.io/portal/en/kb/articles/466-connector-overview#Creating_a_connector Creating a connector + + @example: + ```python + resources = Resources() + result = resources.integration.connectors.create({ + "name": "My Connector", + "type": "custom", + "networks": ["network-id-123"], + "enabled": True + }) + print(result["connector"]) # 'connector-id-123' + ``` + """ + result = self.doRequest( + { + "path": "/integration/connector/", + "method": "POST", + "body": connectorObj, + } + ) + + return result + + def edit(self, connectorID: GenericID, connectorObj: Dict) -> str: + """ + @description: + Modifies an existing connector's properties. + + @see: + https://help.tago.io/portal/en/kb/articles/466-connector-overview Connector Overview + + @example: + ```python + resources = Resources() + result = resources.integration.connectors.edit("connector-id-123", {"name": "Updated Connector"}) + print(result) # Connector Successfully Updated + ``` + """ + result = self.doRequest( + { + "path": f"/integration/connector/{connectorID}", + "method": "PUT", + "body": connectorObj, + } + ) + + return result + + def delete(self, connectorID: str) -> str: + """ + @description: + Deletes a connector from the application. + + @see: + https://help.tago.io/portal/en/kb/articles/466-connector-overview Connector Overview + + @example: + ```python + resources = Resources() + result = resources.integration.connectors.delete("connector-id-123") + print(result) # Connector Successfully Deleted + ``` + """ + result = self.doRequest( + { + "path": f"/integration/connector/{connectorID}", + "method": "DELETE", + } + ) + + return result diff --git a/src/tagoio_sdk/modules/Resources/IntegrationConnectorType.py b/src/tagoio_sdk/modules/Resources/IntegrationConnectorType.py new file mode 100644 index 0000000..90d6ea2 --- /dev/null +++ b/src/tagoio_sdk/modules/Resources/IntegrationConnectorType.py @@ -0,0 +1,65 @@ +from datetime import datetime +from typing import Any, Literal, Optional, List, TypedDict + +from tagoio_sdk.common.Common_Type import GenericID, Query + + +class IDeviceParameters(TypedDict, total=False): + name: Optional[str] + label: Optional[str] + type: Optional[Literal["text", "dropdown", "switch", "number"]] + default: Any + group: Optional[Literal["default", "main", "advanced", "hide"]] + options: Optional[List[Any]] + """Optional only for dropdown""" + + +class ConnectorCreateInfo(TypedDict, total=False): + name: Optional[str] + description: Optional[str] + logo_url: Optional[str] + device_parameters: Optional[List[IDeviceParameters]] + networks: Optional[List[GenericID]] + payload_encoder: Optional[str] + payload_decoder: Optional[str] + """Base64 decoded string""" + install_text: Optional[str] + """Refers to the **description** in the Documentation settings""" + install_end_text: Optional[str] + """Refers to the **completion text** in the Documentation settings""" + device_annotation: Optional[str] + + +class ConnectorInfo(ConnectorCreateInfo): + id: GenericID + public: bool + description: Optional[str] + logo_url: Optional[str] + created_at: datetime + updated_at: datetime + device_parameters: Optional[List[IDeviceParameters]] + networks: Optional[List[GenericID]] + install_text: Optional[str] + """Refers to the **description** in the Documentation settings""" + install_end_text: Optional[str] + """Refers to the **completion text** in the Documentation settings""" + device_annotation: Optional[str] + + +class ConnectorQuery(Query): + fields: Optional[ + List[ + Literal[ + "name", + "id", + "description", + "logo_url", + "install_text", + "install_end_text", + "device_annotation", + "payload_decoder", + "networks", + ] + ] + ] + filter: Optional[ConnectorInfo] From 8ee63cd00fa582b2bdfcb0134e917bc7d80dcd27 Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Tue, 6 May 2025 18:41:07 -0300 Subject: [PATCH 11/26] Add IntegrationConnector documentation and update Resources index --- .../IntegrationConnector_Type.rst | 121 ++++++++++++++ .../Resources/IntegrationConnector/index.rst | 149 ++++++++++++++++++ docs/source/Resources/index.rst | 1 + 3 files changed, 271 insertions(+) create mode 100644 docs/source/Resources/IntegrationConnector/IntegrationConnector_Type.rst create mode 100644 docs/source/Resources/IntegrationConnector/index.rst diff --git a/docs/source/Resources/IntegrationConnector/IntegrationConnector_Type.rst b/docs/source/Resources/IntegrationConnector/IntegrationConnector_Type.rst new file mode 100644 index 0000000..34cd590 --- /dev/null +++ b/docs/source/Resources/IntegrationConnector/IntegrationConnector_Type.rst @@ -0,0 +1,121 @@ +**Integration Connector Type** +============================== + + +.. _IDeviceParameters: + +IDeviceParameters +---------------- + + **Attributes:** + + | **name**: Optional[str] + | Name of the device parameter. + + | **label**: Optional[str] + | Display label for the device parameter. + + | **type**: Optional["text" or "dropdown" or "switch" or "number"] + | The type of input for the device parameter. + + | **default**: Any + | Default value for the device parameter. + + | **group**: Optional["default" or "main" or "advanced" or "hide"] + | Group category for the device parameter. + + | **options**: Optional[List[Any]] + | List of options (Optional only for dropdown). + + +.. _ConnectorCreateInfo: + +ConnectorCreateInfo +------------------ + + **Attributes:** + + | **name**: Optional[str] + | The name of the connector. + + | **description**: Optional[str] + | Description of the connector. + + | **logo_url**: Optional[str] + | URL for the connector's logo image. + + | **device_parameters**: Optional[List[:ref:`IDeviceParameters`]] + | List of device parameters for the connector. + + | **networks**: Optional[List[:ref:`GenericID`]] + | List of network IDs associated with the connector. + + | **payload_encoder**: Optional[str] + | Function to encode payload data. + + | **payload_decoder**: Optional[str] + | Base64 decoded string for parsing payload data. + + | **install_text**: Optional[str] + | Refers to the **description** in the Documentation settings. + + | **install_end_text**: Optional[str] + | Refers to the **completion text** in the Documentation settings. + + | **device_annotation**: Optional[str] + | Additional notes or annotations for the device. + + +.. _ConnectorInfo: + +ConnectorInfo(:ref:`ConnectorCreateInfo`) +------------ + + **Attributes:** + + | **id**: :ref:`GenericID` + | Unique identifier for the connector. + + | **public**: bool + | Indicates if the connector is public. + + | **description**: Optional[str] + | Description of the connector. + + | **logo_url**: Optional[str] + | URL for the connector's logo image. + + | **created_at**: datetime + | Date and time when the connector was created. + + | **updated_at**: datetime + | Date and time when the connector was last updated. + + | **device_parameters**: Optional[List[:ref:`IDeviceParameters`]] + | List of device parameters for the connector. + + | **networks**: Optional[List[:ref:`GenericID`]] + | List of network IDs associated with the connector. + + | **install_text**: Optional[str] + | Refers to the **description** in the Documentation settings. + + | **install_end_text**: Optional[str] + | Refers to the **completion text** in the Documentation settings. + + | **device_annotation**: Optional[str] + | Additional notes or annotations for the device. + + +.. _ConnectorQuery: + +ConnectorQuery(:ref:`Query`) +------------- + + **Attributes:** + + | **fields**: Optional[List["name" or "id" or "description" or "logo_url" or "install_text" or "install_end_text" or "device_annotation" or "payload_decoder" or "networks"]] + | List of fields to include in query results. + + | **filters**: Optional[:ref:`ConnectorInfo`] + | Filter criteria for the connector query. diff --git a/docs/source/Resources/IntegrationConnector/index.rst b/docs/source/Resources/IntegrationConnector/index.rst new file mode 100644 index 0000000..5343bd7 --- /dev/null +++ b/docs/source/Resources/IntegrationConnector/index.rst @@ -0,0 +1,149 @@ +**Integration Connector** +========== + +Manage connectors in your application. + +======== +list +======== + +Lists all connectors from the application with pagination support. + +See: `Connector Overview `_ + + **Parameters:** + + | *Optional* **queryObj**: :ref:`ConnectorQuery` + | Query parameters to filter the results. + + **Returns:** + + | list[:ref:`ConnectorInfo`] + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.integration.connectors.list({ + "page": 1, + "fields": ["id", "name"], + "amount": 10, + "orderBy": ["name", "asc"] + }) + print(result) # [{'id': 'connector-id-123', 'name': 'My Connector'}, ...] + + +======== +info +======== + +Retrieves detailed information about a specific connector. + +See: `Connector Overview `_ + + **Parameters:** + + | **connectorID**: GenericID: str + | Connector ID + + | *Optional* **fields**: List[str] + | List of fields to retrieve + + **Returns:** + + | :ref:`ConnectorInfo` + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.integration.connectors.info("connector-id-123", ["id", "name"]) + print(result) # {'id': 'connector-id-123', 'name': 'My Connector', 'profile': 'profile-id-123'} + + +======== +create +======== + +Creates a new connector in the application. + +See: `Creating a connector `_ + + **Parameters:** + + | **connectorObj**: :ref:`ConnectorCreateInfo` + | Object with connector properties + + **Returns:** + + | dict[str, GenericID] + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.integration.connectors.create({ + "name": "My Connector", + "type": "custom", + "networks": ["network-id-123"], + "enabled": True + }) + print(result["connector"]) # 'connector-id-123' + + +======== +edit +======== + +Modifies an existing connector's properties. + +See: `Connector Overview `_ + + **Parameters:** + + | **connectorID**: GenericID: str + | Connector ID + + | **connectorObj**: Dict + | Object with properties to update + + **Returns:** + + | str + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.integration.connectors.edit("connector-id-123", {"name": "Updated Connector"}) + print(result) # Connector Successfully Updated + + +======== +delete +======== + +Deletes a connector from the application. + +See: `Connector Overview `_ + + **Parameters:** + + | **connectorID**: str + | Connector ID + + **Returns:** + + | str + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.integration.connectors.delete("connector-id-123") + print(result) # Connector Successfully Deleted diff --git a/docs/source/Resources/index.rst b/docs/source/Resources/index.rst index 5ce2736..9325d98 100644 --- a/docs/source/Resources/index.rst +++ b/docs/source/Resources/index.rst @@ -36,5 +36,6 @@ Instance Devices/index Files/index IntegrationNetwork/index + IntegrationConnector/index Profile/index Run/index From 060331a0bdaa699ee295cc8f67740b05d9227e55 Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Tue, 6 May 2025 18:41:16 -0300 Subject: [PATCH 12/26] Add tests for Connectors class methods in test_integration_connector.py --- tests/Resources/test_integration_connector.py | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tests/Resources/test_integration_connector.py diff --git a/tests/Resources/test_integration_connector.py b/tests/Resources/test_integration_connector.py new file mode 100644 index 0000000..178724c --- /dev/null +++ b/tests/Resources/test_integration_connector.py @@ -0,0 +1,139 @@ +import os +from requests_mock.mocker import Mocker + +from tagoio_sdk.modules.Resources.Resources import Resources +from tagoio_sdk.modules.Resources.IntegrationConnector import Connectors + +os.environ["T_ANALYSIS_TOKEN"] = "your_token_value" + + +def mockListConnectors() -> dict: + return { + "status": True, + "result": [ + { + "id": "connector_id_1", + "name": "Connector 1", + "public": True, + "created_at": "2023-01-01T00:00:00.000Z", + "updated_at": "2023-01-01T00:00:00.000Z", + }, + { + "id": "connector_id_2", + "name": "Connector 2", + "public": False, + "created_at": "2023-01-02T00:00:00.000Z", + "updated_at": "2023-01-02T00:00:00.000Z", + }, + ], + } + + +def mockConnectorInfo() -> dict: + return { + "status": True, + "result": { + "id": "connector_id_1", + "name": "Connector 1", + "public": True, + "description": "Test connector description", + "logo_url": "https://example.com/logo.png", + "networks": ["network_id_1", "network_id_2"], + "created_at": "2023-01-01T00:00:00.000Z", + "updated_at": "2023-01-01T00:00:00.000Z", + "enabled": True, + "type": "custom", + "install_text": "Installation instructions", + "install_end_text": "Installation complete", + "device_annotation": "Device annotation", + }, + } + + +def mockCreateConnector() -> dict: + return {"status": True, "result": {"connector": "new_connector_id"}} + + +def testConnectorsMethodList(requests_mock: Mocker) -> None: + """Test list method of Connectors class.""" + mock_response = mockListConnectors() + requests_mock.get("https://api.tago.io/integration/connector/", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + result = resources.integration.connectors.list() + + # Check if result has expected structure + assert len(result) == 2 + assert result[0]["id"] == "connector_id_1" + assert result[1]["id"] == "connector_id_2" + + # Test with query parameters + query = { + "page": 2, + "fields": ["id", "name", "public"], + "amount": 15, + "orderBy": ["name", "desc"], + "filter": {"name": "Test"}, + } + + requests_mock.get("https://api.tago.io/integration/connector/", json=mock_response) + resources.integration.connectors.list(query) + + # We're just checking if the call works with parameters + + +def testConnectorsMethodInfo(requests_mock: Mocker) -> None: + """Test info method of Connectors class.""" + mock_response = mockConnectorInfo() + requests_mock.get("https://api.tago.io/integration/connector/connector_id_1", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + result = resources.integration.connectors.info("connector_id_1") + + # Check if result has expected structure + assert result["id"] == "connector_id_1" + assert result["name"] == "Connector 1" + assert result["description"] == "Test connector description" + assert len(result["networks"]) == 2 + + +def testConnectorsMethodCreate(requests_mock: Mocker) -> None: + """Test create method of Connectors class.""" + mock_response = mockCreateConnector() + requests_mock.post("https://api.tago.io/integration/connector/", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + connector_data = {"name": "New Connector", "type": "custom", "networks": ["network_id_1"], "enabled": True} + + result = resources.integration.connectors.create(connector_data) + + # Check if result has expected structure + assert result["connector"] == "new_connector_id" + + +def testConnectorsMethodEdit(requests_mock: Mocker) -> None: + """Test edit method of Connectors class.""" + mock_response = {"status": True, "result": "Connector Successfully Updated"} + requests_mock.put("https://api.tago.io/integration/connector/connector_id_1", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + edit_data = {"name": "Updated Connector Name", "description": "Updated description"} + + result = resources.integration.connectors.edit("connector_id_1", edit_data) + + # Check if result has expected structure + assert result == "Connector Successfully Updated" + + +def testConnectorsMethodDelete(requests_mock: Mocker) -> None: + """Test delete method of Connectors class.""" + mock_response = {"status": True, "result": "Connector Successfully Deleted"} + requests_mock.delete("https://api.tago.io/integration/connector/connector_id_1", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + result = resources.integration.connectors.delete("connector_id_1") + + # Check if result has expected structure + assert result == "Connector Successfully Deleted" From df94233e3a3680f6496e915687d960bc7c0dfbb2 Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Tue, 6 May 2025 18:41:49 -0300 Subject: [PATCH 13/26] Remove unused import of Any from IntegrationConnector module --- src/tagoio_sdk/modules/Resources/IntegrationConnector.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tagoio_sdk/modules/Resources/IntegrationConnector.py b/src/tagoio_sdk/modules/Resources/IntegrationConnector.py index 6f4effe..bf060e8 100644 --- a/src/tagoio_sdk/modules/Resources/IntegrationConnector.py +++ b/src/tagoio_sdk/modules/Resources/IntegrationConnector.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional, Any +from typing import Dict, List, Optional from tagoio_sdk.common.Common_Type import GenericID from tagoio_sdk.common.tagoio_module import TagoIOModule From 822b7205fc24a87ac5c744ad2efe87955406ed92 Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Thu, 8 May 2025 17:57:14 -0300 Subject: [PATCH 14/26] Add Notifications module and integrate it into Resources and AccountDeprecated --- .../modules/Resources/AccountDeprecated.py | 3 + .../modules/Resources/Notification_Type.py | 18 +- .../modules/Resources/Notifications.py | 246 ++++++++++++++++++ src/tagoio_sdk/modules/Resources/Resources.py | 2 + 4 files changed, 264 insertions(+), 5 deletions(-) create mode 100644 src/tagoio_sdk/modules/Resources/Notifications.py diff --git a/src/tagoio_sdk/modules/Resources/AccountDeprecated.py b/src/tagoio_sdk/modules/Resources/AccountDeprecated.py index 5558ec2..67d3188 100644 --- a/src/tagoio_sdk/modules/Resources/AccountDeprecated.py +++ b/src/tagoio_sdk/modules/Resources/AccountDeprecated.py @@ -10,6 +10,7 @@ from .Dictionaries import Dictionaries from .Devices import Devices from .Files import Files +from .Notifications import Notifications from .Integration import Integration from .Profile import Profile from .Run import Run @@ -40,6 +41,8 @@ def __init__(self, params: GenericModuleParams): """@deprecated moved to Resources().devices""" self.files = Files(params) """@deprecated moved to Resources().files""" + self.notifications = Notifications(params) + """@deprecated moved to Resources().notifications""" self.billing = Billing(params) """@deprecated moved to Resources().billing""" self.integration = Integration(params) diff --git a/src/tagoio_sdk/modules/Resources/Notification_Type.py b/src/tagoio_sdk/modules/Resources/Notification_Type.py index e1446f7..b74ea92 100644 --- a/src/tagoio_sdk/modules/Resources/Notification_Type.py +++ b/src/tagoio_sdk/modules/Resources/Notification_Type.py @@ -1,7 +1,7 @@ from datetime import datetime -from typing import Any, Literal, Optional, TypedDict, Union +from typing import Any, Dict, List, Literal, Optional, TypedDict, Union -from tagoio_sdk.common.Common_Type import GenericID +from tagoio_sdk.common.Common_Type import GenericID, Query HexColor = str @@ -53,13 +53,21 @@ class NotificationCreate(TypedDict): buttons_autodisable: Optional[bool] -class NotificationInfoBasic(TypedDict): +# TODO: FIX: The filter condition read is not working. +class NotificationQuery(Query): + fields: Optional[List[Literal["created_at"]]] + filter: Optional[Dict[Literal["read"], bool]] + + +class NotificationInfo(NotificationCreate): id: GenericID created_at: datetime -class NotificationCreateReturn(TypedDict): +class NotificationInfoBasic(TypedDict): id: GenericID + created_at: datetime -NotificationInfo = NotificationInfoBasic and NotificationCreate +class NotificationCreateReturn(TypedDict): + id: GenericID diff --git a/src/tagoio_sdk/modules/Resources/Notifications.py b/src/tagoio_sdk/modules/Resources/Notifications.py new file mode 100644 index 0000000..69be3fc --- /dev/null +++ b/src/tagoio_sdk/modules/Resources/Notifications.py @@ -0,0 +1,246 @@ +from typing import List, Optional, Union + +from tagoio_sdk.common.Common_Type import GenericID, GenericToken +from tagoio_sdk.common.tagoio_module import TagoIOModule +from tagoio_sdk.modules.Resources.Notification_Type import NotificationCreate, NotificationInfo, NotificationQuery +from tagoio_sdk.modules.Utils.dateParser import dateParserList + + +class Notifications(TagoIOModule): + def list(self, queryObj: Optional[NotificationQuery] = None) -> List[NotificationInfo]: + """ + @description: + Retrieves all notifications from the application with optional filtering. + + @see: + https://help.tago.io/portal/en/kb/articles/11-notification Notification + + @example: + If receive an error "Authorization Denied", check policy **Profile** / **Access notification** in Access Management. + ```python + resources = Resources() + result = resources.notifications.list({"filter": {"read": False}, "amount": 10}) + print(result) # [{'id': 'notification-id-123', 'title': 'System Update', 'message': 'Features', ...}] + ``` + """ + result = self.doRequest( + { + "path": "/notification/", + "method": "GET", + "params": queryObj or {}, + } + ) + + result = dateParserList(result, ["created_at"]) + + return result + + def markAsRead(self, notificationIDS: Union[GenericID, List[GenericID]]) -> str: + """ + @description: + Marks one or multiple notifications as read. + + @see: + https://help.tago.io/portal/en/kb/articles/11-notification Notification + + @example: + If receive an error "Authorization Denied", check policy **Profile** / **Edit notification** in Access Management. + ```python + resources = Resources() + # Mark single notification + resources.notifications.markAsRead("notification-id-123") + + # Mark multiple notifications + resources.notifications.markAsRead(["id-1", "id-2"]) + ``` + """ + if not isinstance(notificationIDS, list): + notificationIDS = [notificationIDS] + + result = self.doRequest( + { + "path": "/notification/read", + "method": "PUT", + "body": { + "notification_ids": notificationIDS, + "read": True, + }, + } + ) + + return result + + def markAsUnread(self, notificationIDS: Union[GenericID, List[GenericID]]) -> str: + """ + @description: + Marks one or multiple notifications as unread. + + @see: + https://help.tago.io/portal/en/kb/articles/11-notification Notification + + @example: + If receive an error "Authorization Denied", check policy **Profile** / **Edit notification** in Access Management. + ```python + resources = Resources() + # Mark single notification + resources.notifications.markAsUnread("notification-id-123") + + # Mark multiple notifications + resources.notifications.markAsUnread(["id-1", "id-2"]) + ``` + """ + if not isinstance(notificationIDS, list): + notificationIDS = [notificationIDS] + + result = self.doRequest( + { + "path": "/notification/read", + "method": "PUT", + "body": { + "notification_ids": notificationIDS, + "read": False, + }, + } + ) + + return result + + def markAllAsRead(self) -> str: + """ + @description: + Marks all notifications in the application as read. + + @see: + https://help.tago.io/portal/en/kb/articles/11-notification Notification + + @example: + If receive an error "Authorization Denied", check policy **Profile** / **Edit notification** in Access Management. + ```python + resources = Resources() + result = resources.notifications.markAllAsRead() + print(result) # All TagoIO Notification Run Successfully Updated + ``` + """ + result = self.doRequest( + { + "path": "/notification/markallread", + "method": "PUT", + } + ) + + return result + + def notificationButton(self, notificationID: GenericID, buttonID: str) -> str: + """ + @description: + Records when a notification button is pressed by the user. + + @see: + https://help.tago.io/portal/en/kb/articles/11-notification Notification + + @example: + If receive an error "Authorization Denied", check policy **Profile** / **Edit notification** in Access Management. + ```python + resources = Resources() + result = resources.notifications.notificationButton("notification-123", "button-456") + print(result) + ``` + """ + result = self.doRequest( + { + "path": f"/notification/{notificationID}/{buttonID}", + "method": "PUT", + } + ) + + return result + + def create(self, notificationData: NotificationCreate) -> dict: + """ + @description: + Creates a new notification in the system. + + @see: + https://help.tago.io/portal/en/kb/articles/11-notification Notification + + @example: + If receive an error "Authorization Denied", check policy **Profile** / **Create notification** in Access Management. + ```python + resources = Resources() + result = resources.notifications.create({"title": "System Update", "message": "New features available"}) + print(result["id"]) # notification-id-123 + ``` + """ + result = self.doRequest( + { + "path": "/notification", + "method": "POST", + "body": {**notificationData}, + } + ) + + return result + + def remove(self, notificationID: GenericID) -> str: + """ + @description: + Permanently deletes a notification from the system. + + @see: + https://help.tago.io/portal/en/kb/articles/11-notification Notification + + @example: + If receive an error "Authorization Denied", check policy **Profile** / **Delete notification** in Access Management. + ```python + resources = Resources() + result = resources.notifications.remove("notification-123") + print(result) # Successfully Removed + ``` + """ + result = self.doRequest( + { + "path": f"/notification/{notificationID}", + "method": "DELETE", + } + ) + + return result + + def registerDevice(self, deviceToken: GenericToken, platform: str) -> str: + """ + @description: + Registers a mobile device for push notifications. + @note: + **This is used internally for mobile applications** + """ + result = self.doRequest( + { + "path": "/notification/push/register", + "method": "POST", + "body": { + "device_token": deviceToken, + "platform": platform, + }, + } + ) + + return result + + def unRegisterDevice(self, deviceToken: GenericToken) -> str: + """ + @description: + Removes a mobile device from push notification service. + @note: + **This is used internally for mobile applications** + """ + result = self.doRequest( + { + "path": "/notification/push/unregister", + "method": "POST", + "body": { + "device_token": deviceToken, + }, + } + ) + + return result diff --git a/src/tagoio_sdk/modules/Resources/Resources.py b/src/tagoio_sdk/modules/Resources/Resources.py index 962d97e..d7c3a33 100644 --- a/src/tagoio_sdk/modules/Resources/Resources.py +++ b/src/tagoio_sdk/modules/Resources/Resources.py @@ -11,6 +11,7 @@ from .Dictionaries import Dictionaries from .Devices import Devices from .Files import Files +from .Notifications import Notifications from .Profile import Profile from .Run import Run from .Integration import Integration @@ -31,6 +32,7 @@ def __init__(self, params: Optional[GenericModuleParams] = None): self.dictionaries = Dictionaries(params) self.devices = Devices(params) self.files = Files(params) + self.notifications = Notifications(params) self.profile = Profile(params) self.run = Run(params) self.integration = Integration(params) From a5d2495a514e951704cfc75d9349e20c4d2853c3 Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Thu, 8 May 2025 17:57:18 -0300 Subject: [PATCH 15/26] Add tests for Notifications class methods in test_notifications.py --- tests/Resources/test_notifications.py | 164 ++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 tests/Resources/test_notifications.py diff --git a/tests/Resources/test_notifications.py b/tests/Resources/test_notifications.py new file mode 100644 index 0000000..4297dde --- /dev/null +++ b/tests/Resources/test_notifications.py @@ -0,0 +1,164 @@ +import os +from requests_mock.mocker import Mocker + +from tagoio_sdk.modules.Resources.Resources import Resources +from tagoio_sdk.modules.Resources.Notification_Type import NotificationInfo + +os.environ["T_ANALYSIS_TOKEN"] = "your_token_value" + + +def mockNotificationList() -> list[NotificationInfo]: + return { + "status": True, + "result": [ + { + "id": "notification_id_1", + "title": "System Update", + "message": "New features available", + "read": False, + "created_at": "2023-06-15T10:00:00.000Z", + }, + { + "id": "notification_id_2", + "title": "Maintenance", + "message": "Scheduled maintenance", + "read": True, + "created_at": "2023-06-14T14:30:00.000Z", + }, + ], + } + + +def mockCreateNotification() -> dict: + return { + "status": True, + "result": {"id": "new_notification_id"}, + } + + +def testNotificationsMethodList(requests_mock: Mocker) -> None: + """Test list method of Notifications class.""" + mock_response = mockNotificationList() + requests_mock.get("https://api.tago.io/notification/", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + result = resources.notifications.list() + + # Check if result has expected structure + assert len(result) == 2 + assert result[0]["id"] == "notification_id_1" + assert result[1]["id"] == "notification_id_2" + + # Test with query parameters + query = {"read": False, "amount": 10} + + requests_mock.get("https://api.tago.io/notification/", json=mock_response) + result = resources.notifications.list(query) + + assert len(result) == 2 + + +def testNotificationsMethodMarkAsRead(requests_mock: Mocker) -> None: + """Test markAsRead method of Notifications class.""" + mock_response = {"status": True, "result": "TagoIO Notification Run Successfully Updated"} + requests_mock.put("https://api.tago.io/notification/read", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + # Test with single notification ID + result = resources.notifications.markAsRead("notification_id_1") + assert result == "TagoIO Notification Run Successfully Updated" + + # Test with multiple notification IDs + result = resources.notifications.markAsRead(["notification_id_1", "notification_id_2"]) + assert result == "TagoIO Notification Run Successfully Updated" + + +def testNotificationsMethodMarkAsUnread(requests_mock: Mocker) -> None: + """Test markAsUnread method of Notifications class.""" + mock_response = {"status": True, "result": "TagoIO Notification Run Successfully Updated"} + requests_mock.put("https://api.tago.io/notification/read", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + # Test with single notification ID + result = resources.notifications.markAsUnread("notification_id_1") + assert result == "TagoIO Notification Run Successfully Updated" + + # Test with multiple notification IDs + result = resources.notifications.markAsUnread(["notification_id_1", "notification_id_2"]) + assert result == "TagoIO Notification Run Successfully Updated" + + +def testNotificationsMethodMarkAllAsRead(requests_mock: Mocker) -> None: + """Test markAllAsRead method of Notifications class.""" + mock_response = {"status": True, "result": "All TagoIO Notification Run Successfully Updated"} + requests_mock.put("https://api.tago.io/notification/markallread", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + result = resources.notifications.markAllAsRead() + + assert result == "All TagoIO Notification Run Successfully Updated" + + +def testNotificationsMethodNotificationButton(requests_mock: Mocker) -> None: + """Test notificationButton method of Notifications class.""" + mock_response = {"status": True, "result": "Button action processed"} + requests_mock.put("https://api.tago.io/notification/notification_id_1/button_id_1", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + result = resources.notifications.notificationButton("notification_id_1", "button_id_1") + + assert result == "Button action processed" + + +def testNotificationsMethodCreate(requests_mock: Mocker) -> None: + """Test create method of Notifications class.""" + mock_response = mockCreateNotification() + requests_mock.post("https://api.tago.io/notification", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + notification_data = { + "title": "New Notification", + "message": "This is a test notification", + "read": False, + } + + result = resources.notifications.create(notification_data) + + # Check if result has expected structure + assert result["id"] == "new_notification_id" + + +def testNotificationsMethodRemove(requests_mock: Mocker) -> None: + """Test remove method of Notifications class.""" + mock_response = {"status": True, "result": "Successfully Removed"} + requests_mock.delete("https://api.tago.io/notification/notification_id_1", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + result = resources.notifications.remove("notification_id_1") + + assert result == "Successfully Removed" + + +def testNotificationsMethodRegisterDevice(requests_mock: Mocker) -> None: + """Test registerDevice method of Notifications class.""" + mock_response = {"status": True, "result": "Device successfully registered"} + requests_mock.post("https://api.tago.io/notification/push/register", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + result = resources.notifications.registerDevice("device_token_123", "android") + + assert result == "Device successfully registered" + + +def testNotificationsMethodUnRegisterDevice(requests_mock: Mocker) -> None: + """Test unRegisterDevice method of Notifications class.""" + mock_response = {"status": True, "result": "Device successfully unregistered"} + requests_mock.post("https://api.tago.io/notification/push/unregister", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + result = resources.notifications.unRegisterDevice("device_token_123") + + assert result == "Device successfully unregistered" From c4cf7afcb1060fed6241e8538b8581ee6778434d Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Thu, 8 May 2025 17:57:28 -0300 Subject: [PATCH 16/26] Add detailed documentation for Notification types and update Resources index --- .../Notifications/Notification_Type.rst | 177 ++++++++++++++++ docs/source/Resources/Notifications/index.rst | 193 ++++++++++++++++++ docs/source/Resources/index.rst | 1 + 3 files changed, 371 insertions(+) create mode 100644 docs/source/Resources/Notifications/Notification_Type.rst create mode 100644 docs/source/Resources/Notifications/index.rst diff --git a/docs/source/Resources/Notifications/Notification_Type.rst b/docs/source/Resources/Notifications/Notification_Type.rst new file mode 100644 index 0000000..691d5a8 --- /dev/null +++ b/docs/source/Resources/Notifications/Notification_Type.rst @@ -0,0 +1,177 @@ +**Notification Type** +=================== + + +.. _NotificationTriggerAnalysis: + +NotificationTriggerAnalysis +-------------------------- + + **Attributes:** + + | analysis_id: :ref:`GenericID` + | ID of the analysis to trigger. + + +.. _NotificationTriggerHTTP: + +NotificationTriggerHTTP +---------------------- + + **Attributes:** + + | url: str + | The URL to make the request to. + + | method: "POST" or "GET" or "PUT" or "DELETE" or "REDIRECT" + | HTTP method to use for the request. + + | body: dict[str, Any] + | Body of the HTTP request. + + +.. _NotificationTriggerProfile: + +NotificationTriggerProfile +------------------------ + + **Attributes:** + + | share_profile: "accept" or "refuse" + | Action to take on a profile share. + + +.. _NotificationButton: + +NotificationButton +---------------- + + **Attributes:** + + | id: str + | Unique identifier for the button. + + | label: str + | Text displayed on the button. + + | color: Optional[str] + | Color of the button. + + | triggers: :ref:`NotificationTriggerAnalysis` or :ref:`NotificationTriggerHTTP` or list[:ref:`NotificationTriggerProfile`] + | Actions triggered when the button is clicked. + + +.. _NotificationIconImage: + +NotificationIconImage +------------------- + + **Attributes:** + + | image_url: str + | URL of the image to use as the notification icon. + + | bg_color: Optional[HexColor] + | Background color for the icon. + + | fit: Optional["fill" or "contain" or "cover"] + | How the image should fit in its container. + + +.. _NotificationIconSVG: + +NotificationIconSVG +----------------- + + **Attributes:** + + | svg_url: str + | URL of the SVG to use as the notification icon. + + | svg_color: Optional[HexColor] + | Color of the SVG. + + | bg_color: Optional[HexColor] + | Background color for the icon. + + +.. _NotificationCreate: + +NotificationCreate +---------------- + + **Attributes:** + + | title: str + | Title of the notification. + + | message: str + | Content of the notification. + + | read: Optional[bool] + | Whether the notification has been read. + + | icon: Optional[:ref:`NotificationIconSVG` or :ref:`NotificationIconImage`] + | Icon for the notification. + + | buttons: Optional[list[:ref:`NotificationButton`]] + | Buttons to display with the notification. + + | buttons_enabled: Optional[bool] + | Whether buttons are enabled. + + | buttons_autodisable: Optional[bool] + | Whether buttons should automatically disable after being clicked. + + +.. _NotificationQuery: + +NotificationQuery(:ref:`Query`) +------------- + + **Attributes:** + + | fields: Optional[List["created_at"]] + | Fields to include in the query response. + + | filter: Optional[Dict["read", bool]] + | Filters for the query. + + +.. _NotificationInfo: + +NotificationInfo(:ref:`NotificationCreate`) +------------ + + **Attributes:** + + | id: :ref:`GenericID` + | Unique identifier for the notification. + + | created_at: datetime + | When the notification was created. + + +.. _NotificationInfoBasic: + +NotificationInfoBasic +------------------ + + **Attributes:** + + | id: :ref:`GenericID` + | Unique identifier for the notification. + + | created_at: datetime + | When the notification was created. + + +.. _NotificationCreateReturn: + +NotificationCreateReturn +--------------------- + + **Attributes:** + + | id: :ref:`GenericID` + | Unique identifier for the newly created notification. diff --git a/docs/source/Resources/Notifications/index.rst b/docs/source/Resources/Notifications/index.rst new file mode 100644 index 0000000..bfc3537 --- /dev/null +++ b/docs/source/Resources/Notifications/index.rst @@ -0,0 +1,193 @@ +**Notifications** +========== + +Manage notifications in your application. + +======= +list +======= + +Retrieves all notifications from the application with optional filtering. + +See: `Notification `_ + + **Parameters:** + + | *Optional* **queryObj**: :ref:`NotificationQuery` + | Query parameters to filter the results. + + **Returns:** + + | list[:ref:`NotificationInfo`] + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.notifications.list({"filter": {"read": False}, "amount": 10}) + print(result) # [{'id': 'notification-id-123', 'title': 'System Update', 'message': 'Features', ...}] + + +========== +markAsRead +========== + +Marks one or multiple notifications as read. + +See: `Notification `_ + + **Parameters:** + + | **notificationIDS**: Union[str, list[str]] + | Notification ID or list of notification IDs + + **Returns:** + + | string + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + # Mark single notification + resources.notifications.markAsRead("notification-id-123") + + # Mark multiple notifications + resources.notifications.markAsRead(["id-1", "id-2"]) + + +============ +markAsUnread +============ + +Marks one or multiple notifications as unread. + +See: `Notification `_ + + **Parameters:** + + | **notificationIDS**: Union[str, list[str]] + | Notification ID or list of notification IDs + + **Returns:** + + | string + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + # Mark single notification + resources.notifications.markAsUnread("notification-id-123") + + # Mark multiple notifications + resources.notifications.markAsUnread(["id-1", "id-2"]) + + +============ +markAllAsRead +============ + +Marks all notifications in the application as read. + +See: `Notification `_ + + **Parameters:** + + | None + + **Returns:** + + | string + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.notifications.markAllAsRead() + print(result) # All TagoIO Notification Run Successfully Updated + + +================= +notificationButton +================= + +Records when a notification button is pressed by the user. + +See: `Notification `_ + + **Parameters:** + + | **notificationID**: str + | Notification ID + + | **buttonID**: str + | Button ID + + **Returns:** + + | string + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.notifications.notificationButton("notification-123", "button-456") + print(result) + + +====== +create +====== + +Creates a new notification in the system. + +See: `Notification `_ + + **Parameters:** + + | **notificationData**: :ref:`NotificationCreate` + | Notification data to create + + **Returns:** + + | dict + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.notifications.create({"title": "System Update", "message": "New features available"}) + print(result["id"]) # notification-id-123 + + +====== +remove +====== + +Permanently deletes a notification from the system. + +See: `Notification `_ + + **Parameters:** + + | **notificationID**: str + | Notification ID + + **Returns:** + + | string + + .. code-block:: python + + from tagoio_sdk import Resources + + resources = Resources() + result = resources.notifications.remove("notification-123") + print(result) # Successfully Removed diff --git a/docs/source/Resources/index.rst b/docs/source/Resources/index.rst index 9325d98..44c02c0 100644 --- a/docs/source/Resources/index.rst +++ b/docs/source/Resources/index.rst @@ -35,6 +35,7 @@ Instance Dictionaries/index Devices/index Files/index + Notifications/index IntegrationNetwork/index IntegrationConnector/index Profile/index From 1123975e506d0a0a255c2e1453c1f7647e2172c7 Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Fri, 9 May 2025 18:08:07 -0300 Subject: [PATCH 17/26] Add requests-toolbelt as a dependency in pyproject.toml --- poetry.lock | 100 +++++++++++++++++++++++++++++++++++++++++++------ pyproject.toml | 1 + 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/poetry.lock b/poetry.lock index 8d911b5..55d37cd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "aiohttp" @@ -6,6 +6,7 @@ version = "3.8.4" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "aiohttp-3.8.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5ce45967538fb747370308d3145aa68a074bdecb4f3a300869590f725ced69c1"}, {file = "aiohttp-3.8.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b744c33b6f14ca26b7544e8d8aadff6b765a80ad6164fb1a430bbadd593dfb1a"}, @@ -106,7 +107,7 @@ multidict = ">=4.5,<7.0" yarl = ">=1.0,<2.0" [package.extras] -speedups = ["Brotli", "aiodns", "cchardet"] +speedups = ["Brotli", "aiodns", "cchardet ; python_version < \"3.10\""] [[package]] name = "aiosignal" @@ -114,6 +115,7 @@ version = "1.3.1" description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, @@ -128,6 +130,7 @@ version = "0.7.13" description = "A configurable sidebar-enabled Sphinx theme" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"}, {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"}, @@ -139,6 +142,7 @@ version = "4.0.2" description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, @@ -150,6 +154,8 @@ version = "1.4.1" description = "Atomic file writes." optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["dev"] +markers = "sys_platform == \"win32\"" files = [ {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, ] @@ -160,6 +166,7 @@ version = "22.2.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.6" +groups = ["main", "dev"] files = [ {file = "attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836"}, {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"}, @@ -170,7 +177,7 @@ cov = ["attrs[tests]", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"] dev = ["attrs[docs,tests]"] docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope.interface"] tests = ["attrs[tests-no-zope]", "zope.interface"] -tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990)", "mypy (>=0.971,<0.990)", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-mypy-plugins", "pytest-xdist[psutil]", "pytest-xdist[psutil]"] +tests-no-zope = ["cloudpickle ; platform_python_implementation == \"CPython\"", "cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "hypothesis", "mypy (>=0.971,<0.990) ; platform_python_implementation == \"CPython\"", "mypy (>=0.971,<0.990) ; platform_python_implementation == \"CPython\"", "pympler", "pympler", "pytest (>=4.3.0)", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version < \"3.11\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version < \"3.11\"", "pytest-xdist[psutil]", "pytest-xdist[psutil]"] [[package]] name = "babel" @@ -178,6 +185,7 @@ version = "2.12.1" description = "Internationalization utilities" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "Babel-2.12.1-py3-none-any.whl", hash = "sha256:b4246fb7677d3b98f501a39d43396d3cafdc8eadb045f4a31be01863f655c610"}, {file = "Babel-2.12.1.tar.gz", hash = "sha256:cc2d99999cd01d44420ae725a21c9e3711b3aadc7976d6147f622d8581963455"}, @@ -189,6 +197,7 @@ version = "0.22.1" description = "The bidirectional mapping library for Python." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "bidict-0.22.1-py3-none-any.whl", hash = "sha256:6ef212238eb884b664f28da76f33f1d28b260f665fc737b413b287d5487d1e7b"}, {file = "bidict-0.22.1.tar.gz", hash = "sha256:1e0f7f74e4860e6d0943a05d4134c63a2fad86f3d4732fb265bd79e4e856d81d"}, @@ -205,6 +214,7 @@ version = "22.12.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "black-22.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9eedd20838bd5d75b80c9f5487dbcb06836a43833a37846cf1d8c1cc01cef59d"}, {file = "black-22.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:159a46a4947f73387b4d83e87ea006dbb2337eab6c879620a3ba52699b1f4351"}, @@ -240,6 +250,7 @@ version = "2022.12.7" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" +groups = ["main", "dev"] files = [ {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, @@ -251,6 +262,7 @@ version = "3.0.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = "*" +groups = ["main", "dev"] files = [ {file = "charset-normalizer-3.0.1.tar.gz", hash = "sha256:ebea339af930f8ca5d7a699b921106c6e29c617fe9606fa7baa043c1cdae326f"}, {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88600c72ef7587fe1708fd242b385b6ed4b8904976d5da0893e31df8b3480cb6"}, @@ -348,6 +360,7 @@ version = "8.1.3" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, @@ -362,6 +375,8 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\" or platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -373,6 +388,7 @@ version = "0.17.1" description = "Docutils -- Python Documentation Utilities" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["dev"] files = [ {file = "docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"}, {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"}, @@ -384,6 +400,7 @@ version = "4.0.1" description = "the modular source code checker: pep8 pyflakes and co" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"}, {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"}, @@ -400,6 +417,7 @@ version = "2.9.1" description = "Flake8 Type Annotation Checks" optional = false python-versions = ">=3.7,<4.0" +groups = ["dev"] files = [ {file = "flake8-annotations-2.9.1.tar.gz", hash = "sha256:11f09efb99ae63c8f9d6b492b75fe147fbc323179fddfe00b2e56eefeca42f57"}, {file = "flake8_annotations-2.9.1-py3-none-any.whl", hash = "sha256:a4385158a7a9fc8af1d8820a2f4c8d03387997006a83f5f8bfe5bc6085bdf88a"}, @@ -415,6 +433,7 @@ version = "0.3.6" description = "flake8 plugin to call black as a code style validator" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "flake8-black-0.3.6.tar.gz", hash = "sha256:0dfbca3274777792a5bcb2af887a4cad72c72d0e86c94e08e3a3de151bb41c34"}, {file = "flake8_black-0.3.6-py3-none-any.whl", hash = "sha256:fe8ea2eca98d8a504f22040d9117347f6b367458366952862ac3586e7d4eeaca"}, @@ -434,6 +453,7 @@ version = "1.3.3" description = "A list-like structure which implements collections.abc.MutableSequence" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "frozenlist-1.3.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff8bf625fe85e119553b5383ba0fb6aa3d0ec2ae980295aaefa552374926b3f4"}, {file = "frozenlist-1.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dfbac4c2dfcc082fcf8d942d1e49b6aa0766c19d3358bd86e2000bf0fa4a9cf0"}, @@ -517,6 +537,7 @@ version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" +groups = ["main", "dev"] files = [ {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, @@ -528,6 +549,7 @@ version = "1.4.1" description = "Getting image size from png/jpeg/jpeg2000/gif file" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["dev"] files = [ {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, @@ -539,6 +561,8 @@ version = "6.0.0" description = "Read metadata from Python packages" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version == \"3.9\"" files = [ {file = "importlib_metadata-6.0.0-py3-none-any.whl", hash = "sha256:7efb448ec9a5e313a57655d35aa54cd3e01b7e1fbcf72dce1bf06119420f5bad"}, {file = "importlib_metadata-6.0.0.tar.gz", hash = "sha256:e354bedeb60efa6affdcc8ae121b73544a7aa74156d047311948f6d711cd378d"}, @@ -550,7 +574,7 @@ zipp = ">=0.5" [package.extras] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] -testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] +testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8 ; python_version < \"3.12\"", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\"", "pytest-perf (>=0.9.2)"] [[package]] name = "iniconfig" @@ -558,6 +582,7 @@ version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, @@ -569,6 +594,7 @@ version = "5.12.0" description = "A Python utility / library to sort Python imports." optional = false python-versions = ">=3.8.0" +groups = ["dev"] files = [ {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"}, {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"}, @@ -586,6 +612,7 @@ version = "3.1.2" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, @@ -603,6 +630,7 @@ version = "2.1.2" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7"}, {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036"}, @@ -662,6 +690,7 @@ version = "0.6.1" description = "McCabe checker, plugin for flake8" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, @@ -673,6 +702,7 @@ version = "6.0.4" description = "multidict implementation" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "multidict-6.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8"}, {file = "multidict-6.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eeb6dcc05e911516ae3d1f207d4b0520d07f54484c49dfc294d6e7d63b734171"}, @@ -756,6 +786,7 @@ version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" +groups = ["dev"] files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -767,6 +798,7 @@ version = "23.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"}, {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, @@ -778,6 +810,7 @@ version = "0.11.0" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "pathspec-0.11.0-py3-none-any.whl", hash = "sha256:3a66eb970cbac598f9e5ccb5b2cf58930cd8e3ed86d393d541eaf2d8b1705229"}, {file = "pathspec-0.11.0.tar.gz", hash = "sha256:64d338d4e0914e91c1792321e6907b5a593f1ab1851de7fc269557a21b30ebbc"}, @@ -789,6 +822,7 @@ version = "3.0.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "platformdirs-3.0.0-py3-none-any.whl", hash = "sha256:b1d5eb14f221506f50d6604a561f4c5786d9e80355219694a1b244bcd96f4567"}, {file = "platformdirs-3.0.0.tar.gz", hash = "sha256:8a1228abb1ef82d788f74139988b137e78692984ec7b08eaa6c65f1723af28f9"}, @@ -804,6 +838,7 @@ version = "1.0.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, @@ -819,6 +854,7 @@ version = "1.11.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["dev"] files = [ {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, @@ -830,6 +866,7 @@ version = "2.8.0" description = "Python style guide checker" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["dev"] files = [ {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"}, {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"}, @@ -841,6 +878,7 @@ version = "2.4.0" description = "passive checker of Python programs" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["dev"] files = [ {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"}, {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, @@ -852,13 +890,14 @@ version = "2.14.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "Pygments-2.14.0-py3-none-any.whl", hash = "sha256:fa7bd7bd2771287c0de303af8bfdfc731f51bd2c6a47ab69d117138893b82717"}, {file = "Pygments-2.14.0.tar.gz", hash = "sha256:b3ed06a9e8ac9a9aae5a6f5dbe78a8a58655d17b43b93c078f094ddc476ae297"}, ] [package.extras] -plugins = ["importlib-metadata"] +plugins = ["importlib-metadata ; python_version < \"3.8\""] [[package]] name = "pytest" @@ -866,6 +905,7 @@ version = "6.2.5" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, @@ -890,6 +930,7 @@ version = "2.8.2" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] files = [ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, @@ -904,6 +945,7 @@ version = "4.3.4" description = "Engine.IO server and client for Python" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "python-engineio-4.3.4.tar.gz", hash = "sha256:d8d8b072799c36cadcdcc2b40d2a560ce09797ab3d2d596b2ad519a5e4df19ae"}, {file = "python_engineio-4.3.4-py3-none-any.whl", hash = "sha256:7454314a529bba20e745928601ffeaf101c1b5aca9a6c4e48ad397803d10ea0c"}, @@ -919,6 +961,7 @@ version = "5.7.2" description = "Socket.IO server and client for Python" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "python-socketio-5.7.2.tar.gz", hash = "sha256:92395062d9db3c13d30e7cdedaa0e1330bba78505645db695415f9a3c628d097"}, {file = "python_socketio-5.7.2-py3-none-any.whl", hash = "sha256:d9a9f047e6fdd306c852fbac36516f4b495c2096f8ad9ceb8803b8e5ff5622e3"}, @@ -939,6 +982,7 @@ version = "2.28.2" description = "Python HTTP for Humans." optional = false python-versions = ">=3.7, <4" +groups = ["main", "dev"] files = [ {file = "requests-2.28.2-py3-none-any.whl", hash = "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa"}, {file = "requests-2.28.2.tar.gz", hash = "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf"}, @@ -960,6 +1004,7 @@ version = "1.10.0" description = "Mock out responses from the requests package" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "requests-mock-1.10.0.tar.gz", hash = "sha256:59c9c32419a9fb1ae83ec242d98e889c45bd7d7a65d48375cc243ec08441658b"}, {file = "requests_mock-1.10.0-py2.py3-none-any.whl", hash = "sha256:2fdbb637ad17ee15c06f33d31169e71bf9fe2bdb7bc9da26185be0dd8d842699"}, @@ -973,12 +1018,28 @@ six = "*" fixture = ["fixtures"] test = ["fixtures", "mock", "purl", "pytest", "requests-futures", "sphinx", "testrepository (>=0.0.18)", "testtools"] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +description = "A utility belt for advanced users of python-requests" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] +files = [ + {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, + {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, +] + +[package.dependencies] +requests = ">=2.0.1,<3.0.0" + [[package]] name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main", "dev"] files = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, @@ -990,6 +1051,7 @@ version = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, @@ -1001,6 +1063,7 @@ version = "4.5.0" description = "Python documentation generator" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "Sphinx-4.5.0-py3-none-any.whl", hash = "sha256:ebf612653238bcc8f4359627a9b7ce44ede6fdd75d9d30f68255c7383d3a6226"}, {file = "Sphinx-4.5.0.tar.gz", hash = "sha256:7bf8ca9637a4ee15af412d1a1d9689fec70523a68ca9bb9127c2f3eeb344e2e6"}, @@ -1028,7 +1091,7 @@ sphinxcontrib-serializinghtml = ">=1.1.5" [package.extras] docs = ["sphinxcontrib-websupport"] lint = ["docutils-stubs", "flake8 (>=3.5.0)", "isort", "mypy (>=0.931)", "types-requests", "types-typed-ast"] -test = ["cython", "html5lib", "pytest", "pytest-cov", "typed-ast"] +test = ["cython", "html5lib", "pytest", "pytest-cov", "typed-ast ; python_version < \"3.8\""] [[package]] name = "sphinxcontrib-applehelp" @@ -1036,6 +1099,7 @@ version = "1.0.4" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "sphinxcontrib-applehelp-1.0.4.tar.gz", hash = "sha256:828f867945bbe39817c210a1abfd1bc4895c8b73fcaade56d45357a348a07d7e"}, {file = "sphinxcontrib_applehelp-1.0.4-py3-none-any.whl", hash = "sha256:29d341f67fb0f6f586b23ad80e072c8e6ad0b48417db2bde114a4c9746feb228"}, @@ -1051,6 +1115,7 @@ version = "1.0.2" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." optional = false python-versions = ">=3.5" +groups = ["dev"] files = [ {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, @@ -1066,6 +1131,7 @@ version = "2.0.1" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "sphinxcontrib-htmlhelp-2.0.1.tar.gz", hash = "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff"}, {file = "sphinxcontrib_htmlhelp-2.0.1-py3-none-any.whl", hash = "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903"}, @@ -1081,6 +1147,7 @@ version = "1.0.1" description = "A sphinx extension which renders display math in HTML via JavaScript" optional = false python-versions = ">=3.5" +groups = ["dev"] files = [ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, @@ -1095,6 +1162,7 @@ version = "1.0.3" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." optional = false python-versions = ">=3.5" +groups = ["dev"] files = [ {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, @@ -1110,6 +1178,7 @@ version = "1.1.5" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." optional = false python-versions = ">=3.5" +groups = ["dev"] files = [ {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, @@ -1125,6 +1194,7 @@ version = "0.10.2" description = "Python Library for Tom's Obvious, Minimal Language" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["dev"] files = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, @@ -1136,6 +1206,8 @@ version = "2.0.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_full_version < \"3.11.0a7\"" files = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, @@ -1147,6 +1219,8 @@ version = "4.5.0" description = "Backported and Experimental Type Hints for Python 3.7+" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version == \"3.9\"" files = [ {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, @@ -1158,14 +1232,15 @@ version = "1.26.14" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +groups = ["main", "dev"] files = [ {file = "urllib3-1.26.14-py2.py3-none-any.whl", hash = "sha256:75edcdc2f7d85b137124a6c3c9fc3933cdeaa12ecb9a6a959f22797a0feca7e1"}, {file = "urllib3-1.26.14.tar.gz", hash = "sha256:076907bf8fd355cde77728471316625a4d2f7e713c125f51953bb5b3eecf4f72"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +brotli = ["brotli (>=1.0.9) ; (os_name != \"nt\" or python_version >= \"3\") and platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; (os_name != \"nt\" or python_version >= \"3\") and platform_python_implementation != \"CPython\"", "brotlipy (>=0.6.0) ; os_name == \"nt\" and python_version < \"3\""] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] @@ -1174,6 +1249,7 @@ version = "1.8.2" description = "Yet another URL library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "yarl-1.8.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bb81f753c815f6b8e2ddd2eef3c855cf7da193b82396ac013c661aaa6cc6b0a5"}, {file = "yarl-1.8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:47d49ac96156f0928f002e2424299b2c91d9db73e08c4cd6742923a086f1c863"}, @@ -1261,6 +1337,8 @@ version = "3.15.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version == \"3.9\"" files = [ {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, @@ -1268,9 +1346,9 @@ files = [ [package.extras] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8 ; python_version < \"3.12\"", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\""] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = "^3.9" -content-hash = "0446affa9fd8b9e9fe01b15c59aa130114e1626aba750d87782b1e896de3f006" +content-hash = "59a747e376def927ee37c6c552570376d004e222376687bd30bf23957366d781" diff --git a/pyproject.toml b/pyproject.toml index 0c03f5b..5299ff4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ requests = "2.28.2" python-dateutil = "^2.8.2" python-socketio = {extras = ["asyncio_client"], version = "^5.7.2"} aiohttp = "^3.8.4" +requests-toolbelt = "^1.0.0" [tool.poetry.dev-dependencies] pytest = "^6.2.5" From 4fa23fb4902265b92ed162e8a66236dd5cc4f0e7 Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Fri, 9 May 2025 18:10:10 -0300 Subject: [PATCH 18/26] Refactor multipart upload handling in Files module to use requests-toolbelt for better multipart form data management and improve error handling. --- src/tagoio_sdk/modules/Resources/Files.py | 202 ++++++++++++++-------- 1 file changed, 128 insertions(+), 74 deletions(-) diff --git a/src/tagoio_sdk/modules/Resources/Files.py b/src/tagoio_sdk/modules/Resources/Files.py index a6e2de6..3ac391a 100644 --- a/src/tagoio_sdk/modules/Resources/Files.py +++ b/src/tagoio_sdk/modules/Resources/Files.py @@ -1,7 +1,6 @@ import time from typing import Dict, List, Optional, Any -from tagoio_sdk.common.Common_Type import GenericID from tagoio_sdk.common.tagoio_module import TagoIOModule from tagoio_sdk.modules.Utils.dateParser import dateParserList from tagoio_sdk.modules.Resources.Files_Types import ( @@ -13,6 +12,7 @@ MoveFiles, UploadOptions, ) +from tagoio_sdk.regions import getConnectionURI class Files(TagoIOModule): @@ -328,7 +328,8 @@ def getFileMD5(self, url: str) -> str: return result - def _createMultipartUpload(self, filename: str, options: Optional[UploadOptions] = None) -> Dict[str, Any]: + def _createMultipartUpload(self, filename: str, options: Optional[UploadOptions] = None) -> str: + """Creates a multipart upload instance.""" options = options or {} dashboard = options.get("dashboard") widget = options.get("widget") @@ -357,6 +358,7 @@ def _createMultipartUpload(self, filename: str, options: Optional[UploadOptions] def _uploadPart( self, filename: str, upload_id: str, part_number: int, blob: bytes, options: Optional[UploadOptions] = None ) -> Dict[str, Any]: + """Uploads a single part to TagoIO.""" options = options or {} field_id = options.get("fieldId") dashboard = options.get("dashboard") @@ -365,43 +367,57 @@ def _uploadPart( path = f"/data/files/{dashboard}/{widget}" if dashboard and widget else "/files" import io - - # Prepare the multipart form data - fields = { - "filename": filename, - "upload_id": upload_id, - "part": str(part_number), - "multipart_action": "upload", + import requests + from requests_toolbelt.multipart.encoder import MultipartEncoder + + # Create multipart form data with proper content type + form_data = { + "filename": (None, filename), + "upload_id": (None, upload_id), + "part": (None, str(part_number)), + "multipart_action": (None, "upload"), + # Use actual filename here and correct content type "file": (filename, io.BytesIO(blob), "application/octet-stream"), } if field_id: - fields["field_id"] = field_id + form_data["field_id"] = (None, field_id) - headers = {"Content-Type": "multipart/form-data"} + multipart = MultipartEncoder(fields=form_data) + headers = {"Content-Type": multipart.content_type, "token": self.token} - result = self.doRequest( - { - "path": path, - "method": "POST", - "body": fields, - "headers": headers, - } - ) + # Need to directly use requests here for proper multipart handling + api_url = getConnectionURI(self.region)["api"] + url = f"{api_url}{path}" - return { - "ETag": result["ETag"], - "PartNumber": part_number, - } + response = requests.post(url=url, data=multipart, headers=headers) + + if response.status_code >= 200 and response.status_code < 300: + result = response.json().get("result", {}) + return {"ETag": result.get("ETag"), "PartNumber": part_number} + else: + error_message = response.text + try: + error_data = response.json() + if "message" in error_data: + error_message = error_data["message"] + except: + pass + raise ValueError(f"Error in part upload: {error_message}") def _addToQueue( self, filename: str, - upload_id: GenericID, + upload_id: str, part_number: int, blob: bytes, - options: Optional[UploadOptions], + options: Optional[UploadOptions] = None, ) -> Dict[str, Any]: + """ + Adds an upload to the queue. + It will try to upload for 'opts.maxTriesForEachChunk' and fail + if it couldn't upload after those many tries. + """ options = options or {} max_tries = options.get("maxTriesForEachChunk", 5) timeout = options.get("timeoutForEachFailedChunk", 2000) @@ -428,6 +444,7 @@ def _addToQueue( def _completeMultipartUpload( self, filename: str, upload_id: str, parts: List[Dict[str, Any]], options: Optional[UploadOptions] = None ) -> Dict[str, str]: + """Finishes a multipart upload instance.""" options = options or {} field_id = options.get("fieldId") dashboard = options.get("dashboard") @@ -438,24 +455,17 @@ def _completeMultipartUpload( # Sort parts by part number parts_ordered = sorted(parts, key=lambda x: x["PartNumber"]) - headers = {"Content-Type": "multipart/form-data"} - - body = { - "multipart_action": "end", - "upload_id": upload_id, - "filename": filename, - "parts": parts_ordered, - "headers": headers, - } - - if field_id: - body["field_id"] = field_id - result = self.doRequest( { "path": path, "method": "POST", - "body": body, + "body": { + "multipart_action": "end", + "upload_id": upload_id, + "filename": filename, + "parts": parts_ordered, + **({"field_id": field_id} if field_id else {}), + }, } ) @@ -478,8 +488,8 @@ def uploadFile(self, file: bytes, filename: str, options: Optional[UploadOptions with open('myfile.txt', 'rb') as f: file_data = f.read() result = resources.files.uploadFile(file_data, "/uploads/myfile.txt", { - "chunkSize": 5 * 1024 * 1024, # 5MB chunks - "onProgress": lambda progress: print(f"Upload progress: {progress}%") + "chunkSize": 5 * 1024 * 1024, # 5MB chunks + "onProgress": lambda progress: print(f"Upload progress: {progress}%") }) print(result["file"]) # https://api.tago.io/file/.../uploads/myfile.txt ``` @@ -490,7 +500,7 @@ def uploadFile(self, file: bytes, filename: str, options: Optional[UploadOptions # Setup cancellation if provided cancelled = False if options.get("onCancelToken"): - options["onCancelToken"](lambda: setattr(locals(), "cancelled", True)) + options["onCancelToken"](lambda: globals().update(cancelled=True)) self._is_canceled(cancelled) @@ -500,7 +510,7 @@ def uploadFile(self, file: bytes, filename: str, options: Optional[UploadOptions # Calculate chunk sizes bytes_per_chunk = options.get("chunkSize", 7 * MB) file_size = len(file) - chunk_amount = (file_size // bytes_per_chunk) + 1 + chunk_amount = (file_size // bytes_per_chunk) + (1 if file_size % bytes_per_chunk > 0 else 0) parts_per_time = 3 # Check minimum chunk size for multipart uploads @@ -513,54 +523,68 @@ def uploadFile(self, file: bytes, filename: str, options: Optional[UploadOptions part_number = 1 error = None parts = [] - promises = [] import threading + import queue self._is_canceled(cancelled) - # Function to process chunks and update progress - def process_chunk(offset_start: int, offset_end: int, part_number: int) -> Dict[str, Any] | None: + # Queue to collect results from threads + result_queue = queue.Queue() + + # Function to process chunks + def process_chunk(start: int, end: int, p_num: int) -> None: try: - sliced = file[offset_start:offset_end] - part_data = self._addToQueue(filename, upload_id, part_number, sliced, options) - parts.append(part_data) - - # Update progress if callback provided - if options.get("onProgress"): - percentage = (len(parts) * 100) / chunk_amount - limited_percentage = min(percentage, 100) - rounded_percentage = round(limited_percentage, 2) - options["onProgress"](rounded_percentage) - - return part_data + sliced = file[start:end] + part_data = self._addToQueue(filename, upload_id, p_num, sliced, options) + result_queue.put(("success", part_data, p_num)) except Exception as e: - nonlocal error - error = e - return None + result_queue.put(("error", str(e), p_num)) + + active_threads = set() # Upload each chunk while offset_start < file_size: # Check if we're at the maximum parallel uploads - while len(promises) >= parts_per_time: + while len(active_threads) >= parts_per_time: self._is_canceled(cancelled) + # Check for completed threads and process results + still_active = set() + for thread in active_threads: + if thread.is_alive(): + still_active.add(thread) + + # Update active threads + active_threads = still_active + + # Process any results + while not result_queue.empty(): + status, data, p_num = result_queue.get() + if status == "success": + parts.append(data) + if options.get("onProgress"): + percentage = (len(parts) * 100) / chunk_amount + limited_percentage = min(percentage, 100) + rounded_percentage = round(limited_percentage, 2) + options["onProgress"](rounded_percentage) + else: + error = ValueError(f"Error uploading part {p_num}: {data}") + if error: raise error - time.sleep(1) - - # Check if any threads have completed - promises = [p for p in promises if p.is_alive()] + time.sleep(0.2) # Start a new upload thread - thread = threading.Thread(target=process_chunk, args=(offset_start, offset_end, part_number)) + thread = threading.Thread( + target=process_chunk, args=(offset_start, min(offset_end, file_size), part_number) + ) thread.start() - promises.append(thread) + active_threads.add(thread) self._is_canceled(cancelled) - - time.sleep(0.5) + time.sleep(0.1) # Move to the next chunk offset_start = offset_end @@ -568,19 +592,49 @@ def process_chunk(offset_start: int, offset_end: int, part_number: int) -> Dict[ part_number += 1 # Wait for all uploads to complete - while promises: + while active_threads: self._is_canceled(cancelled) + # Check for completed threads + still_active = set() + for thread in active_threads: + if thread.is_alive(): + still_active.add(thread) + + # Update active threads + active_threads = still_active + + # Process any results + while not result_queue.empty(): + status, data, p_num = result_queue.get() + if status == "success": + parts.append(data) + if options.get("onProgress"): + percentage = (len(parts) * 100) / chunk_amount + limited_percentage = min(percentage, 100) + rounded_percentage = round(limited_percentage, 2) + options["onProgress"](rounded_percentage) + else: + error = ValueError(f"Error uploading part {p_num}: {data}") + if error: raise error - time.sleep(1) + time.sleep(0.2) - # Update the list of active threads - promises = [p for p in promises if p.is_alive()] + # One final check for results + while not result_queue.empty(): + status, data, p_num = result_queue.get() + if status == "success": + parts.append(data) + else: + raise ValueError(f"Error uploading part {p_num}: {data}") self._is_canceled(cancelled) + if len(parts) != chunk_amount: + raise ValueError(f"Expected {chunk_amount} parts but got {len(parts)}") + # Complete the multipart upload with retries for i in range(3): try: From 8773cd8cb774d532885a310c7525937545672701 Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Fri, 9 May 2025 18:11:47 -0300 Subject: [PATCH 19/26] Refactor dev-dependencies section in pyproject.toml to use group.dev.dependencies format --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5299ff4..7c3dcb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ python-socketio = {extras = ["asyncio_client"], version = "^5.7.2"} aiohttp = "^3.8.4" requests-toolbelt = "^1.0.0" -[tool.poetry.dev-dependencies] +[tool.poetry.group.dev.dependencies] pytest = "^6.2.5" flake8 = "^4.0.1" isort = "^5.10.1" From ae091e0620780755d72a1e2a8d1c6e0945405b59 Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Mon, 12 May 2025 12:03:54 -0300 Subject: [PATCH 20/26] Add Secrets module and integrate it into Resources and AccountDeprecated --- .../modules/Resources/AccountDeprecated.py | 3 + src/tagoio_sdk/modules/Resources/Resources.py | 2 + src/tagoio_sdk/modules/Resources/Secrets.py | 160 ++++++++++++++++++ .../modules/Resources/Secrets_Type.py | 34 ++++ 4 files changed, 199 insertions(+) create mode 100644 src/tagoio_sdk/modules/Resources/Secrets.py create mode 100644 src/tagoio_sdk/modules/Resources/Secrets_Type.py diff --git a/src/tagoio_sdk/modules/Resources/AccountDeprecated.py b/src/tagoio_sdk/modules/Resources/AccountDeprecated.py index 67d3188..88b920f 100644 --- a/src/tagoio_sdk/modules/Resources/AccountDeprecated.py +++ b/src/tagoio_sdk/modules/Resources/AccountDeprecated.py @@ -14,6 +14,7 @@ from .Integration import Integration from .Profile import Profile from .Run import Run +from .Secrets import Secrets class AccountDeprecated(Account): @@ -51,3 +52,5 @@ def __init__(self, params: GenericModuleParams): """@deprecated moved to Resources().run""" self.profiles = Profile(params) """@deprecated moved to Resources().profiles""" + self.secrets = Secrets(params) + """@deprecated moved to Resources().secrets""" diff --git a/src/tagoio_sdk/modules/Resources/Resources.py b/src/tagoio_sdk/modules/Resources/Resources.py index d7c3a33..d58aa71 100644 --- a/src/tagoio_sdk/modules/Resources/Resources.py +++ b/src/tagoio_sdk/modules/Resources/Resources.py @@ -14,6 +14,7 @@ from .Notifications import Notifications from .Profile import Profile from .Run import Run +from .Secrets import Secrets from .Integration import Integration from .Account import Account @@ -35,5 +36,6 @@ def __init__(self, params: Optional[GenericModuleParams] = None): self.notifications = Notifications(params) self.profile = Profile(params) self.run = Run(params) + self.secrets = Secrets(params) self.integration = Integration(params) self.account = Account(params) diff --git a/src/tagoio_sdk/modules/Resources/Secrets.py b/src/tagoio_sdk/modules/Resources/Secrets.py new file mode 100644 index 0000000..a692c5d --- /dev/null +++ b/src/tagoio_sdk/modules/Resources/Secrets.py @@ -0,0 +1,160 @@ +from typing import Dict, List, Optional + +from tagoio_sdk.common.Common_Type import GenericID +from tagoio_sdk.common.tagoio_module import TagoIOModule +from tagoio_sdk.modules.Utils.dateParser import dateParser +from tagoio_sdk.modules.Resources.Secrets_Type import SecretsCreate, SecretsEdit, SecretsInfo, SecretsQuery + + +class Secrets(TagoIOModule): + def list(self, queryObj: Optional[SecretsQuery] = None) -> List[SecretsInfo]: + """ + @description: + Retrieves a paginated list of all secrets stored in the profile with filtering and sorting options. + + @see: + https://help.tago.io/portal/en/kb/articles/secrets Secrets + + @example: + If receive an error "Authorization Denied", check policy **Secrets** / **Access** in Access Management. + ```python + resources = Resources() + result = resources.secrets.list({ + "page": 1, + "fields": ["id", "key"], + "amount": 20 + }) + print(result) # [ { 'id': 'secret-id-123', 'key': 'API_KEY' } ] + ``` + """ + queryObj = queryObj or {} + + orderBy = "key,asc" + if "orderBy" in queryObj: + orderBy = f"{queryObj['orderBy'][0]},{queryObj['orderBy'][1]}" + + result = self.doRequest( + { + "path": "/secrets", + "method": "GET", + "params": { + "page": queryObj.get("page", 1), + "fields": queryObj.get("fields", ["id", "key"]), + "filter": queryObj.get("filter", {}), + "amount": queryObj.get("amount", 20), + "orderBy": orderBy, + }, + } + ) + + return result + + def info(self, secretID: GenericID) -> SecretsInfo: + """ + @description: + Retrieves detailed information about a specific secret using its ID. + + @see: + https://help.tago.io/portal/en/kb/articles/secrets Secrets + + @example: + If receive an error "Authorization Denied", check policy **Secrets** / **Access** in Access Management. + ```python + resources = Resources() + secret_info = resources.secrets.info("secret-id-123") + print(secret_info) # { 'id': 'secret-id-123', 'key': 'API_KEY' } + ``` + """ + result = self.doRequest( + { + "path": f"/secrets/{secretID}", + "method": "GET", + } + ) + + result = dateParser(result, ["created_at", "updated_at"]) + + return result + + def create(self, secretObj: SecretsCreate) -> Dict[str, GenericID]: + """ + @description: + Creates a new secret in the profile with the specified key and value. + + @see: + https://help.tago.io/portal/en/kb/articles/secrets#Creating_a_Secret Creating a Secret + + @example: + If receive an error "Authorization Denied", check policy **Secrets** / **Create** in Access Management. + ```python + resources = Resources() + result = resources.secrets.create({ + "key": "API_KEY", + "value": "my-secret-value" + }) + print(result) # { 'id': 'secret-id-132' } + ``` + """ + result = self.doRequest( + { + "path": "/secrets", + "method": "POST", + "body": {**secretObj}, + } + ) + + return result + + def edit(self, secretID: GenericID, secretObj: SecretsEdit) -> str: + """ + @description: + Modifies the properties of an existing secret. + + @see: + https://help.tago.io/portal/en/kb/articles/secrets Secrets + + @example: + If receive an error "Authorization Denied", check policy **Secrets** / **Edit** in Access Management. + ```python + resources = Resources() + result = resources.secrets.edit("secret-id-123", { + "value": "new-secret-value", + "tags": [{"key": "type", "value": "user"}] + }) + print(result) # Successfully Updated + ``` + """ + result = self.doRequest( + { + "path": f"/secrets/{secretID}", + "method": "PUT", + "body": {**secretObj}, + } + ) + + return result + + def delete(self, secretID: GenericID) -> str: + """ + @description: + Permanently removes a secret from the profile. + + @see: + https://help.tago.io/portal/en/kb/articles/secrets Secrets + + @example: + If receive an error "Authorization Denied", check policy **Secrets** / **delete** in Access Management. + ```python + resources = Resources() + result = resources.secrets.delete("secret-id-123") + print(result) # Successfully Removed + ``` + """ + result = self.doRequest( + { + "path": f"/secrets/{secretID}", + "method": "DELETE", + } + ) + + return result diff --git a/src/tagoio_sdk/modules/Resources/Secrets_Type.py b/src/tagoio_sdk/modules/Resources/Secrets_Type.py new file mode 100644 index 0000000..e5c13cd --- /dev/null +++ b/src/tagoio_sdk/modules/Resources/Secrets_Type.py @@ -0,0 +1,34 @@ +from datetime import datetime +from typing import List, Optional, TypedDict, Literal + +from tagoio_sdk.common.Common_Type import GenericID, TagsObj, Query + + +class SecretsInfo(TypedDict): + id: GenericID + key: str + tags: Optional[List[TagsObj]] + value_length: int + created_at: datetime + updated_at: datetime + + +class SecretsCreate(TypedDict): + key: str + value: str + tags: Optional[List[TagsObj]] + + +class SecretsEdit(TypedDict, total=False): + value: str + tags: Optional[List[TagsObj]] + + +class SecretsFilter(TypedDict): + key: str + tags: Optional[List[TagsObj]] + + +class SecretsQuery(Query): + fields: Optional[List[Literal["id", "key", "tags", "created_at", "updated_at"]]] + filter: Optional[SecretsFilter] From 2ad831f2d3ab7efaf7458c27103eb8c0f1400167 Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Mon, 12 May 2025 12:03:59 -0300 Subject: [PATCH 21/26] Add tests for Secrets class methods in test_secrets.py --- tests/Resources/test_secrets.py | 157 ++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 tests/Resources/test_secrets.py diff --git a/tests/Resources/test_secrets.py b/tests/Resources/test_secrets.py new file mode 100644 index 0000000..d633a21 --- /dev/null +++ b/tests/Resources/test_secrets.py @@ -0,0 +1,157 @@ +import os +from requests_mock.mocker import Mocker + +from tagoio_sdk.modules.Resources.Resources import Resources +from tagoio_sdk.modules.Resources.Secrets_Type import SecretsInfo + +os.environ["T_ANALYSIS_TOKEN"] = "your_token_value" + + +def mockSecretsList() -> list[SecretsInfo]: + return { + "status": True, + "result": [ + { + "id": "secret_id_1", + "key": "API_KEY", + "value_length": 32, + "tags": [{"key": "type", "value": "api"}], + "created_at": "2023-02-21T15:17:35.759Z", + "updated_at": "2023-02-21T15:17:35.759Z", + }, + { + "id": "secret_id_2", + "key": "DB_PASSWORD", + "value_length": 16, + "tags": [{"key": "type", "value": "database"}], + "created_at": "2023-02-22T10:30:45.123Z", + "updated_at": "2023-02-22T10:30:45.123Z", + }, + ], + } + + +def mockSecretInfo() -> SecretsInfo: + return { + "status": True, + "result": { + "id": "secret_id_1", + "key": "API_KEY", + "value_length": 32, + "tags": [{"key": "type", "value": "api"}], + "created_at": "2023-02-21T15:17:35.759Z", + "updated_at": "2023-02-21T15:17:35.759Z", + }, + } + + +def mockCreateSecret() -> dict: + return { + "status": True, + "result": {"id": "new_secret_id"}, + } + + +def testSecretsMethodList(requests_mock: Mocker) -> None: + """Test list method of Secrets class.""" + mock_response = mockSecretsList() + requests_mock.get("https://api.tago.io/secrets", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + query = { + "page": 1, + "fields": ["id", "key", "tags"], + "amount": 20, + "orderBy": ["key", "asc"], + } + + result = resources.secrets.list(query) + + # Check if the result is a list + assert isinstance(result, list) + # Check if the result has the expected items + assert len(result) == 2 + # Check if items have expected properties + assert result[0]["id"] == "secret_id_1" + assert result[0]["key"] == "API_KEY" + assert result[1]["id"] == "secret_id_2" + assert result[1]["key"] == "DB_PASSWORD" + assert result[0]["tags"][0]["key"] == "type" + assert result[0]["tags"][0]["value"] == "api" + + +def testSecretsMethodCreate(requests_mock: Mocker) -> None: + """Test create method of Secrets class.""" + mock_response = mockCreateSecret() + requests_mock.post("https://api.tago.io/secrets", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + secret_data = { + "key": "NEW_API_KEY", + "value": "secret_value_123", + "tags": [{"key": "type", "value": "api"}], + } + + result = resources.secrets.create(secret_data) + + # Check if result has expected structure + assert result["id"] == "new_secret_id" + + +def testSecretsMethodInfo(requests_mock: Mocker) -> None: + """Test info method of Secrets class.""" + mock_response = mockSecretInfo() + requests_mock.get("https://api.tago.io/secrets/secret_id_1", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + result = resources.secrets.info("secret_id_1") + + # Check if result has expected properties + assert result["id"] == "secret_id_1" + assert result["key"] == "API_KEY" + assert result["value_length"] == 32 + assert len(result["tags"]) == 1 + assert result["tags"][0]["key"] == "type" + assert result["tags"][0]["value"] == "api" + + +def testSecretsMethodEdit(requests_mock: Mocker) -> None: + """Test edit method of Secrets class.""" + mock_response = { + "status": True, + "result": "Successfully Updated", + } + + requests_mock.put("https://api.tago.io/secrets/secret_id_1", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + secret_data = { + "value": "new_secret_value", + "tags": [{"key": "environment", "value": "production"}], + } + + result = resources.secrets.edit("secret_id_1", secret_data) + + # Check if result has expected message + assert result == "Successfully Updated" + + +def testSecretsMethodDelete(requests_mock: Mocker) -> None: + """Test delete method of Secrets class.""" + mock_response = { + "status": True, + "result": "Successfully Removed", + } + + requests_mock.delete("https://api.tago.io/secrets/secret_id_1", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + result = resources.secrets.delete("secret_id_1") + + # Check if result has expected message + assert result == "Successfully Removed" From f0b5e6911b394242bee1af203939e80eb268682e Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Mon, 12 May 2025 12:10:23 -0300 Subject: [PATCH 22/26] Add documentation for Secrets module and create index entries for Secrets types --- .../source/Resources/Secrets/Secrets_Type.rst | 87 ++++++++++ docs/source/Resources/Secrets/index.rst | 162 ++++++++++++++++++ docs/source/Resources/index.rst | 1 + 3 files changed, 250 insertions(+) create mode 100644 docs/source/Resources/Secrets/Secrets_Type.rst create mode 100644 docs/source/Resources/Secrets/index.rst diff --git a/docs/source/Resources/Secrets/Secrets_Type.rst b/docs/source/Resources/Secrets/Secrets_Type.rst new file mode 100644 index 0000000..af8d4ce --- /dev/null +++ b/docs/source/Resources/Secrets/Secrets_Type.rst @@ -0,0 +1,87 @@ +**Secrets Type** +=============== + + +.. _SecretsInfo: + +SecretsInfo +----------- + + **Attributes:** + + | id: :ref:`GenericID` + | Unique identifier for the secret. + + | key: str + | Key name of the secret. + + | tags: Optional[List[:ref:`TagsObj`]] + | List of tags associated with the secret. + + | value_length: int + | Length of the secret value. + + | created_at: datetime + | Date and time when the secret was created. + + | updated_at: datetime + | Date and time when the secret was last updated. + + +.. _SecretsCreate: + +SecretsCreate +------------- + + **Attributes:** + + | key: str + | Key name for the new secret. + + | value: str + | Value of the secret. + + | tags: Optional[List[:ref:`TagsObj`]] + | List of tags to associate with the secret. + + +.. _SecretsEdit: + +SecretsEdit +---------- + + **Attributes:** + + | value: Optional[str] + | New value for the secret. + + | tags: Optional[List[:ref:`TagsObj`]] + | Updated list of tags for the secret. + + +.. _SecretsFilter: + +SecretsFilter +------------ + + **Attributes:** + + | key: str + | Key name to filter secrets by. + + | tags: Optional[List[:ref:`TagsObj`]] + | Tags to filter secrets by. + + +.. _SecretsQuery: + +SecretsQuery(:ref:`Query`) +------------ + + **Attributes:** + + | fields: Optional[List[Literal["id", "key", "tags", "created_at", "updated_at"]]] + | List of fields to include in the query results. + + | filter: Optional[:ref:`SecretsFilter`] + | Filter criteria for the query. diff --git a/docs/source/Resources/Secrets/index.rst b/docs/source/Resources/Secrets/index.rst new file mode 100644 index 0000000..6851a7b --- /dev/null +++ b/docs/source/Resources/Secrets/index.rst @@ -0,0 +1,162 @@ +**Secrets** +========== + +Manage secrets in your application. + +======= +list +======= + +Retrieves a paginated list of all secrets stored in the profile with filtering and sorting options. + +See: `Secrets `_ + + **Parameters:** + + | *Optional* **queryObj**: :ref:`SecretsQuery` + | Query parameters to filter the results. + + .. code-block:: + :caption: **Default queryObj:** + + queryObj = { + "page": 1, + "fields": ["id", "key"], + "filter": {}, + "amount": 20, + "orderBy": ["key", "asc"] + } + + **Returns:** + + | list[:ref:`SecretsInfo`] + + .. code-block:: python + + # If receive an error "Authorization Denied", check policy "Secrets" / "Access" in Access Management. + from tagoio_sdk import Resources + + resources = Resources() + result = resources.secrets.list({ + "page": 1, + "fields": ["id", "key"], + "amount": 20 + }) + print(result) # [ { 'id': 'secret-id-123', 'key': 'API_KEY' } ] + + +======= +info +======= + +Retrieves detailed information about a specific secret using its ID. + +See: `Secrets `_ + + **Parameters:** + + | **secretID**: str + | Secret ID + + **Returns:** + + | :ref:`SecretsInfo` + + .. code-block:: python + + # If receive an error "Authorization Denied", check policy "Secrets" / "Access" in Access Management. + from tagoio_sdk import Resources + + resources = Resources() + secret_info = resources.secrets.info("secret-id-123") + print(secret_info) # { 'id': 'secret-id-123', 'key': 'API_KEY' } + + +======= +create +======= + +Creates a new secret in the profile with the specified key and value. + +See: `Creating a Secret `_ + + **Parameters:** + + | **secretObj**: :ref:`SecretsCreate` + | Secret information + + **Returns:** + + | dict[str, :ref:`GenericID`] + + .. code-block:: python + + # If receive an error "Authorization Denied", check policy "Secrets" / "Create" in Access Management. + from tagoio_sdk import Resources + + resources = Resources() + result = resources.secrets.create({ + "key": "API_KEY", + "value": "my-secret-value" + }) + print(result) # { 'id': 'secret-id-132' } + + +======= +edit +======= + +Modifies the properties of an existing secret. + +See: `Secrets `_ + + **Parameters:** + + | **secretID**: str + | Secret ID + + | **secretObj**: :ref:`SecretsEdit` + | Secret information to update + + **Returns:** + + | string + + .. code-block:: python + + # If receive an error "Authorization Denied", check policy "Secrets" / "Edit" in Access Management. + from tagoio_sdk import Resources + + resources = Resources() + result = resources.secrets.edit("secret-id-123", { + "value": "new-secret-value", + "tags": [{"key": "type", "value": "user"}] + }) + print(result) # Successfully Updated + + +======= +delete +======= + +Permanently removes a secret from the profile. + +See: `Secrets `_ + + **Parameters:** + + | **secretID**: str + | Secret ID + + **Returns:** + + | string + + .. code-block:: python + + # If receive an error "Authorization Denied", check policy "Secrets" / "delete" in Access Management. + from tagoio_sdk import Resources + + resources = Resources() + result = resources.secrets.delete("secret-id-123") + print(result) # Successfully Removed diff --git a/docs/source/Resources/index.rst b/docs/source/Resources/index.rst index 44c02c0..671d83c 100644 --- a/docs/source/Resources/index.rst +++ b/docs/source/Resources/index.rst @@ -40,3 +40,4 @@ Instance IntegrationConnector/index Profile/index Run/index + Secrets/index From 8ca0486aa91a15d7d62c52dd99b52c6cbd87cb96 Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Mon, 12 May 2025 16:42:26 -0300 Subject: [PATCH 23/26] Add ServiceAuthorization module and integrate it into Resources and AccountDeprecated --- .../modules/Resources/AccountDeprecated.py | 3 + src/tagoio_sdk/modules/Resources/Resources.py | 2 + .../Resources/Service_Authorization.py | 138 ++++++++++++++++++ .../Resources/Service_Authorization_Types.py | 24 +++ 4 files changed, 167 insertions(+) create mode 100644 src/tagoio_sdk/modules/Resources/Service_Authorization.py create mode 100644 src/tagoio_sdk/modules/Resources/Service_Authorization_Types.py diff --git a/src/tagoio_sdk/modules/Resources/AccountDeprecated.py b/src/tagoio_sdk/modules/Resources/AccountDeprecated.py index 88b920f..ac243a5 100644 --- a/src/tagoio_sdk/modules/Resources/AccountDeprecated.py +++ b/src/tagoio_sdk/modules/Resources/AccountDeprecated.py @@ -15,6 +15,7 @@ from .Profile import Profile from .Run import Run from .Secrets import Secrets +from .Service_Authorization import ServiceAuthorization class AccountDeprecated(Account): @@ -54,3 +55,5 @@ def __init__(self, params: GenericModuleParams): """@deprecated moved to Resources().profiles""" self.secrets = Secrets(params) """@deprecated moved to Resources().secrets""" + self.serviceAuthorization = ServiceAuthorization(params) + """@deprecated moved to Resources().secrets""" diff --git a/src/tagoio_sdk/modules/Resources/Resources.py b/src/tagoio_sdk/modules/Resources/Resources.py index d58aa71..3037a62 100644 --- a/src/tagoio_sdk/modules/Resources/Resources.py +++ b/src/tagoio_sdk/modules/Resources/Resources.py @@ -15,6 +15,7 @@ from .Profile import Profile from .Run import Run from .Secrets import Secrets +from .Service_Authorization import ServiceAuthorization from .Integration import Integration from .Account import Account @@ -37,5 +38,6 @@ def __init__(self, params: Optional[GenericModuleParams] = None): self.profile = Profile(params) self.run = Run(params) self.secrets = Secrets(params) + self.serviceAuthorization = ServiceAuthorization(params) self.integration = Integration(params) self.account = Account(params) diff --git a/src/tagoio_sdk/modules/Resources/Service_Authorization.py b/src/tagoio_sdk/modules/Resources/Service_Authorization.py new file mode 100644 index 0000000..2f355af --- /dev/null +++ b/src/tagoio_sdk/modules/Resources/Service_Authorization.py @@ -0,0 +1,138 @@ +from typing import Dict, List, Optional + +from tagoio_sdk.common.Common_Type import TokenDataList, TokenData +from tagoio_sdk.common.tagoio_module import TagoIOModule +from tagoio_sdk.modules.Resources.Service_Authorization_Types import ( + TokenCreateResponse, + GenericToken, + ServiceAuthorizationQuery, +) +from tagoio_sdk.modules.Utils.dateParser import dateParserList + + +class ServiceAuthorization(TagoIOModule): + def tokenList(self, query: Optional[ServiceAuthorizationQuery] = None) -> List[TokenDataList]: + """ + @description: + Retrieves a paginated list of all service authorization tokens with filtering and sorting options. + + @see: + https://help.tago.io/portal/en/kb/articles/218-authorization Authorization + + @example: + If receive an error "Authorization Denied", check policy **Service Authorization** / **Access** in Access Management. + ```python + resources = Resources() + result = resources.serviceAuthorization.tokenList({ + "page": 1, + "fields": ["name", "token", "verification_code"], + "amount": 20 + }) + print(result) # [ { 'name': 'API Service Token', 'token': 'token-xyz-123' } ] + ``` + """ + query = query or {} + + orderBy = "created_at,desc" + if "orderBy" in query: + orderBy = f"{query['orderBy'][0]},{query['orderBy'][1]}" + + result = self.doRequest( + { + "path": "/serviceauth", + "method": "GET", + "params": { + "page": query.get("page", 1), + "fields": query.get("fields", ["name", "token"]), + "filter": query.get("filter", {}), + "amount": query.get("amount", 20), + "orderBy": orderBy, + }, + } + ) + + result = dateParserList(result, ["created_at", "last_authorization", "expire_time"]) + + return result + + def tokenCreate(self, tokenParams: TokenData) -> TokenCreateResponse: + """ + @description: + Generates and retrieves a new service authorization token with specified permissions. + + @see: + https://help.tago.io/portal/en/kb/articles/218-authorization Authorization + + @example: + If receive an error "Authorization Denied", check policy **Service Authorization** / **Create** in Access Management. + ```python + resources = Resources() + result = resources.serviceAuthorization.tokenCreate({ + "name": "Service Token", + "verification_code": "additional parameter" + }) + print(result) # { 'token': 'token-xyz-123', 'name': 'Service Token', ... } + ``` + """ + result = self.doRequest( + { + "path": "/serviceauth", + "method": "POST", + "body": tokenParams, + } + ) + + return result + + def tokenDelete(self, token: GenericToken) -> str: + """ + @description: + Permanently removes a service authorization token. + + @see: + https://help.tago.io/portal/en/kb/articles/218-authorization Authorization + + @example: + If receive an error "Authorization Denied", check policy **Service Authorization** / **Delete** in Access Management. + ```python + resources = Resources() + result = resources.serviceAuthorization.tokenDelete("token-xyz-123") + print(result) # Token Successfully Removed + ``` + """ + result = self.doRequest( + { + "path": f"/serviceauth/{token}", + "method": "DELETE", + } + ) + + return result + + def tokenEdit(self, token: GenericToken, verificationCode: Optional[str] = None) -> str: + """ + @description: + Updates a service authorization token with an optional verification code. + + @see: + https://help.tago.io/portal/en/kb/articles/218-authorization Authorization + + @example: + If receive an error "Authorization Denied", check policy **Service Authorization** / **Edit** in Access Management. + ```python + resources = Resources() + result = resources.serviceAuthorization.tokenEdit("token-xyz-123", "verification-code") + print(result) # Authorization Code Successfully Updated + ``` + """ + result = self.doRequest( + { + "path": f"/serviceauth/{token}", + "method": "PUT", + "body": { + "verification_code": verificationCode, + }, + } + ) + + return result diff --git a/src/tagoio_sdk/modules/Resources/Service_Authorization_Types.py b/src/tagoio_sdk/modules/Resources/Service_Authorization_Types.py new file mode 100644 index 0000000..e7b8b82 --- /dev/null +++ b/src/tagoio_sdk/modules/Resources/Service_Authorization_Types.py @@ -0,0 +1,24 @@ +from typing import List, Literal, Optional, TypedDict + +from tagoio_sdk.common.Common_Type import GenericID, Query + +GenericToken = str +"""Token used on TagoIO, string with 34 characters""" + + +class TokenCreateResponse(TypedDict): + token: GenericToken + name: str + profile: GenericID + verification_code: Optional[str] + """[Optional] Verification code to validate middleware requests.""" + + +class ServiceAuthorizationFilter(TypedDict): + name: str + token: GenericToken + + +class ServiceAuthorizationQuery(Query): + fields: Optional[List[Literal["name", "token", "verification_code", "created_at"]]] + filter: Optional[ServiceAuthorizationFilter] From df59926954ec516f4450cbb23e95d7eff351d07c Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Mon, 12 May 2025 16:42:34 -0300 Subject: [PATCH 24/26] Add tests for ServiceAuthorization methods including tokenList, tokenCreate, tokenDelete, and tokenEdit --- tests/Resources/test_service_authorization.py | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 tests/Resources/test_service_authorization.py diff --git a/tests/Resources/test_service_authorization.py b/tests/Resources/test_service_authorization.py new file mode 100644 index 0000000..e91b589 --- /dev/null +++ b/tests/Resources/test_service_authorization.py @@ -0,0 +1,120 @@ +import os +from requests_mock.mocker import Mocker + +from tagoio_sdk.modules.Resources.Resources import Resources +from tagoio_sdk.common.Common_Type import TokenDataList, TokenData +from tagoio_sdk.modules.Resources.Service_Authorization_Types import TokenCreateResponse + +os.environ["T_ANALYSIS_TOKEN"] = "your_token_value" + + +def mockTokenList() -> list[TokenDataList]: + return { + "status": True, + "result": [ + { + "token": "token_id_1", + "name": "Service Token 1", + "permission": "full", + "verification_code": "abc123", + "created_at": "2023-02-21T15:17:35.759Z", + "last_authorization": "2023-02-22T10:30:45.123Z", + "expire_time": "never", + }, + { + "token": "token_id_2", + "name": "Service Token 2", + "permission": "read", + "verification_code": "def456", + "created_at": "2023-03-15T08:22:17.432Z", + "last_authorization": "2023-03-16T14:05:22.789Z", + "expire_time": "2024-03-15T08:22:17.432Z", + }, + ], + } + + +def mockTokenCreate() -> dict: + return { + "status": True, + "result": { + "token": "new_token_id", + "name": "New Service Token", + "profile": "profile_id_123", + "additional_parameters": "verification_code_789", + }, + } + + +def testServiceAuthorizationMethodTokenList(requests_mock: Mocker) -> None: + """Test tokenList method of ServiceAuthorization class.""" + mock_response = mockTokenList() + requests_mock.get("https://api.tago.io/serviceauth", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + query = { + "page": 1, + "fields": ["name", "token", "permission"], + "amount": 20, + "orderBy": ["name", "asc"], + } + + result = resources.serviceAuthorization.tokenList(query) + + # Check if the result is a list + assert isinstance(result, list) + # Check if the result has the expected items + assert len(result) == 2 + # Check if items have expected properties + assert result[0]["token"] == "token_id_1" + assert result[0]["name"] == "Service Token 1" + assert result[0]["permission"] == "full" + assert result[1]["token"] == "token_id_2" + assert result[1]["name"] == "Service Token 2" + + +def testServiceAuthorizationMethodTokenCreate(requests_mock: Mocker) -> None: + """Test tokenCreate method of ServiceAuthorization class.""" + mock_response = mockTokenCreate() + requests_mock.post("https://api.tago.io/serviceauth", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + + token_data = { + "name": "New Service Token", + "verification_code": "verification_code_789", + "permission": "full", + } + + result = resources.serviceAuthorization.tokenCreate(token_data) + + # Check if result has expected structure + assert result["token"] == "new_token_id" + assert result["name"] == "New Service Token" + assert result["profile"] == "profile_id_123" + assert result["additional_parameters"] == "verification_code_789" + + +def testServiceAuthorizationMethodTokenDelete(requests_mock: Mocker) -> None: + """Test tokenDelete method of ServiceAuthorization class.""" + mock_response = {"status": True, "result": "Token Successfully Removed"} + requests_mock.delete("https://api.tago.io/serviceauth/token_id_1", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + result = resources.serviceAuthorization.tokenDelete("token_id_1") + + # Check if result has expected message + assert result == "Token Successfully Removed" + + +def testServiceAuthorizationMethodTokenEdit(requests_mock: Mocker) -> None: + """Test tokenEdit method of ServiceAuthorization class.""" + mock_response = {"status": True, "result": "Authorization Code Successfully Updated"} + requests_mock.put("https://api.tago.io/serviceauth/token_id_1", json=mock_response) + + resources = Resources({"token": "your_token_value"}) + result = resources.serviceAuthorization.tokenEdit("token_id_1", "new_verification_code") + + # Check if result has expected message + assert result == "Authorization Code Successfully Updated" From fc8d45ca6d5030638c5372146da70eef16b417ca Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Mon, 12 May 2025 16:43:35 -0300 Subject: [PATCH 25/26] Add ServiceAuthorization documentation and integrate into Resources index --- .../Service_Authorization_Types.rst | 59 ++++++++ .../Resources/ServiceAuthorization/index.rst | 132 ++++++++++++++++++ docs/source/Resources/index.rst | 1 + 3 files changed, 192 insertions(+) create mode 100644 docs/source/Resources/ServiceAuthorization/Service_Authorization_Types.rst create mode 100644 docs/source/Resources/ServiceAuthorization/index.rst diff --git a/docs/source/Resources/ServiceAuthorization/Service_Authorization_Types.rst b/docs/source/Resources/ServiceAuthorization/Service_Authorization_Types.rst new file mode 100644 index 0000000..8279ba7 --- /dev/null +++ b/docs/source/Resources/ServiceAuthorization/Service_Authorization_Types.rst @@ -0,0 +1,59 @@ +**Service Authorization Types** +=============================== + + +.. _GenericTokenAuthorization: + +GenericToken +----------- + + | **GenericToken**: str + | Token used on TagoIO, string with 34 characters + + +.. _TokenCreateResponseAuthorization: + +TokenCreateResponse +----------------- + + **Attributes:** + + | token: :ref:`GenericTokenAuthorization` + | The authorization token. + + | name: str + | Name of the token. + + | profile: :ref:`GenericID` + | Profile ID associated with the token. + + | additional_parameters: Optional[str] + | [Optional] Verification code to validate middleware requests. + + +.. _ServiceAuthorizationFilter: + +ServiceAuthorizationFilter +------------------------ + + **Attributes:** + + | name: str + | Name to filter service authorizations by. + + | token: :ref:`GenericToken` + | Token to filter service authorizations by. + + +.. _ServiceAuthorizationQuery: + +ServiceAuthorizationQuery(:ref:`Query`) +------------------- + + **Attributes:** + + | fields: Optional[List["name" or "token" or "verification_code" or "created_at"]] + | List of fields to include in the query results. + + | filter: Optional[:ref:`ServiceAuthorizationFilter`] + | Filter criteria for the query. diff --git a/docs/source/Resources/ServiceAuthorization/index.rst b/docs/source/Resources/ServiceAuthorization/index.rst new file mode 100644 index 0000000..55afa8b --- /dev/null +++ b/docs/source/Resources/ServiceAuthorization/index.rst @@ -0,0 +1,132 @@ +**Service Authorization** +======================= + +Manage service authorization tokens. + +=========== +tokenList +=========== + +Retrieves a paginated list of all service authorization tokens with filtering and sorting options. + +See: `Authorization `_ + + **Parameters:** + + | *Optional* **query**: :ref:`ServiceAuthorizationQuery` + | Query parameters to filter the results. + + .. code-block:: + :caption: **Default query:** + + query = { + "page": 1, + "fields": ["name", "token"], + "filter": {}, + "amount": 20, + "orderBy": ["created_at", "desc"] + } + + **Returns:** + + | list[:ref:`TokenDataList`] + + .. code-block:: python + + # If receive an error "Authorization Denied", check policy "Service Authorization" / "Access" in Access Management. + from tagoio_sdk import Resources + + resources = Resources() + result = resources.serviceAuthorization.tokenList({ + "page": 1, + "fields": ["name", "token", "verification_code"], + "amount": 20 + }) + print(result) # [ { 'name': 'API Service Token', 'token': 'token-xyz-123' } ] + + +============ +tokenCreate +============ + +Generates and retrieves a new service authorization token with specified permissions. + +See: `Authorization `_ + + **Parameters:** + + | **tokenParams**: :ref:`TokenData` + | Parameters for the new token + + **Returns:** + + | :ref:`TokenCreateResponseAuthorization` + + .. code-block:: python + + # If receive an error "Authorization Denied", check policy "Service Authorization" / "Create" in Access Management. + from tagoio_sdk import Resources + + resources = Resources() + result = resources.serviceAuthorization.tokenCreate({ + "name": "Service Token", + "verification_code": "additional parameter" + }) + print(result) # { 'token': 'token-xyz-123', 'name': 'Service Token', ... } + + +============ +tokenDelete +============ + +Permanently removes a service authorization token. + +See: `Authorization `_ + + **Parameters:** + + | **token**: :ref:`GenericTokenAuthorization` + | Token to be deleted + + **Returns:** + + | str + + .. code-block:: python + + # If receive an error "Authorization Denied", check policy "Service Authorization" / "Delete" in Access Management. + from tagoio_sdk import Resources + + resources = Resources() + result = resources.serviceAuthorization.tokenDelete("token-xyz-123") + print(result) # Token Successfully Removed + + +=========== +tokenEdit +=========== + +Updates a service authorization token with an optional verification code. + +See: `Authorization `_ + + **Parameters:** + + | **token**: :ref:`GenericTokenAuthorization` + | Token to be updated + + | *Optional* **verificationCode**: str + | New verification code for the token + + **Returns:** + + | str + + .. code-block:: python + + # If receive an error "Authorization Denied", check policy "Service Authorization" / "Edit" in Access Management. + from tagoio_sdk import Resources + + resources = Resources() + result = resources.serviceAuthorization.tokenEdit("token-xyz-123", "verification-code") + print(result) # Authorization Code Successfully Updated diff --git a/docs/source/Resources/index.rst b/docs/source/Resources/index.rst index 671d83c..1a758ff 100644 --- a/docs/source/Resources/index.rst +++ b/docs/source/Resources/index.rst @@ -41,3 +41,4 @@ Instance Profile/index Run/index Secrets/index + ServiceAuthorization/index From 40dbbb4d4cea09c4bb55dcf07f1589bb7786c259 Mon Sep 17 00:00:00 2001 From: Mateus Silva Date: Mon, 12 May 2025 16:43:46 -0300 Subject: [PATCH 26/26] Refactor import statement in ServiceAuthorization module to remove unused Dict type --- src/tagoio_sdk/modules/Resources/Service_Authorization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tagoio_sdk/modules/Resources/Service_Authorization.py b/src/tagoio_sdk/modules/Resources/Service_Authorization.py index 2f355af..abe1168 100644 --- a/src/tagoio_sdk/modules/Resources/Service_Authorization.py +++ b/src/tagoio_sdk/modules/Resources/Service_Authorization.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional +from typing import List, Optional from tagoio_sdk.common.Common_Type import TokenDataList, TokenData from tagoio_sdk.common.tagoio_module import TagoIOModule