From 0018fd7453d336bb11eb0cb175160a9c4bb11d78 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Thu, 10 Jul 2025 13:49:43 +0200 Subject: [PATCH 01/12] explicitly allow creation of MC without auth, do not try to check auth if it was not provided --- mergin/client.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/mergin/client.py b/mergin/client.py index 6456c1b..9895e55 100644 --- a/mergin/client.py +++ b/mergin/client.py @@ -94,7 +94,7 @@ def __init__( proxy_config=None, ): self.url = url if url is not None else MerginClient.default_url() - self._auth_params = None + self._auth_params = {} self._auth_session = None self._user_info = None self._server_type = None @@ -102,6 +102,7 @@ def __init__( self.client_version = "Python-client/" + __version__ if plugin_version is not None: # this could be e.g. "Plugin/2020.1 QGIS/3.14" self.client_version += " " + plugin_version + self._has_auth = auth_token is not None or login is not None or password is not None self.setup_logging() if auth_token: try: @@ -196,7 +197,10 @@ def _check_token(f): """Wrapper for creating/renewing authorization token.""" def wrapper(self, *args): - if self._auth_params: + + # if user has not provided auth information (token or login/password) it does not make sense to try these checks + # the client without auth can still be used to access public information like server config + if self._has_auth: if self._auth_session: # Refresh auth token if it expired or will expire very soon delta = self._auth_session["expire"] - datetime.now(timezone.utc) @@ -209,7 +213,7 @@ def wrapper(self, *args): raise AuthTokenExpiredError("Token has expired - please re-login") else: # Create a new authorization token - self.log.info(f"No token - login user: {self._auth_params['login']}") + self.log.info(f"No token - login user: {self._auth_params.get('login', None)}") if self._auth_params.get("login", None) and self._auth_params.get("password", None): self.login(self._auth_params["login"], self._auth_params["password"]) else: From f0be54723cc2f144e1bd27f0674ed8b3fb916d56 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Fri, 11 Jul 2025 10:21:58 +0200 Subject: [PATCH 02/12] do not store _has_auth variable --- mergin/client.py | 35 +++++++++++++++-------------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/mergin/client.py b/mergin/client.py index 9895e55..b389a4c 100644 --- a/mergin/client.py +++ b/mergin/client.py @@ -102,7 +102,6 @@ def __init__( self.client_version = "Python-client/" + __version__ if plugin_version is not None: # this could be e.g. "Plugin/2020.1 QGIS/3.14" self.client_version += " " + plugin_version - self._has_auth = auth_token is not None or login is not None or password is not None self.setup_logging() if auth_token: try: @@ -196,28 +195,24 @@ def user_agent_info(self): def _check_token(f): """Wrapper for creating/renewing authorization token.""" - def wrapper(self, *args): - - # if user has not provided auth information (token or login/password) it does not make sense to try these checks - # the client without auth can still be used to access public information like server config - if self._has_auth: - if self._auth_session: - # Refresh auth token if it expired or will expire very soon - delta = self._auth_session["expire"] - datetime.now(timezone.utc) - if delta.total_seconds() < 5: - self.log.info("Token has expired - refreshing...") - if self._auth_params.get("login", None) and self._auth_params.get("password", None): - self.log.info("Token has expired - refreshing...") - self.login(self._auth_params["login"], self._auth_params["password"]) - else: - raise AuthTokenExpiredError("Token has expired - please re-login") - else: - # Create a new authorization token - self.log.info(f"No token - login user: {self._auth_params.get('login', None)}") + def wrapper(self, *args, **kwargs): + if self._auth_session: + # Refresh auth token if it expired or will expire very soon + delta = self._auth_session["expire"] - datetime.now(timezone.utc) + if delta.total_seconds() < 5: + self.log.info("Token has expired - refreshing...") if self._auth_params.get("login", None) and self._auth_params.get("password", None): + self.log.info("Token has expired - refreshing...") self.login(self._auth_params["login"], self._auth_params["password"]) else: - raise ClientError("Missing login or password") + raise AuthTokenExpiredError("Token has expired - please re-login") + else: + # Create a new authorization token + self.log.info(f"No token - login user: {self._auth_params.get('login', None)}") + if self._auth_params.get("login", None) and self._auth_params.get("password", None): + self.login(self._auth_params["login"], self._auth_params["password"]) + else: + raise ClientError("Missing login or password") return f(self, *args) From 31e3899e563ead105294828cfd30158ffb624d94 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Fri, 11 Jul 2025 10:22:21 +0200 Subject: [PATCH 03/12] handle kwargs here --- mergin/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mergin/client.py b/mergin/client.py index b389a4c..6e56e2c 100644 --- a/mergin/client.py +++ b/mergin/client.py @@ -214,7 +214,7 @@ def wrapper(self, *args, **kwargs): else: raise ClientError("Missing login or password") - return f(self, *args) + return f(self, *args, **kwargs) return wrapper From ea819406d44066bc00077d182e4791c439ccbaad Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Fri, 11 Jul 2025 10:22:47 +0200 Subject: [PATCH 04/12] do not check token in _do_request() --- mergin/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mergin/client.py b/mergin/client.py index 6e56e2c..3cc0006 100644 --- a/mergin/client.py +++ b/mergin/client.py @@ -218,7 +218,6 @@ def wrapper(self, *args, **kwargs): return wrapper - @_check_token def _do_request(self, request): """General server request method.""" if self._auth_session: From e428182e75f9d18b71fcea3de732568fa4e037cd Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Fri, 11 Jul 2025 10:23:51 +0200 Subject: [PATCH 05/12] use _check_token on functions that actually need user to have valid token --- mergin/client.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/mergin/client.py b/mergin/client.py index 3cc0006..1d756e2 100644 --- a/mergin/client.py +++ b/mergin/client.py @@ -333,6 +333,7 @@ def username(self): return None # not authenticated return self._user_info["username"] + @_check_token def workspace_service(self, workspace_id): """ This Requests information about a workspace service from /workspace/{id}/service endpoint, @@ -343,6 +344,7 @@ def workspace_service(self, workspace_id): resp = self.get(f"/v1/workspace/{workspace_id}/service") return json.load(resp) + @_check_token def workspace_usage(self, workspace_id): """ This Requests information about a workspace usage from /workspace/{id}/usage endpoint, @@ -395,6 +397,7 @@ def server_version(self): return self._server_version + @_check_token def workspaces_list(self): """ Find all available workspaces @@ -405,6 +408,7 @@ def workspaces_list(self): workspaces = json.load(resp) return workspaces + @_check_token def create_workspace(self, workspace_name): """ Create new workspace for currently active user. @@ -423,6 +427,7 @@ def create_workspace(self, workspace_name): e.extra = f"Workspace name: {workspace_name}" raise e + @_check_token def create_project(self, project_name, is_public=False, namespace=None): """ Create new project repository in user namespace on Mergin Maps server. @@ -472,6 +477,7 @@ def create_project(self, project_name, is_public=False, namespace=None): e.extra = f"Namespace: {namespace}, project name: {project_name}" raise e + @_check_token def create_project_and_push(self, project_name, directory, is_public=False, namespace=None): """ Convenience method to create project and push the initial version right after that. @@ -517,6 +523,7 @@ def create_project_and_push(self, project_name, directory, is_public=False, name if mp.inspect_files(): self.push_project(directory) + @_check_token def paginated_projects_list( self, page=1, @@ -590,6 +597,7 @@ def paginated_projects_list( projects = json.load(resp) return projects + @_check_token def projects_list( self, tags=None, @@ -659,6 +667,7 @@ def projects_list( break return projects + @_check_token def project_info(self, project_path_or_id, since=None, version=None): """ Fetch info about project. @@ -682,6 +691,7 @@ def project_info(self, project_path_or_id, since=None, version=None): resp = self.get("/v1/project/{}".format(project_path_or_id), params) return json.load(resp) + @_check_token def paginated_project_versions(self, project_path, page, per_page=100, descending=False): """ Get records of project's versions (history) using calculated pagination. @@ -703,6 +713,7 @@ def paginated_project_versions(self, project_path, page, per_page=100, descendin resp_json = json.load(resp) return resp_json["versions"], resp_json["count"] + @_check_token def project_versions_count(self, project_path): """ return the total count of versions @@ -718,6 +729,7 @@ def project_versions_count(self, project_path): resp_json = json.load(resp) return resp_json["count"] + @_check_token def project_versions(self, project_path, since=1, to=None): """ Get records of project's versions (history) in ascending order. @@ -765,6 +777,7 @@ def project_versions(self, project_path, since=1, to=None): filtered_versions = list(filter(lambda v: (num_since <= int_version(v["name"]) <= num_to), versions)) return filtered_versions + @_check_token def download_project(self, project_path, directory, version=None): """ Download project into given directory. If version is not specified, latest version is downloaded @@ -782,6 +795,7 @@ def download_project(self, project_path, directory, version=None): download_project_wait(job) download_project_finalize(job) + @_check_token def user_info(self): server_type = self.server_type() if server_type == ServerType.OLD: @@ -790,6 +804,7 @@ def user_info(self): resp = self.get("/v1/user/profile") return json.load(resp) + @_check_token def set_project_access(self, project_path, access): """ Updates access for given project. @@ -813,6 +828,7 @@ def set_project_access(self, project_path, access): e.extra = f"Project path: {project_path}" raise e + @_check_token def add_user_permissions_to_project(self, project_path, usernames, permission_level): """ Add specified permissions to specified users to project @@ -846,6 +862,7 @@ def add_user_permissions_to_project(self, project_path, usernames, permission_le category=DeprecationWarning, ) + @_check_token def remove_user_permissions_from_project(self, project_path, usernames): """ Removes specified users from project @@ -870,6 +887,7 @@ def remove_user_permissions_from_project(self, project_path, usernames): category=DeprecationWarning, ) + @_check_token def project_user_permissions(self, project_path): """ Returns permissions for project @@ -890,6 +908,7 @@ def project_user_permissions(self, project_path): result["readers"] = access.get("readersnames", []) return result + @_check_token def push_project(self, directory): """ Upload local changes to the repository. @@ -903,6 +922,7 @@ def push_project(self, directory): push_project_wait(job) push_project_finalize(job) + @_check_token def pull_project(self, directory): """ Fetch and apply changes from repository. @@ -916,6 +936,7 @@ def pull_project(self, directory): pull_project_wait(job) return pull_project_finalize(job) + @_check_token def clone_project(self, source_project_path, cloned_project_name, cloned_project_namespace=None): """ Clone project on server. @@ -961,6 +982,7 @@ def clone_project(self, source_project_path, cloned_project_name, cloned_project request = urllib.request.Request(url, data=json.dumps(data).encode(), headers=json_headers, method="POST") self._do_request(request) + @_check_token def delete_project_now(self, project_path): """ Delete project repository on server immediately. @@ -984,6 +1006,7 @@ def delete_project_now(self, project_path): request = urllib.request.Request(url, method="DELETE") self._do_request(request) + @_check_token def delete_project(self, project_path): """ Delete project repository on server. Newer servers since 2023 @@ -1008,6 +1031,7 @@ def delete_project(self, project_path): request = urllib.request.Request(url, method="DELETE") self._do_request(request) + @_check_token def project_status(self, directory): """ Get project status, e.g. server and local changes. @@ -1027,23 +1051,27 @@ def project_status(self, directory): return pull_changes, push_changes, push_changes_summary + @_check_token def project_version_info(self, project_id, version): """Returns JSON with detailed information about a single project version""" resp = self.get(f"/v1/project/version/{project_id}/{version}") return json.load(resp) + @_check_token def project_file_history_info(self, project_path, file_path): """Returns JSON with full history of a single file within a project""" params = {"path": file_path} resp = self.get("/v1/resource/history/{}".format(project_path), params) return json.load(resp) + @_check_token def project_file_changeset_info(self, project_path, file_path, version): """Returns JSON with changeset details of a particular version of a file within a project""" params = {"path": file_path} resp = self.get("/v1/resource/changesets/{}/{}".format(project_path, version), params) return json.load(resp) + @_check_token def get_projects_by_names(self, projects): """Returns JSON with projects' info for list of required projects. The schema of the returned information is the same as the response from projects_list(). @@ -1058,6 +1086,7 @@ def get_projects_by_names(self, projects): resp = self.post("/v1/project/by_names", {"projects": projects}, {"Content-Type": "application/json"}) return json.load(resp) + @_check_token def download_file(self, project_dir, file_path, output_filename, version=None): """ Download project file at specified version. Get the latest if no version specified. @@ -1111,6 +1140,7 @@ def get_file_diff(self, project_dir, file_path, output_diff, version_from, versi elif len(diffs) == 1: shutil.copy(diffs[0], output_diff) + @_check_token def download_file_diffs(self, project_dir, file_path, versions): """Download file diffs for specified versions if they are not present in the cache. @@ -1187,6 +1217,7 @@ def has_writing_permissions(self, project_path): info = self.project_info(project_path) return info["permissions"]["upload"] + @_check_token def rename_project(self, project_path: str, new_project_name: str) -> None: """ Rename project on server. @@ -1258,6 +1289,7 @@ def reset_local_changes(self, directory: str, files_to_reset: typing.List[str] = if files_download: self.download_files(directory, files_download, version=current_version) + @_check_token def download_files( self, project_dir: str, file_paths: typing.List[str], output_paths: typing.List[str] = None, version: str = None ): @@ -1283,6 +1315,7 @@ def has_editor_support(self): """ return is_version_acceptable(self.server_version(), "2024.4.0") + @_check_token def create_user( self, email: str, @@ -1314,6 +1347,7 @@ def create_user( user_info = self.post("v2/users", params, json_headers) return json.load(user_info) + @_check_token def get_workspace_member(self, workspace_id: int, user_id: int) -> dict: """ Get a workspace member detail @@ -1321,6 +1355,7 @@ def get_workspace_member(self, workspace_id: int, user_id: int) -> dict: resp = self.get(f"v2/workspaces/{workspace_id}/members/{user_id}") return json.load(resp) + @_check_token def list_workspace_members(self, workspace_id: int) -> List[dict]: """ Get a list of workspace members @@ -1328,6 +1363,7 @@ def list_workspace_members(self, workspace_id: int) -> List[dict]: resp = self.get(f"v2/workspaces/{workspace_id}/members") return json.load(resp) + @_check_token def update_workspace_member( self, workspace_id: int, user_id: int, workspace_role: WorkspaceRole, reset_projects_roles: bool = False ) -> dict: @@ -1343,12 +1379,14 @@ def update_workspace_member( workspace_member = self.patch(f"v2/workspaces/{workspace_id}/members/{user_id}", params, json_headers) return json.load(workspace_member) + @_check_token def remove_workspace_member(self, workspace_id: int, user_id: int): """ Remove a user from workspace members """ self.delete(f"v2/workspaces/{workspace_id}/members/{user_id}") + @_check_token def list_project_collaborators(self, project_id: str) -> List[dict]: """ Get a list of project collaborators @@ -1356,6 +1394,7 @@ def list_project_collaborators(self, project_id: str) -> List[dict]: project_collaborators = self.get(f"v2/projects/{project_id}/collaborators") return json.load(project_collaborators) + @_check_token def add_project_collaborator(self, project_id: str, user: str, project_role: ProjectRole) -> dict: """ Add a user to project collaborators and grant them a project role. @@ -1367,6 +1406,7 @@ def add_project_collaborator(self, project_id: str, user: str, project_role: Pro project_collaborator = self.post(f"v2/projects/{project_id}/collaborators", params, json_headers) return json.load(project_collaborator) + @_check_token def update_project_collaborator(self, project_id: str, user_id: int, project_role: ProjectRole) -> dict: """ Update project role of the existing project collaborator. @@ -1376,6 +1416,7 @@ def update_project_collaborator(self, project_id: str, user_id: int, project_rol project_collaborator = self.patch(f"v2/projects/{project_id}/collaborators/{user_id}", params, json_headers) return json.load(project_collaborator) + @_check_token def remove_project_collaborator(self, project_id: str, user_id: int): """ Remove a user from project collaborators @@ -1387,6 +1428,7 @@ def server_config(self) -> dict: response = self.get("/config") return json.load(response) + @_check_token def send_logs( self, logfile: str, From 53a3380767e249f78ac358dc045486ff62b7979d Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Fri, 11 Jul 2025 10:24:01 +0200 Subject: [PATCH 06/12] fix test --- mergin/test/test_client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mergin/test/test_client.py b/mergin/test/test_client.py index b5de8a6..d761a98 100644 --- a/mergin/test/test_client.py +++ b/mergin/test/test_client.py @@ -2888,8 +2888,7 @@ def test_mc_without_login(): with pytest.raises(ClientError) as e: mc.workspaces_list() - assert e.value.http_error == 401 - assert e.value.detail == '"Authentication information is missing or invalid."\n' + assert e.value.detail == "Missing login or password" def test_do_request_error_handling(mc: MerginClient): From 1c76fb26fa6e1a63124a0e4d3c71bbbe83bda247 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Fri, 11 Jul 2025 11:53:46 +0200 Subject: [PATCH 07/12] make _check_token function more readable --- mergin/client.py | 42 +++++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/mergin/client.py b/mergin/client.py index 1d756e2..8804cb8 100644 --- a/mergin/client.py +++ b/mergin/client.py @@ -192,27 +192,35 @@ def user_agent_info(self): system_version = platform.mac_ver()[0] return f"{self.client_version} ({platform.system()}/{system_version})" - def _check_token(f): - """Wrapper for creating/renewing authorization token.""" + def validate_auth(self): + """Validate that client has valid auth token or can be logged in.""" - def wrapper(self, *args, **kwargs): - if self._auth_session: - # Refresh auth token if it expired or will expire very soon - delta = self._auth_session["expire"] - datetime.now(timezone.utc) - if delta.total_seconds() < 5: - self.log.info("Token has expired - refreshing...") - if self._auth_params.get("login", None) and self._auth_params.get("password", None): - self.log.info("Token has expired - refreshing...") - self.login(self._auth_params["login"], self._auth_params["password"]) - else: - raise AuthTokenExpiredError("Token has expired - please re-login") - else: - # Create a new authorization token - self.log.info(f"No token - login user: {self._auth_params.get('login', None)}") + if self._auth_session: + # Refresh auth token if it expired or will expire very soon + delta = self._auth_session["expire"] - datetime.now(timezone.utc) + if delta.total_seconds() < 5: + self.log.info("Token has expired - refreshing...") if self._auth_params.get("login", None) and self._auth_params.get("password", None): + self.log.info("Token has expired - refreshing...") self.login(self._auth_params["login"], self._auth_params["password"]) else: - raise ClientError("Missing login or password") + raise AuthTokenExpiredError("Token has expired - please re-login") + else: + # Create a new authorization token + self.log.info(f"No token - login user: {self._auth_params.get('login', None)}") + if self._auth_params.get("login", None) and self._auth_params.get("password", None): + self.login(self._auth_params["login"], self._auth_params["password"]) + else: + raise ClientError("Missing login or password") + + @staticmethod + def _check_token(f): + """Wrapper for creating/renewing authorization token. + Every function that requires authentication should be decorated with this as @_check_token.""" + + def wrapper(self, *args, **kwargs): + # functions that run prior to required function using this decorator + self.validate_auth() return f(self, *args, **kwargs) From af43b8e082c8e3235b7b61416c871d2159011cf1 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Fri, 11 Jul 2025 11:54:52 +0200 Subject: [PATCH 08/12] simplify login function to use API instead of custom logic --- mergin/client.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/mergin/client.py b/mergin/client.py index 8804cb8..2cb82bc 100644 --- a/mergin/client.py +++ b/mergin/client.py @@ -309,24 +309,12 @@ def login(self, login, password): self._auth_session = None self.log.info(f"Going to log in user {login}") try: - self._auth_params = params - url = urllib.parse.urljoin(self.url, urllib.parse.quote("/v1/auth/login")) - data = json.dumps(self._auth_params, cls=DateTimeEncoder).encode("utf-8") - request = urllib.request.Request(url, data, {"Content-Type": "application/json"}, method="POST") - request.add_header("User-Agent", self.user_agent_info()) - resp = self.opener.open(request) + resp = self.post("/v1/auth/login", data=params, headers={"Content-Type": "application/json"}) data = json.load(resp) session = data["session"] - except urllib.error.HTTPError as e: - if e.headers.get("Content-Type", "") == "application/problem+json": - info = json.load(e) - self.log.info(f"Login problem: {info.get('detail')}") - raise LoginError(info.get("detail")) - self.log.info(f"Login problem: {e.read().decode('utf-8')}") - raise LoginError(e.read().decode("utf-8")) - except urllib.error.URLError as e: - # e.g. when DNS resolution fails (no internet connection?) - raise ClientError("failure reason: " + str(e.reason)) + except ClientError as e: + self.log.info(f"Login problem: {e.detail}") + raise LoginError(e.detail) self._auth_session = { "token": "Bearer %s" % session["token"], "expire": dateutil.parser.parse(session["expire"]), From db37a2e4ad333e39a479af2a764bddf21902eb52 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Fri, 11 Jul 2025 11:55:38 +0200 Subject: [PATCH 09/12] use f-string here --- mergin/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mergin/client.py b/mergin/client.py index 2cb82bc..4ad7c5e 100644 --- a/mergin/client.py +++ b/mergin/client.py @@ -316,7 +316,7 @@ def login(self, login, password): self.log.info(f"Login problem: {e.detail}") raise LoginError(e.detail) self._auth_session = { - "token": "Bearer %s" % session["token"], + "token": f"Bearer {session['token']}", "expire": dateutil.parser.parse(session["expire"]), } self._user_info = {"username": data["username"]} From a1ce295768e2ea2b00f3686d0bfec00f502ce8e3 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Fri, 11 Jul 2025 11:56:00 +0200 Subject: [PATCH 10/12] add test cases for validate_auth() --- mergin/test/test_client.py | 61 +++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/mergin/test/test_client.py b/mergin/test/test_client.py index d761a98..214e010 100644 --- a/mergin/test/test_client.py +++ b/mergin/test/test_client.py @@ -5,7 +5,7 @@ import tempfile import subprocess import shutil -from datetime import datetime, timedelta, date +from datetime import datetime, timedelta, date, timezone import pytest import pytz import sqlite3 @@ -14,6 +14,7 @@ from .. import InvalidProject from ..client import ( MerginClient, + AuthTokenExpiredError, ClientError, MerginProject, LoginError, @@ -2910,3 +2911,61 @@ def test_do_request_error_handling(mc: MerginClient): assert e.value.http_error == 400 assert "Passwords must be at least 8 characters long." in e.value.detail + + +def test_validate_auth(mc: MerginClient): + """Test validate authentication under different scenarios.""" + + # ----- Client without authentication ----- + mc_not_auth = MerginClient(SERVER_URL) + + with pytest.raises(ClientError) as e: + mc_not_auth.validate_auth() + + assert e.value.detail == "Missing login or password" + + # ----- Client with token ----- + # create a client with valid auth token based on other MerginClient instance, but not with username/password + mc_auth_token = MerginClient(SERVER_URL, auth_token=mc._auth_session["token"]) + + # this should pass and not raise an error + mc_auth_token.validate_auth() + + # manually set expire date to the past to simulate expired token + mc_auth_token._auth_session["expire"] = datetime.now(timezone.utc) - timedelta(days=1) + + # check that this raises an error + with pytest.raises(AuthTokenExpiredError): + mc_auth_token.validate_auth() + + # ----- Client with token and username/password ----- + # create a client with valid auth token based on other MerginClient instance with username/password that allows relogin if the token is expired + mc_auth_token_login = MerginClient( + SERVER_URL, auth_token=mc._auth_session["token"], login=API_USER, password=USER_PWD + ) + + # this should pass and not raise an error + mc_auth_token_login.validate_auth() + + # manually set expire date to the past to simulate expired token + mc_auth_token_login._auth_session["expire"] = datetime.now(timezone.utc) - timedelta(days=1) + + # this should pass and not raise an error, as the client is able to re-login + mc_auth_token_login.validate_auth() + + # ----- Client with token and username/WRONG password ----- + # create a client with valid auth token based on other MerginClient instance with username and WRONG password + # that does NOT allow relogin if the token is expired + mc_auth_token_login_wrong_password = MerginClient( + SERVER_URL, auth_token=mc._auth_session["token"], login=API_USER, password="WRONG_PASSWORD" + ) + + # this should pass and not raise an error + mc_auth_token_login_wrong_password.validate_auth() + + # manually set expire date to the past to simulate expired token + mc_auth_token_login_wrong_password._auth_session["expire"] = datetime.now(timezone.utc) - timedelta(days=1) + + # this should pass and not raise an error, as the client is able to re-login + with pytest.raises(LoginError): + mc_auth_token_login_wrong_password.validate_auth() From b8af66dd31729f583e4f632b9d6d0045bfb20449 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Mon, 14 Jul 2025 11:48:19 +0200 Subject: [PATCH 11/12] function name refactor --- mergin/client.py | 88 ++++++++++++++++++++++++------------------------ 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/mergin/client.py b/mergin/client.py index 4ad7c5e..f8c605f 100644 --- a/mergin/client.py +++ b/mergin/client.py @@ -214,9 +214,9 @@ def validate_auth(self): raise ClientError("Missing login or password") @staticmethod - def _check_token(f): + def check_token(f): """Wrapper for creating/renewing authorization token. - Every function that requires authentication should be decorated with this as @_check_token.""" + Every function that requires authentication should be decorated with this as @check_token.""" def wrapper(self, *args, **kwargs): # functions that run prior to required function using this decorator @@ -329,7 +329,7 @@ def username(self): return None # not authenticated return self._user_info["username"] - @_check_token + @check_token def workspace_service(self, workspace_id): """ This Requests information about a workspace service from /workspace/{id}/service endpoint, @@ -340,7 +340,7 @@ def workspace_service(self, workspace_id): resp = self.get(f"/v1/workspace/{workspace_id}/service") return json.load(resp) - @_check_token + @check_token def workspace_usage(self, workspace_id): """ This Requests information about a workspace usage from /workspace/{id}/usage endpoint, @@ -393,7 +393,7 @@ def server_version(self): return self._server_version - @_check_token + @check_token def workspaces_list(self): """ Find all available workspaces @@ -404,7 +404,7 @@ def workspaces_list(self): workspaces = json.load(resp) return workspaces - @_check_token + @check_token def create_workspace(self, workspace_name): """ Create new workspace for currently active user. @@ -423,7 +423,7 @@ def create_workspace(self, workspace_name): e.extra = f"Workspace name: {workspace_name}" raise e - @_check_token + @check_token def create_project(self, project_name, is_public=False, namespace=None): """ Create new project repository in user namespace on Mergin Maps server. @@ -473,7 +473,7 @@ def create_project(self, project_name, is_public=False, namespace=None): e.extra = f"Namespace: {namespace}, project name: {project_name}" raise e - @_check_token + @check_token def create_project_and_push(self, project_name, directory, is_public=False, namespace=None): """ Convenience method to create project and push the initial version right after that. @@ -519,7 +519,7 @@ def create_project_and_push(self, project_name, directory, is_public=False, name if mp.inspect_files(): self.push_project(directory) - @_check_token + @check_token def paginated_projects_list( self, page=1, @@ -593,7 +593,7 @@ def paginated_projects_list( projects = json.load(resp) return projects - @_check_token + @check_token def projects_list( self, tags=None, @@ -663,7 +663,7 @@ def projects_list( break return projects - @_check_token + @check_token def project_info(self, project_path_or_id, since=None, version=None): """ Fetch info about project. @@ -687,7 +687,7 @@ def project_info(self, project_path_or_id, since=None, version=None): resp = self.get("/v1/project/{}".format(project_path_or_id), params) return json.load(resp) - @_check_token + @check_token def paginated_project_versions(self, project_path, page, per_page=100, descending=False): """ Get records of project's versions (history) using calculated pagination. @@ -709,7 +709,7 @@ def paginated_project_versions(self, project_path, page, per_page=100, descendin resp_json = json.load(resp) return resp_json["versions"], resp_json["count"] - @_check_token + @check_token def project_versions_count(self, project_path): """ return the total count of versions @@ -725,7 +725,7 @@ def project_versions_count(self, project_path): resp_json = json.load(resp) return resp_json["count"] - @_check_token + @check_token def project_versions(self, project_path, since=1, to=None): """ Get records of project's versions (history) in ascending order. @@ -773,7 +773,7 @@ def project_versions(self, project_path, since=1, to=None): filtered_versions = list(filter(lambda v: (num_since <= int_version(v["name"]) <= num_to), versions)) return filtered_versions - @_check_token + @check_token def download_project(self, project_path, directory, version=None): """ Download project into given directory. If version is not specified, latest version is downloaded @@ -791,7 +791,7 @@ def download_project(self, project_path, directory, version=None): download_project_wait(job) download_project_finalize(job) - @_check_token + @check_token def user_info(self): server_type = self.server_type() if server_type == ServerType.OLD: @@ -800,7 +800,7 @@ def user_info(self): resp = self.get("/v1/user/profile") return json.load(resp) - @_check_token + @check_token def set_project_access(self, project_path, access): """ Updates access for given project. @@ -824,7 +824,7 @@ def set_project_access(self, project_path, access): e.extra = f"Project path: {project_path}" raise e - @_check_token + @check_token def add_user_permissions_to_project(self, project_path, usernames, permission_level): """ Add specified permissions to specified users to project @@ -858,7 +858,7 @@ def add_user_permissions_to_project(self, project_path, usernames, permission_le category=DeprecationWarning, ) - @_check_token + @check_token def remove_user_permissions_from_project(self, project_path, usernames): """ Removes specified users from project @@ -883,7 +883,7 @@ def remove_user_permissions_from_project(self, project_path, usernames): category=DeprecationWarning, ) - @_check_token + @check_token def project_user_permissions(self, project_path): """ Returns permissions for project @@ -904,7 +904,7 @@ def project_user_permissions(self, project_path): result["readers"] = access.get("readersnames", []) return result - @_check_token + @check_token def push_project(self, directory): """ Upload local changes to the repository. @@ -918,7 +918,7 @@ def push_project(self, directory): push_project_wait(job) push_project_finalize(job) - @_check_token + @check_token def pull_project(self, directory): """ Fetch and apply changes from repository. @@ -932,7 +932,7 @@ def pull_project(self, directory): pull_project_wait(job) return pull_project_finalize(job) - @_check_token + @check_token def clone_project(self, source_project_path, cloned_project_name, cloned_project_namespace=None): """ Clone project on server. @@ -978,7 +978,7 @@ def clone_project(self, source_project_path, cloned_project_name, cloned_project request = urllib.request.Request(url, data=json.dumps(data).encode(), headers=json_headers, method="POST") self._do_request(request) - @_check_token + @check_token def delete_project_now(self, project_path): """ Delete project repository on server immediately. @@ -1002,7 +1002,7 @@ def delete_project_now(self, project_path): request = urllib.request.Request(url, method="DELETE") self._do_request(request) - @_check_token + @check_token def delete_project(self, project_path): """ Delete project repository on server. Newer servers since 2023 @@ -1027,7 +1027,7 @@ def delete_project(self, project_path): request = urllib.request.Request(url, method="DELETE") self._do_request(request) - @_check_token + @check_token def project_status(self, directory): """ Get project status, e.g. server and local changes. @@ -1047,27 +1047,27 @@ def project_status(self, directory): return pull_changes, push_changes, push_changes_summary - @_check_token + @check_token def project_version_info(self, project_id, version): """Returns JSON with detailed information about a single project version""" resp = self.get(f"/v1/project/version/{project_id}/{version}") return json.load(resp) - @_check_token + @check_token def project_file_history_info(self, project_path, file_path): """Returns JSON with full history of a single file within a project""" params = {"path": file_path} resp = self.get("/v1/resource/history/{}".format(project_path), params) return json.load(resp) - @_check_token + @check_token def project_file_changeset_info(self, project_path, file_path, version): """Returns JSON with changeset details of a particular version of a file within a project""" params = {"path": file_path} resp = self.get("/v1/resource/changesets/{}/{}".format(project_path, version), params) return json.load(resp) - @_check_token + @check_token def get_projects_by_names(self, projects): """Returns JSON with projects' info for list of required projects. The schema of the returned information is the same as the response from projects_list(). @@ -1082,7 +1082,7 @@ def get_projects_by_names(self, projects): resp = self.post("/v1/project/by_names", {"projects": projects}, {"Content-Type": "application/json"}) return json.load(resp) - @_check_token + @check_token def download_file(self, project_dir, file_path, output_filename, version=None): """ Download project file at specified version. Get the latest if no version specified. @@ -1136,7 +1136,7 @@ def get_file_diff(self, project_dir, file_path, output_diff, version_from, versi elif len(diffs) == 1: shutil.copy(diffs[0], output_diff) - @_check_token + @check_token def download_file_diffs(self, project_dir, file_path, versions): """Download file diffs for specified versions if they are not present in the cache. @@ -1213,7 +1213,7 @@ def has_writing_permissions(self, project_path): info = self.project_info(project_path) return info["permissions"]["upload"] - @_check_token + @check_token def rename_project(self, project_path: str, new_project_name: str) -> None: """ Rename project on server. @@ -1285,7 +1285,7 @@ def reset_local_changes(self, directory: str, files_to_reset: typing.List[str] = if files_download: self.download_files(directory, files_download, version=current_version) - @_check_token + @check_token def download_files( self, project_dir: str, file_paths: typing.List[str], output_paths: typing.List[str] = None, version: str = None ): @@ -1311,7 +1311,7 @@ def has_editor_support(self): """ return is_version_acceptable(self.server_version(), "2024.4.0") - @_check_token + @check_token def create_user( self, email: str, @@ -1343,7 +1343,7 @@ def create_user( user_info = self.post("v2/users", params, json_headers) return json.load(user_info) - @_check_token + @check_token def get_workspace_member(self, workspace_id: int, user_id: int) -> dict: """ Get a workspace member detail @@ -1351,7 +1351,7 @@ def get_workspace_member(self, workspace_id: int, user_id: int) -> dict: resp = self.get(f"v2/workspaces/{workspace_id}/members/{user_id}") return json.load(resp) - @_check_token + @check_token def list_workspace_members(self, workspace_id: int) -> List[dict]: """ Get a list of workspace members @@ -1359,7 +1359,7 @@ def list_workspace_members(self, workspace_id: int) -> List[dict]: resp = self.get(f"v2/workspaces/{workspace_id}/members") return json.load(resp) - @_check_token + @check_token def update_workspace_member( self, workspace_id: int, user_id: int, workspace_role: WorkspaceRole, reset_projects_roles: bool = False ) -> dict: @@ -1375,14 +1375,14 @@ def update_workspace_member( workspace_member = self.patch(f"v2/workspaces/{workspace_id}/members/{user_id}", params, json_headers) return json.load(workspace_member) - @_check_token + @check_token def remove_workspace_member(self, workspace_id: int, user_id: int): """ Remove a user from workspace members """ self.delete(f"v2/workspaces/{workspace_id}/members/{user_id}") - @_check_token + @check_token def list_project_collaborators(self, project_id: str) -> List[dict]: """ Get a list of project collaborators @@ -1390,7 +1390,7 @@ def list_project_collaborators(self, project_id: str) -> List[dict]: project_collaborators = self.get(f"v2/projects/{project_id}/collaborators") return json.load(project_collaborators) - @_check_token + @check_token def add_project_collaborator(self, project_id: str, user: str, project_role: ProjectRole) -> dict: """ Add a user to project collaborators and grant them a project role. @@ -1402,7 +1402,7 @@ def add_project_collaborator(self, project_id: str, user: str, project_role: Pro project_collaborator = self.post(f"v2/projects/{project_id}/collaborators", params, json_headers) return json.load(project_collaborator) - @_check_token + @check_token def update_project_collaborator(self, project_id: str, user_id: int, project_role: ProjectRole) -> dict: """ Update project role of the existing project collaborator. @@ -1412,7 +1412,7 @@ def update_project_collaborator(self, project_id: str, user_id: int, project_rol project_collaborator = self.patch(f"v2/projects/{project_id}/collaborators/{user_id}", params, json_headers) return json.load(project_collaborator) - @_check_token + @check_token def remove_project_collaborator(self, project_id: str, user_id: int): """ Remove a user from project collaborators @@ -1424,7 +1424,7 @@ def server_config(self) -> dict: response = self.get("/config") return json.load(response) - @_check_token + @check_token def send_logs( self, logfile: str, From b5d7c14a1eaa0d952be4cbfcc9dcd979fbbc88e8 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Wed, 16 Jul 2025 13:07:33 +0200 Subject: [PATCH 12/12] drop decorator, add new parameter to _do_request() --- mergin/client.py | 84 ++++++++++-------------------------------------- 1 file changed, 17 insertions(+), 67 deletions(-) diff --git a/mergin/client.py b/mergin/client.py index f8c605f..9e32310 100644 --- a/mergin/client.py +++ b/mergin/client.py @@ -213,21 +213,11 @@ def validate_auth(self): else: raise ClientError("Missing login or password") - @staticmethod - def check_token(f): - """Wrapper for creating/renewing authorization token. - Every function that requires authentication should be decorated with this as @check_token.""" - - def wrapper(self, *args, **kwargs): - # functions that run prior to required function using this decorator + def _do_request(self, request, validate_auth=True): + """General server request method.""" + if validate_auth: self.validate_auth() - return f(self, *args, **kwargs) - - return wrapper - - def _do_request(self, request): - """General server request method.""" if self._auth_session: request.add_header("Authorization", self._auth_session["token"]) request.add_header("User-Agent", self.user_agent_info()) @@ -269,31 +259,31 @@ def _do_request(self, request): # e.g. when DNS resolution fails (no internet connection?) raise ClientError("Error requesting " + request.full_url + ": " + str(e)) - def get(self, path, data=None, headers={}): + def get(self, path, data=None, headers={}, validate_auth=True): url = urllib.parse.urljoin(self.url, urllib.parse.quote(path)) if data: url += "?" + urllib.parse.urlencode(data) request = urllib.request.Request(url, headers=headers) - return self._do_request(request) + return self._do_request(request, validate_auth=validate_auth) - def post(self, path, data=None, headers={}): + def post(self, path, data=None, headers={}, validate_auth=True): url = urllib.parse.urljoin(self.url, urllib.parse.quote(path)) if headers.get("Content-Type", None) == "application/json": data = json.dumps(data, cls=DateTimeEncoder).encode("utf-8") request = urllib.request.Request(url, data, headers, method="POST") - return self._do_request(request) + return self._do_request(request, validate_auth=validate_auth) - def patch(self, path, data=None, headers={}): + def patch(self, path, data=None, headers={}, validate_auth=True): url = urllib.parse.urljoin(self.url, urllib.parse.quote(path)) if headers.get("Content-Type", None) == "application/json": data = json.dumps(data, cls=DateTimeEncoder).encode("utf-8") request = urllib.request.Request(url, data, headers, method="PATCH") - return self._do_request(request) + return self._do_request(request, validate_auth=validate_auth) - def delete(self, path): + def delete(self, path, validate_auth=True): url = urllib.parse.urljoin(self.url, urllib.parse.quote(path)) request = urllib.request.Request(url, method="DELETE") - return self._do_request(request) + return self._do_request(request, validate_auth=validate_auth) def login(self, login, password): """ @@ -309,7 +299,9 @@ def login(self, login, password): self._auth_session = None self.log.info(f"Going to log in user {login}") try: - resp = self.post("/v1/auth/login", data=params, headers={"Content-Type": "application/json"}) + resp = self.post( + "/v1/auth/login", data=params, headers={"Content-Type": "application/json"}, validate_auth=False + ) data = json.load(resp) session = data["session"] except ClientError as e: @@ -329,7 +321,6 @@ def username(self): return None # not authenticated return self._user_info["username"] - @check_token def workspace_service(self, workspace_id): """ This Requests information about a workspace service from /workspace/{id}/service endpoint, @@ -340,7 +331,6 @@ def workspace_service(self, workspace_id): resp = self.get(f"/v1/workspace/{workspace_id}/service") return json.load(resp) - @check_token def workspace_usage(self, workspace_id): """ This Requests information about a workspace usage from /workspace/{id}/usage endpoint, @@ -363,7 +353,7 @@ def server_type(self): """ if not self._server_type: try: - resp = self.get("/config") + resp = self.get("/config", validate_auth=False) config = json.load(resp) if config["server_type"] == "ce": self._server_type = ServerType.CE @@ -385,7 +375,7 @@ def server_version(self): """ if self._server_version is None: try: - resp = self.get("/config") + resp = self.get("/config", validate_auth=False) config = json.load(resp) self._server_version = config["version"] except (ClientError, KeyError): @@ -393,7 +383,6 @@ def server_version(self): return self._server_version - @check_token def workspaces_list(self): """ Find all available workspaces @@ -404,7 +393,6 @@ def workspaces_list(self): workspaces = json.load(resp) return workspaces - @check_token def create_workspace(self, workspace_name): """ Create new workspace for currently active user. @@ -423,7 +411,6 @@ def create_workspace(self, workspace_name): e.extra = f"Workspace name: {workspace_name}" raise e - @check_token def create_project(self, project_name, is_public=False, namespace=None): """ Create new project repository in user namespace on Mergin Maps server. @@ -473,7 +460,6 @@ def create_project(self, project_name, is_public=False, namespace=None): e.extra = f"Namespace: {namespace}, project name: {project_name}" raise e - @check_token def create_project_and_push(self, project_name, directory, is_public=False, namespace=None): """ Convenience method to create project and push the initial version right after that. @@ -519,7 +505,6 @@ def create_project_and_push(self, project_name, directory, is_public=False, name if mp.inspect_files(): self.push_project(directory) - @check_token def paginated_projects_list( self, page=1, @@ -593,7 +578,6 @@ def paginated_projects_list( projects = json.load(resp) return projects - @check_token def projects_list( self, tags=None, @@ -663,7 +647,6 @@ def projects_list( break return projects - @check_token def project_info(self, project_path_or_id, since=None, version=None): """ Fetch info about project. @@ -687,7 +670,6 @@ def project_info(self, project_path_or_id, since=None, version=None): resp = self.get("/v1/project/{}".format(project_path_or_id), params) return json.load(resp) - @check_token def paginated_project_versions(self, project_path, page, per_page=100, descending=False): """ Get records of project's versions (history) using calculated pagination. @@ -709,7 +691,6 @@ def paginated_project_versions(self, project_path, page, per_page=100, descendin resp_json = json.load(resp) return resp_json["versions"], resp_json["count"] - @check_token def project_versions_count(self, project_path): """ return the total count of versions @@ -725,7 +706,6 @@ def project_versions_count(self, project_path): resp_json = json.load(resp) return resp_json["count"] - @check_token def project_versions(self, project_path, since=1, to=None): """ Get records of project's versions (history) in ascending order. @@ -773,7 +753,6 @@ def project_versions(self, project_path, since=1, to=None): filtered_versions = list(filter(lambda v: (num_since <= int_version(v["name"]) <= num_to), versions)) return filtered_versions - @check_token def download_project(self, project_path, directory, version=None): """ Download project into given directory. If version is not specified, latest version is downloaded @@ -791,7 +770,6 @@ def download_project(self, project_path, directory, version=None): download_project_wait(job) download_project_finalize(job) - @check_token def user_info(self): server_type = self.server_type() if server_type == ServerType.OLD: @@ -800,7 +778,6 @@ def user_info(self): resp = self.get("/v1/user/profile") return json.load(resp) - @check_token def set_project_access(self, project_path, access): """ Updates access for given project. @@ -824,7 +801,6 @@ def set_project_access(self, project_path, access): e.extra = f"Project path: {project_path}" raise e - @check_token def add_user_permissions_to_project(self, project_path, usernames, permission_level): """ Add specified permissions to specified users to project @@ -858,7 +834,6 @@ def add_user_permissions_to_project(self, project_path, usernames, permission_le category=DeprecationWarning, ) - @check_token def remove_user_permissions_from_project(self, project_path, usernames): """ Removes specified users from project @@ -883,7 +858,6 @@ def remove_user_permissions_from_project(self, project_path, usernames): category=DeprecationWarning, ) - @check_token def project_user_permissions(self, project_path): """ Returns permissions for project @@ -904,7 +878,6 @@ def project_user_permissions(self, project_path): result["readers"] = access.get("readersnames", []) return result - @check_token def push_project(self, directory): """ Upload local changes to the repository. @@ -918,7 +891,6 @@ def push_project(self, directory): push_project_wait(job) push_project_finalize(job) - @check_token def pull_project(self, directory): """ Fetch and apply changes from repository. @@ -932,7 +904,6 @@ def pull_project(self, directory): pull_project_wait(job) return pull_project_finalize(job) - @check_token def clone_project(self, source_project_path, cloned_project_name, cloned_project_namespace=None): """ Clone project on server. @@ -978,7 +949,6 @@ def clone_project(self, source_project_path, cloned_project_name, cloned_project request = urllib.request.Request(url, data=json.dumps(data).encode(), headers=json_headers, method="POST") self._do_request(request) - @check_token def delete_project_now(self, project_path): """ Delete project repository on server immediately. @@ -1002,7 +972,6 @@ def delete_project_now(self, project_path): request = urllib.request.Request(url, method="DELETE") self._do_request(request) - @check_token def delete_project(self, project_path): """ Delete project repository on server. Newer servers since 2023 @@ -1027,7 +996,6 @@ def delete_project(self, project_path): request = urllib.request.Request(url, method="DELETE") self._do_request(request) - @check_token def project_status(self, directory): """ Get project status, e.g. server and local changes. @@ -1047,27 +1015,23 @@ def project_status(self, directory): return pull_changes, push_changes, push_changes_summary - @check_token def project_version_info(self, project_id, version): """Returns JSON with detailed information about a single project version""" resp = self.get(f"/v1/project/version/{project_id}/{version}") return json.load(resp) - @check_token def project_file_history_info(self, project_path, file_path): """Returns JSON with full history of a single file within a project""" params = {"path": file_path} resp = self.get("/v1/resource/history/{}".format(project_path), params) return json.load(resp) - @check_token def project_file_changeset_info(self, project_path, file_path, version): """Returns JSON with changeset details of a particular version of a file within a project""" params = {"path": file_path} resp = self.get("/v1/resource/changesets/{}/{}".format(project_path, version), params) return json.load(resp) - @check_token def get_projects_by_names(self, projects): """Returns JSON with projects' info for list of required projects. The schema of the returned information is the same as the response from projects_list(). @@ -1082,7 +1046,6 @@ def get_projects_by_names(self, projects): resp = self.post("/v1/project/by_names", {"projects": projects}, {"Content-Type": "application/json"}) return json.load(resp) - @check_token def download_file(self, project_dir, file_path, output_filename, version=None): """ Download project file at specified version. Get the latest if no version specified. @@ -1136,7 +1099,6 @@ def get_file_diff(self, project_dir, file_path, output_diff, version_from, versi elif len(diffs) == 1: shutil.copy(diffs[0], output_diff) - @check_token def download_file_diffs(self, project_dir, file_path, versions): """Download file diffs for specified versions if they are not present in the cache. @@ -1213,7 +1175,6 @@ def has_writing_permissions(self, project_path): info = self.project_info(project_path) return info["permissions"]["upload"] - @check_token def rename_project(self, project_path: str, new_project_name: str) -> None: """ Rename project on server. @@ -1285,7 +1246,6 @@ def reset_local_changes(self, directory: str, files_to_reset: typing.List[str] = if files_download: self.download_files(directory, files_download, version=current_version) - @check_token def download_files( self, project_dir: str, file_paths: typing.List[str], output_paths: typing.List[str] = None, version: str = None ): @@ -1311,7 +1271,6 @@ def has_editor_support(self): """ return is_version_acceptable(self.server_version(), "2024.4.0") - @check_token def create_user( self, email: str, @@ -1343,7 +1302,6 @@ def create_user( user_info = self.post("v2/users", params, json_headers) return json.load(user_info) - @check_token def get_workspace_member(self, workspace_id: int, user_id: int) -> dict: """ Get a workspace member detail @@ -1351,7 +1309,6 @@ def get_workspace_member(self, workspace_id: int, user_id: int) -> dict: resp = self.get(f"v2/workspaces/{workspace_id}/members/{user_id}") return json.load(resp) - @check_token def list_workspace_members(self, workspace_id: int) -> List[dict]: """ Get a list of workspace members @@ -1359,7 +1316,6 @@ def list_workspace_members(self, workspace_id: int) -> List[dict]: resp = self.get(f"v2/workspaces/{workspace_id}/members") return json.load(resp) - @check_token def update_workspace_member( self, workspace_id: int, user_id: int, workspace_role: WorkspaceRole, reset_projects_roles: bool = False ) -> dict: @@ -1375,14 +1331,12 @@ def update_workspace_member( workspace_member = self.patch(f"v2/workspaces/{workspace_id}/members/{user_id}", params, json_headers) return json.load(workspace_member) - @check_token def remove_workspace_member(self, workspace_id: int, user_id: int): """ Remove a user from workspace members """ self.delete(f"v2/workspaces/{workspace_id}/members/{user_id}") - @check_token def list_project_collaborators(self, project_id: str) -> List[dict]: """ Get a list of project collaborators @@ -1390,7 +1344,6 @@ def list_project_collaborators(self, project_id: str) -> List[dict]: project_collaborators = self.get(f"v2/projects/{project_id}/collaborators") return json.load(project_collaborators) - @check_token def add_project_collaborator(self, project_id: str, user: str, project_role: ProjectRole) -> dict: """ Add a user to project collaborators and grant them a project role. @@ -1402,7 +1355,6 @@ def add_project_collaborator(self, project_id: str, user: str, project_role: Pro project_collaborator = self.post(f"v2/projects/{project_id}/collaborators", params, json_headers) return json.load(project_collaborator) - @check_token def update_project_collaborator(self, project_id: str, user_id: int, project_role: ProjectRole) -> dict: """ Update project role of the existing project collaborator. @@ -1412,7 +1364,6 @@ def update_project_collaborator(self, project_id: str, user_id: int, project_rol project_collaborator = self.patch(f"v2/projects/{project_id}/collaborators/{user_id}", params, json_headers) return json.load(project_collaborator) - @check_token def remove_project_collaborator(self, project_id: str, user_id: int): """ Remove a user from project collaborators @@ -1421,10 +1372,9 @@ def remove_project_collaborator(self, project_id: str, user_id: int): def server_config(self) -> dict: """Get server configuration as dictionary.""" - response = self.get("/config") + response = self.get("/config", validate_auth=False) return json.load(response) - @check_token def send_logs( self, logfile: str,