From fdeae26b314614050595c500aff10fa33b6a0aca Mon Sep 17 00:00:00 2001 From: Max Inno Date: Thu, 1 May 2025 02:26:16 +0300 Subject: [PATCH 1/2] feat: string-based API support --- .../api_resources/branches/__init__.py | 1 + crowdin_api/api_resources/branches/enums.py | 17 ++ .../api_resources/branches/resource.py | 256 ++++++++++++++++++ .../branches/tests/test_branches.py | 185 +++++++++++++ crowdin_api/api_resources/branches/types.py | 26 ++ crowdin_api/api_resources/clients/__init__.py | 1 + crowdin_api/api_resources/clients/resource.py | 30 ++ .../api_resources/projects/resource.py | 9 +- .../projects/tests/test_projects_resources.py | 3 + 9 files changed, 527 insertions(+), 1 deletion(-) create mode 100644 crowdin_api/api_resources/branches/__init__.py create mode 100644 crowdin_api/api_resources/branches/enums.py create mode 100644 crowdin_api/api_resources/branches/resource.py create mode 100644 crowdin_api/api_resources/branches/tests/test_branches.py create mode 100644 crowdin_api/api_resources/branches/types.py create mode 100644 crowdin_api/api_resources/clients/__init__.py create mode 100644 crowdin_api/api_resources/clients/resource.py diff --git a/crowdin_api/api_resources/branches/__init__.py b/crowdin_api/api_resources/branches/__init__.py new file mode 100644 index 0000000..9f77d63 --- /dev/null +++ b/crowdin_api/api_resources/branches/__init__.py @@ -0,0 +1 @@ +__pdoc__ = {'tests': False} diff --git a/crowdin_api/api_resources/branches/enums.py b/crowdin_api/api_resources/branches/enums.py new file mode 100644 index 0000000..727c599 --- /dev/null +++ b/crowdin_api/api_resources/branches/enums.py @@ -0,0 +1,17 @@ +from enum import Enum + + +class EditBranchPatchPath(Enum): + NAME = "/name" + TITLE = "/title" + PRIORITY = "/priority" + + +class ListBranchesOrderBy(Enum): + ID = "id" + NAME = "name" + TITLE = "title" + CREATED_AT = "createdAt" + UPDATED_AT = "updatedAt" + EXPORT_PATTERN = "exportPattern" + PRIORITY = "priority" diff --git a/crowdin_api/api_resources/branches/resource.py b/crowdin_api/api_resources/branches/resource.py new file mode 100644 index 0000000..524dfcd --- /dev/null +++ b/crowdin_api/api_resources/branches/resource.py @@ -0,0 +1,256 @@ +from typing import Optional, Iterable + +from crowdin_api.api_resources.abstract.resources import BaseResource +from crowdin_api.api_resources.branches.types import ( + CloneBranchRequest, + AddBranchRequest, + EditBranchPatch, + MergeBranchRequest +) +from crowdin_api.sorting import Sorting + + +class BranchesResource(BaseResource): + """ + Resource for Bundles + + Link to documentation: + https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/string-based/#tag/Branches + """ + + def get_cloned_branch( + self, + project_id: int, + branch_id: int, + clone_id: str + ): + """ + Get Cloned Branch + + Link to documentation: + https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.clones.branch.get + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/string-based/#tag/Branches/operation/api.projects.branches.clones.branch.get + """ + + return self.requester.request( + method="get", + path=f"projects/{project_id}/branches/{branch_id}/clones/{clone_id}/branch" + ) + + def get_branch_clones_path(self, project_id: int, branch_id: int, clone_id: Optional[str] = None): + if clone_id is not None: + return f"projects/{project_id}/branches/{branch_id}/clones/{clone_id}" + return f"projects/{project_id}/branches/{branch_id}/clones" + + def clone_branch( + self, + project_id: int, + branch_id: int, + request_data: CloneBranchRequest + ): + """ + Clone Branch + + Link to documentation: + https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.clones.post + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/string-based/#tag/Branches/operation/api.projects.branches.clones.post + """ + + return self.requester.request( + method="post", + path=self.get_branch_clones_path(project_id, branch_id), + request_data=request_data + ) + + def check_branch_clone_status( + self, + project_id: int, + branch_id: int, + clone_id: str + ): + """ + Check Branch Clone Status + + Link to documentation: + https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.clones.get + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/string-based/#tag/Branches/operation/api.projects.branches.clones.get + """ + + return self.requester.request( + method="get", + path=self.get_branch_clones_path(project_id, branch_id, clone_id) + ) + + def get_branches_path( + self, + project_id: int, + branch_id: Optional[int] = None + ): + if branch_id is not None: + return f"projects/{project_id}/branches/{branch_id}" + + return f"projects/{project_id}/branches" + + def list_branches( + self, + project_id: int, + name: Optional[str] = None, + order_by: Optional[Sorting] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + ): + """ + List Branches + + Link to documentation: + https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.getMany + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/string-based/#tag/Branches/operation/api.projects.branches.getMany + """ + + params = { + "name": name, + "orderBy": order_by, + } + params.update(self.get_page_params(limit=limit, offset=offset)) + + return self.requester.request( + method="get", + path=self.get_branches_path(project_id), + params=params, + ) + + def add_branch( + self, + project_id: int, + request_data: AddBranchRequest + ): + """ + Add Branch + + Link to documentation: + https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.post + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/string-based/#tag/Branches/operation/api.projects.branches.post + """ + + return self.requester.request( + method="post", + path=self.get_branches_path(project_id), + request_data=request_data, + ) + + def get_branch(self, project_id: int, branch_id: int): + """ + Get Branch + + Link to documentation: + https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.get + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/string-based/#tag/Branches/operation/api.projects.branches.get + """ + + return self.requester.request( + method="get", + path=self.get_branches_path(project_id, branch_id), + ) + + def delete_branch(self, project_id: int, branch_id: int): + """ + Delete Branch + + Link to documentation: + https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.delete + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/string-based/#tag/Branches/operation/api.projects.branches.delete + """ + + return self.requester.request( + method="delete", + path=self.get_branches_path(project_id, branch_id), + ) + + def edit_branch(self, project_id: int, branch_id: int, patches: Iterable[EditBranchPatch]): + """ + Edit Branch + + Link to documentation: + https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.patch + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/string-based/#tag/Branches/operation/api.projects.branches.patch + """ + + return self.requester.request( + method="patch", + path=self.get_branches_path(project_id, branch_id), + request_data=patches, + ) + + def get_branch_merges_path(self, project_id: int, branch_id: int, merge_id: Optional[int] = None): + if merge_id is not None: + return f"projects/{project_id}/branches/{branch_id}/merges/{merge_id}" + + return f"projects/{project_id}/branches/{branch_id}/merges" + + def merge_branch(self, project_id: int, branch_id: int, request: MergeBranchRequest): + """ + Merge Branch + + Link to documentation: + https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.merges.post + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/string-based/#tag/Branches/operation/api.projects.branches.merges.post + """ + + return self.requester.request( + method="post", + path=self.get_branch_merges_path(project_id, branch_id), + request_data=request, + ) + + def check_branch_merge_status(self, project_id: int, branch_id: int, merge_id: int): + """ + Check Branch Merge Status + + Link to documentation: + https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.merges.get + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/string-based/#tag/Branches/operation/api.projects.branches.merges.get + """ + + return self.requester.request( + method="get", + path=self.get_branch_merges_path(project_id, branch_id, merge_id), + ) + + def get_branch_merge_summary(self, project_id: int, branch_id: int, merge_id: int): + """ + Get Branch Merge Summary + + Link to documentation: + https://support.crowdin.com/developer/api/v2/string-based/#tag/Branches/operation/api.projects.branches.merges.summary.get + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/string-based/#tag/Branches/operation/api.projects.branches.merges.summary.get + """ + + return self.requester.request( + method="get", + path=self.get_branch_merges_path(project_id, branch_id, merge_id) + "/summary", + ) diff --git a/crowdin_api/api_resources/branches/tests/test_branches.py b/crowdin_api/api_resources/branches/tests/test_branches.py new file mode 100644 index 0000000..440f831 --- /dev/null +++ b/crowdin_api/api_resources/branches/tests/test_branches.py @@ -0,0 +1,185 @@ +from unittest import mock + +import pytest + +from crowdin_api.api_resources.branches.enums import ListBranchesOrderBy +from crowdin_api.api_resources.branches.resource import BranchesResource +from crowdin_api.api_resources.branches.types import CloneBranchRequest, AddBranchRequest +from crowdin_api.requester import APIRequester +from crowdin_api.sorting import SortingRule, Sorting, SortingOrder + + +class TestBranchesResource: + resource_class = BranchesResource + + def get_resource(self, base_absolut_url): + return self.resource_class(requester=APIRequester(base_url=base_absolut_url)) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_cloned_branch(self, m_request, base_absolut_url): + m_request.return_value = "response" + + project_id = 1 + branch_id = 2 + clone_id = "id" + + resource = self.get_resource(base_absolut_url) + assert resource.get_cloned_branch(project_id, branch_id, clone_id) == "response" + m_request.assert_called_once_with( + method="get", + path=f"projects/{project_id}/branches/{branch_id}/clones/{clone_id}/branch", + ) + + @pytest.mark.parametrize( + "incoming_data, request_data", + ( + ( + CloneBranchRequest( + name="Branch name", + title="Branch title" + ), + { + "name": "Branch name", + "title": "Branch title" + } + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_clone_branch(self, m_request, incoming_data, request_data, base_absolut_url): + m_request.return_value = "response" + + project_id = 1 + branch_id = 2 + + resource = self.get_resource(base_absolut_url) + assert resource.clone_branch(project_id, branch_id, incoming_data) == "response" + m_request.assert_called_once_with( + method="post", + path=f"projects/{project_id}/branches/{branch_id}/clones", + request_data=request_data, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_check_branch_clone_status(self, m_request, base_absolut_url): + m_request.return_value = "response" + + project_id = 1 + branch_id = 2 + clone_id = "id" + + resource = self.get_resource(base_absolut_url) + assert resource.check_branch_clone_status(project_id, branch_id, clone_id) + m_request.assert_called_once_with( + method="get", + path=f"projects/{project_id}/branches/{branch_id}/clones/{clone_id}", + ) + + @pytest.mark.parametrize( + "in_params, query_params", + ( + ( + { + "name": "My branch", + "order_by": Sorting( + [SortingRule(ListBranchesOrderBy.CREATED_AT, SortingOrder.DESC)] + ), + "limit": 10, + "offset": 2 + }, + { + "name": "My branch", + "orderBy": Sorting( + [SortingRule(ListBranchesOrderBy.CREATED_AT, SortingOrder.DESC)] + ), + "limit": 10, + "offset": 2 + }, + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_list_branches(self, m_request, in_params, query_params, base_absolut_url): + m_request.return_value = "response" + + project_id = 1 + + resource = self.get_resource(base_absolut_url) + assert resource.list_branches(project_id=project_id, **in_params) == "response" + m_request.assert_called_once_with( + method="get", + path=f"projects/{project_id}/branches", + params=query_params, + ) + + @pytest.mark.parametrize( + "incoming_data, request_data", + ( + ( + AddBranchRequest( + name="New branch", + title="Title of new branch" + ), + { + "name": "New branch", + "title": "Title of new branch" + } + ), + ), + ) + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_add_branch(self, m_request, incoming_data, request_data, base_absolut_url): + m_request.return_value = "response" + + project_id = 1 + + resource = self.get_resource(base_absolut_url) + assert resource.add_branch(project_id, incoming_data) == "response" + m_request.assert_called_once_with( + method="post", + path=f"projects/{project_id}/branches", + request_data=request_data, + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_get_branch(self, m_request, base_absolut_url): + m_request.return_value = "response" + + project_id = 1 + branch_id = 2 + + resource = self.get_resource(base_absolut_url) + assert resource.get_branch(project_id, branch_id) == "response" + m_request.assert_called_once_with( + method="get", + path=f"projects/{project_id}/branches/{branch_id}", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_delete_branch(self, m_request, base_absolut_url): + m_request.return_value = "response" + + project_id = 1 + branch_id = 2 + + resource = self.get_resource(base_absolut_url) + assert resource.delete_branch(project_id, branch_id) == "response" + m_request.assert_called_once_with( + method="delete", + path=f"projects/{project_id}/branches/{branch_id}", + ) + + @mock.patch("crowdin_api.requester.APIRequester.request") + def test_edit_branch(self, m_request, in_body, request_body, base_absolut_url): + m_request.return_value = "response" + + project_id = 1 + branch_id = 2 + + resource = self.get_resource(base_absolut_url) + assert resource.edit_branch(project_id, branch_id, in_body) == "response" + m_request.assert_called_once_with( + method="delete", + path=f"projects/{project_id}/branches/{branch_id}", + request_params=request_body + ) diff --git a/crowdin_api/api_resources/branches/types.py b/crowdin_api/api_resources/branches/types.py new file mode 100644 index 0000000..580a542 --- /dev/null +++ b/crowdin_api/api_resources/branches/types.py @@ -0,0 +1,26 @@ +from typing import TypedDict, Optional, Any + +from crowdin_api.api_resources.branches.enums import EditBranchPatchPath +from crowdin_api.api_resources.enums import PatchOperation + + +class CloneBranchRequest(TypedDict): + name: str + title: Optional[str] + + +class AddBranchRequest(TypedDict): + name: str + title: Optional[str] + + +class EditBranchPatch(TypedDict): + op: PatchOperation + path: EditBranchPatchPath + value: Any + + +class MergeBranchRequest(TypedDict): + deleteAfterMerge: Optional[bool] + sourceBranchId: int + dryRun: Optional[bool] diff --git a/crowdin_api/api_resources/clients/__init__.py b/crowdin_api/api_resources/clients/__init__.py new file mode 100644 index 0000000..9f77d63 --- /dev/null +++ b/crowdin_api/api_resources/clients/__init__.py @@ -0,0 +1 @@ +__pdoc__ = {'tests': False} diff --git a/crowdin_api/api_resources/clients/resource.py b/crowdin_api/api_resources/clients/resource.py new file mode 100644 index 0000000..428644f --- /dev/null +++ b/crowdin_api/api_resources/clients/resource.py @@ -0,0 +1,30 @@ +from typing import Optional + +from crowdin_api.api_resources.abstract.resources import BaseResource + + +class ClientsResource(BaseResource): + """ + Resource for Clients. + + Link to documentation for enterprise: + https://developer.crowdin.com/enterprise/api/v2/#tag/Clients + """ + + def list_clients( + self, + limit: Optional[int] = None, + offset: Optional[int] = None + ): + """ + List Clients + + Link to documentation for enterprise: + https://support.crowdin.com/developer/enterprise/api/v2/#tag/Clients/operation/api.clients.getMany + """ + + return self.requester.request( + method="get", + path="/clients", + params=self.get_page_params(offset=offset, limit=limit) + ) diff --git a/crowdin_api/api_resources/projects/resource.py b/crowdin_api/api_resources/projects/resource.py index d512ea6..48c2566 100644 --- a/crowdin_api/api_resources/projects/resource.py +++ b/crowdin_api/api_resources/projects/resource.py @@ -55,6 +55,7 @@ def list_projects( groupId: Optional[int] = None, userId: Optional[Union[int, str]] = None, hasManagerAccess: Optional[HasManagerAccess] = None, + type: Optional[ProjectType] = None ): """ List Projects. @@ -63,7 +64,13 @@ def list_projects( https://developer.crowdin.com/api/v2/#operation/api.projects.getMany """ - params = {"orderBy": orderBy, "userId": userId, "hasManagerAccess": hasManagerAccess, "groupId": groupId} + params = { + "orderBy": orderBy, + "userId": userId, + "hasManagerAccess": hasManagerAccess, + "groupId": groupId, + "type": type.value if type is not None else None + } params.update(self.get_page_params(page=page, offset=offset, limit=limit)) return self._get_entire_data(method="get", path=self.get_projects_path(), params=params) diff --git a/crowdin_api/api_resources/projects/tests/test_projects_resources.py b/crowdin_api/api_resources/projects/tests/test_projects_resources.py index d9509d4..aef7f27 100644 --- a/crowdin_api/api_resources/projects/tests/test_projects_resources.py +++ b/crowdin_api/api_resources/projects/tests/test_projects_resources.py @@ -60,6 +60,7 @@ def test_get_projects_path(self, projectId, path, base_absolut_url): "userId": 1, "groupId": 1, "hasManagerAccess": HasManagerAccess.TRUE, + "type": ProjectType.STRING_BASED }, { "orderBy": Sorting( @@ -70,6 +71,7 @@ def test_get_projects_path(self, projectId, path, base_absolut_url): "userId": 1, "groupId": 1, "hasManagerAccess": HasManagerAccess.TRUE, + "type": 1 }, ), ( @@ -81,6 +83,7 @@ def test_get_projects_path(self, projectId, path, base_absolut_url): "userId": None, "groupId": None, "hasManagerAccess": None, + "type": None }, ), ), From e90a5ad87558a2e2cbbea57ae5020a30b2d69603 Mon Sep 17 00:00:00 2001 From: Max Inno Date: Thu, 1 May 2025 02:32:56 +0300 Subject: [PATCH 2/2] fix: test --- .../branches/tests/test_branches.py | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/crowdin_api/api_resources/branches/tests/test_branches.py b/crowdin_api/api_resources/branches/tests/test_branches.py index 440f831..9a074f3 100644 --- a/crowdin_api/api_resources/branches/tests/test_branches.py +++ b/crowdin_api/api_resources/branches/tests/test_branches.py @@ -2,9 +2,10 @@ import pytest -from crowdin_api.api_resources.branches.enums import ListBranchesOrderBy +from crowdin_api.api_resources.branches.enums import ListBranchesOrderBy, EditBranchPatchPath from crowdin_api.api_resources.branches.resource import BranchesResource -from crowdin_api.api_resources.branches.types import CloneBranchRequest, AddBranchRequest +from crowdin_api.api_resources.branches.types import CloneBranchRequest, AddBranchRequest, EditBranchPatch +from crowdin_api.api_resources.enums import PatchOperation from crowdin_api.requester import APIRequester from crowdin_api.sorting import SortingRule, Sorting, SortingOrder @@ -169,6 +170,27 @@ def test_delete_branch(self, m_request, base_absolut_url): path=f"projects/{project_id}/branches/{branch_id}", ) + @pytest.mark.parametrize( + "in_body, request_body", + ( + ( + [ + EditBranchPatch( + op=PatchOperation.REPLACE.value, + path=EditBranchPatchPath.NAME.value, + value="New name" + ) + ], + [ + { + "op": "replace", + "path": "/name", + "value": "New name" + } + ] + ), + ), + ) @mock.patch("crowdin_api.requester.APIRequester.request") def test_edit_branch(self, m_request, in_body, request_body, base_absolut_url): m_request.return_value = "response" @@ -179,7 +201,7 @@ def test_edit_branch(self, m_request, in_body, request_body, base_absolut_url): resource = self.get_resource(base_absolut_url) assert resource.edit_branch(project_id, branch_id, in_body) == "response" m_request.assert_called_once_with( - method="delete", + method="patch", path=f"projects/{project_id}/branches/{branch_id}", - request_params=request_body + request_data=request_body )