Skip to content

Commit ce071e5

Browse files
authored
Merge branch 'master' into create_invitation_method
2 parents bf0f30e + b912616 commit ce071e5

File tree

5 files changed

+122
-67
lines changed

5 files changed

+122
-67
lines changed

mergin/client.py

Lines changed: 58 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,14 @@
1919

2020
from typing import List
2121

22-
from .common import ClientError, LoginError, WorkspaceRole, ProjectRole, LOG_FILE_SIZE_TO_SEND, MERGIN_DEFAULT_LOGS_URL
22+
from .common import (
23+
ClientError,
24+
LoginError,
25+
WorkspaceRole,
26+
ProjectRole,
27+
MAX_LOG_FILE_SIZE_TO_SEND,
28+
MERGIN_DEFAULT_LOGS_URL,
29+
)
2330
from .merginproject import MerginProject
2431
from .client_pull import (
2532
download_file_finalize,
@@ -94,7 +101,7 @@ def __init__(
94101
proxy_config=None,
95102
):
96103
self.url = url if url is not None else MerginClient.default_url()
97-
self._auth_params = None
104+
self._auth_params = {}
98105
self._auth_session = None
99106
self._user_info = None
100107
self._server_type = None
@@ -192,36 +199,32 @@ def user_agent_info(self):
192199
system_version = platform.mac_ver()[0]
193200
return f"{self.client_version} ({platform.system()}/{system_version})"
194201

195-
def _check_token(f):
196-
"""Wrapper for creating/renewing authorization token."""
197-
198-
def wrapper(self, *args):
199-
if self._auth_params:
200-
if self._auth_session:
201-
# Refresh auth token if it expired or will expire very soon
202-
delta = self._auth_session["expire"] - datetime.now(timezone.utc)
203-
if delta.total_seconds() < 5:
204-
self.log.info("Token has expired - refreshing...")
205-
if self._auth_params.get("login", None) and self._auth_params.get("password", None):
206-
self.log.info("Token has expired - refreshing...")
207-
self.login(self._auth_params["login"], self._auth_params["password"])
208-
else:
209-
raise AuthTokenExpiredError("Token has expired - please re-login")
210-
else:
211-
# Create a new authorization token
212-
self.log.info(f"No token - login user: {self._auth_params['login']}")
213-
if self._auth_params.get("login", None) and self._auth_params.get("password", None):
214-
self.login(self._auth_params["login"], self._auth_params["password"])
215-
else:
216-
raise ClientError("Missing login or password")
217-
218-
return f(self, *args)
202+
def validate_auth(self):
203+
"""Validate that client has valid auth token or can be logged in."""
219204

220-
return wrapper
205+
if self._auth_session:
206+
# Refresh auth token if it expired or will expire very soon
207+
delta = self._auth_session["expire"] - datetime.now(timezone.utc)
208+
if delta.total_seconds() < 5:
209+
self.log.info("Token has expired - refreshing...")
210+
if self._auth_params.get("login", None) and self._auth_params.get("password", None):
211+
self.log.info("Token has expired - refreshing...")
212+
self.login(self._auth_params["login"], self._auth_params["password"])
213+
else:
214+
raise AuthTokenExpiredError("Token has expired - please re-login")
215+
else:
216+
# Create a new authorization token
217+
self.log.info(f"No token - login user: {self._auth_params.get('login', None)}")
218+
if self._auth_params.get("login", None) and self._auth_params.get("password", None):
219+
self.login(self._auth_params["login"], self._auth_params["password"])
220+
else:
221+
raise ClientError("Missing login or password")
221222

222-
@_check_token
223-
def _do_request(self, request):
223+
def _do_request(self, request, validate_auth=True):
224224
"""General server request method."""
225+
if validate_auth:
226+
self.validate_auth()
227+
225228
if self._auth_session:
226229
request.add_header("Authorization", self._auth_session["token"])
227230
request.add_header("User-Agent", self.user_agent_info())
@@ -263,31 +266,31 @@ def _do_request(self, request):
263266
# e.g. when DNS resolution fails (no internet connection?)
264267
raise ClientError("Error requesting " + request.full_url + ": " + str(e))
265268

266-
def get(self, path, data=None, headers={}):
269+
def get(self, path, data=None, headers={}, validate_auth=True):
267270
url = urllib.parse.urljoin(self.url, urllib.parse.quote(path))
268271
if data:
269272
url += "?" + urllib.parse.urlencode(data)
270273
request = urllib.request.Request(url, headers=headers)
271-
return self._do_request(request)
274+
return self._do_request(request, validate_auth=validate_auth)
272275

273-
def post(self, path, data=None, headers={}):
276+
def post(self, path, data=None, headers={}, validate_auth=True):
274277
url = urllib.parse.urljoin(self.url, urllib.parse.quote(path))
275278
if headers.get("Content-Type", None) == "application/json":
276279
data = json.dumps(data, cls=DateTimeEncoder).encode("utf-8")
277280
request = urllib.request.Request(url, data, headers, method="POST")
278-
return self._do_request(request)
281+
return self._do_request(request, validate_auth=validate_auth)
279282

280-
def patch(self, path, data=None, headers={}):
283+
def patch(self, path, data=None, headers={}, validate_auth=True):
281284
url = urllib.parse.urljoin(self.url, urllib.parse.quote(path))
282285
if headers.get("Content-Type", None) == "application/json":
283286
data = json.dumps(data, cls=DateTimeEncoder).encode("utf-8")
284287
request = urllib.request.Request(url, data, headers, method="PATCH")
285-
return self._do_request(request)
288+
return self._do_request(request, validate_auth=validate_auth)
286289

287-
def delete(self, path):
290+
def delete(self, path, validate_auth=True):
288291
url = urllib.parse.urljoin(self.url, urllib.parse.quote(path))
289292
request = urllib.request.Request(url, method="DELETE")
290-
return self._do_request(request)
293+
return self._do_request(request, validate_auth=validate_auth)
291294

292295
def login(self, login, password):
293296
"""
@@ -303,26 +306,16 @@ def login(self, login, password):
303306
self._auth_session = None
304307
self.log.info(f"Going to log in user {login}")
305308
try:
306-
self._auth_params = params
307-
url = urllib.parse.urljoin(self.url, urllib.parse.quote("/v1/auth/login"))
308-
data = json.dumps(self._auth_params, cls=DateTimeEncoder).encode("utf-8")
309-
request = urllib.request.Request(url, data, {"Content-Type": "application/json"}, method="POST")
310-
request.add_header("User-Agent", self.user_agent_info())
311-
resp = self.opener.open(request)
309+
resp = self.post(
310+
"/v1/auth/login", data=params, headers={"Content-Type": "application/json"}, validate_auth=False
311+
)
312312
data = json.load(resp)
313313
session = data["session"]
314-
except urllib.error.HTTPError as e:
315-
if e.headers.get("Content-Type", "") == "application/problem+json":
316-
info = json.load(e)
317-
self.log.info(f"Login problem: {info.get('detail')}")
318-
raise LoginError(info.get("detail"))
319-
self.log.info(f"Login problem: {e.read().decode('utf-8')}")
320-
raise LoginError(e.read().decode("utf-8"))
321-
except urllib.error.URLError as e:
322-
# e.g. when DNS resolution fails (no internet connection?)
323-
raise ClientError("failure reason: " + str(e.reason))
314+
except ClientError as e:
315+
self.log.info(f"Login problem: {e.detail}")
316+
raise LoginError(e.detail)
324317
self._auth_session = {
325-
"token": "Bearer %s" % session["token"],
318+
"token": f"Bearer {session['token']}",
326319
"expire": dateutil.parser.parse(session["expire"]),
327320
}
328321
self._user_info = {"username": data["username"]}
@@ -367,7 +360,7 @@ def server_type(self):
367360
"""
368361
if not self._server_type:
369362
try:
370-
resp = self.get("/config")
363+
resp = self.get("/config", validate_auth=False)
371364
config = json.load(resp)
372365
if config["server_type"] == "ce":
373366
self._server_type = ServerType.CE
@@ -389,7 +382,7 @@ def server_version(self):
389382
"""
390383
if self._server_version is None:
391384
try:
392-
resp = self.get("/config")
385+
resp = self.get("/config", validate_auth=False)
393386
config = json.load(resp)
394387
self._server_version = config["version"]
395388
except (ClientError, KeyError):
@@ -1403,7 +1396,7 @@ def remove_project_collaborator(self, project_id: str, user_id: int):
14031396

14041397
def server_config(self) -> dict:
14051398
"""Get server configuration as dictionary."""
1406-
response = self.get("/config")
1399+
response = self.get("/config", validate_auth=False)
14071400
return json.load(response)
14081401

14091402
def send_logs(
@@ -1441,16 +1434,20 @@ def send_logs(
14411434
platform.system(), self.url, self.username()
14421435
)
14431436

1437+
# We send more from the local logs
1438+
global_logs_file_size_to_send = MAX_LOG_FILE_SIZE_TO_SEND * 0.2
1439+
local_logs_file_size_to_send = MAX_LOG_FILE_SIZE_TO_SEND * 0.8
1440+
14441441
global_logs = b""
14451442
if global_log_file and os.path.exists(global_log_file):
14461443
with open(global_log_file, "rb") as f:
1447-
if os.path.getsize(global_log_file) > LOG_FILE_SIZE_TO_SEND:
1448-
f.seek(-LOG_FILE_SIZE_TO_SEND, os.SEEK_END)
1444+
if os.path.getsize(global_log_file) > global_logs_file_size_to_send:
1445+
f.seek(-global_logs_file_size_to_send, os.SEEK_END)
14491446
global_logs = f.read() + b"\n--------------------------------\n\n"
14501447

14511448
with open(logfile, "rb") as f:
1452-
if os.path.getsize(logfile) > 512 * 1024:
1453-
f.seek(-512 * 1024, os.SEEK_END)
1449+
if os.path.getsize(logfile) > local_logs_file_size_to_send:
1450+
f.seek(-local_logs_file_size_to_send, os.SEEK_END)
14541451
logs = f.read()
14551452

14561453
payload = meta.encode() + global_logs + logs

mergin/common.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
UPLOAD_CHUNK_SIZE = 10 * 1024 * 1024
88

99
# size of the log file part to send (if file is larger only this size from end will be sent)
10-
LOG_FILE_SIZE_TO_SEND = 100 * 1024
10+
MAX_LOG_FILE_SIZE_TO_SEND = 8 * 1024 * 1024
1111

1212
# default URL for submitting logs
1313
MERGIN_DEFAULT_LOGS_URL = "https://g4pfq226j0.execute-api.eu-west-1.amazonaws.com/mergin_client_log_submit"

mergin/test/test_client.py

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import tempfile
66
import subprocess
77
import shutil
8-
from datetime import datetime, timedelta, date
8+
from datetime import datetime, timedelta, date, timezone
99
import pytest
1010
import pytz
1111
import sqlite3
@@ -14,6 +14,7 @@
1414
from .. import InvalidProject
1515
from ..client import (
1616
MerginClient,
17+
AuthTokenExpiredError,
1718
ClientError,
1819
MerginProject,
1920
LoginError,
@@ -2887,8 +2888,7 @@ def test_mc_without_login():
28872888
with pytest.raises(ClientError) as e:
28882889
mc.workspaces_list()
28892890

2890-
assert e.value.http_error == 401
2891-
assert e.value.detail == '"Authentication information is missing or invalid."\n'
2891+
assert e.value.detail == "Missing login or password"
28922892

28932893

28942894
def test_do_request_error_handling(mc: MerginClient):
@@ -2926,3 +2926,61 @@ def test_create_invitation(mc: MerginClient):
29262926
assert inv["email"] == email
29272927
assert "projects" not in inv
29282928
mc.delete(f"v1/workspace/invitation/{inv['id']}")
2929+
2930+
2931+
def test_validate_auth(mc: MerginClient):
2932+
"""Test validate authentication under different scenarios."""
2933+
2934+
# ----- Client without authentication -----
2935+
mc_not_auth = MerginClient(SERVER_URL)
2936+
2937+
with pytest.raises(ClientError) as e:
2938+
mc_not_auth.validate_auth()
2939+
2940+
assert e.value.detail == "Missing login or password"
2941+
2942+
# ----- Client with token -----
2943+
# create a client with valid auth token based on other MerginClient instance, but not with username/password
2944+
mc_auth_token = MerginClient(SERVER_URL, auth_token=mc._auth_session["token"])
2945+
2946+
# this should pass and not raise an error
2947+
mc_auth_token.validate_auth()
2948+
2949+
# manually set expire date to the past to simulate expired token
2950+
mc_auth_token._auth_session["expire"] = datetime.now(timezone.utc) - timedelta(days=1)
2951+
2952+
# check that this raises an error
2953+
with pytest.raises(AuthTokenExpiredError):
2954+
mc_auth_token.validate_auth()
2955+
2956+
# ----- Client with token and username/password -----
2957+
# create a client with valid auth token based on other MerginClient instance with username/password that allows relogin if the token is expired
2958+
mc_auth_token_login = MerginClient(
2959+
SERVER_URL, auth_token=mc._auth_session["token"], login=API_USER, password=USER_PWD
2960+
)
2961+
2962+
# this should pass and not raise an error
2963+
mc_auth_token_login.validate_auth()
2964+
2965+
# manually set expire date to the past to simulate expired token
2966+
mc_auth_token_login._auth_session["expire"] = datetime.now(timezone.utc) - timedelta(days=1)
2967+
2968+
# this should pass and not raise an error, as the client is able to re-login
2969+
mc_auth_token_login.validate_auth()
2970+
2971+
# ----- Client with token and username/WRONG password -----
2972+
# create a client with valid auth token based on other MerginClient instance with username and WRONG password
2973+
# that does NOT allow relogin if the token is expired
2974+
mc_auth_token_login_wrong_password = MerginClient(
2975+
SERVER_URL, auth_token=mc._auth_session["token"], login=API_USER, password="WRONG_PASSWORD"
2976+
)
2977+
2978+
# this should pass and not raise an error
2979+
mc_auth_token_login_wrong_password.validate_auth()
2980+
2981+
# manually set expire date to the past to simulate expired token
2982+
mc_auth_token_login_wrong_password._auth_session["expire"] = datetime.now(timezone.utc) - timedelta(days=1)
2983+
2984+
# this should pass and not raise an error, as the client is able to re-login
2985+
with pytest.raises(LoginError):
2986+
mc_auth_token_login_wrong_password.validate_auth()

mergin/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# The version is also stored in ../setup.py
2-
__version__ = "0.10.1"
2+
__version__ = "0.10.2"
33

44
# There seems to be no single nice way to keep version info just in one place:
55
# https://packaging.python.org/guides/single-sourcing-package-version/

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
setup(
77
name='mergin-client',
8-
version='0.10.1',
8+
version='0.10.2',
99
url='https://github.com/MerginMaps/python-api-client',
1010
license='MIT',
1111
author='Lutra Consulting Ltd.',

0 commit comments

Comments
 (0)