d2spy 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- d2spy/__init__.py +0 -0
- d2spy/api_client.py +89 -0
- d2spy/auth.py +127 -0
- d2spy/extras/__init__.py +0 -0
- d2spy/extras/third_party/__init__.py +0 -0
- d2spy/extras/third_party/tusclient/__init__.py +1 -0
- d2spy/extras/third_party/tusclient/client.py +77 -0
- d2spy/extras/third_party/tusclient/exceptions.py +35 -0
- d2spy/extras/third_party/tusclient/py.typed +0 -0
- d2spy/extras/third_party/tusclient/request.py +96 -0
- d2spy/extras/third_party/tusclient/uploader/__init__.py +1 -0
- d2spy/extras/third_party/tusclient/uploader/baseuploader.py +246 -0
- d2spy/extras/third_party/tusclient/uploader/uploader.py +105 -0
- d2spy/extras/utils.py +335 -0
- d2spy/models/__init__.py +4 -0
- d2spy/models/data_product.py +257 -0
- d2spy/models/data_product_collection.py +35 -0
- d2spy/models/flight.py +308 -0
- d2spy/models/flight_collection.py +98 -0
- d2spy/models/job.py +31 -0
- d2spy/models/location.py +8 -0
- d2spy/models/project.py +200 -0
- d2spy/models/project_collection.py +53 -0
- d2spy/models/raw_data.py +16 -0
- d2spy/models/user.py +30 -0
- d2spy/schemas/__init__.py +4 -0
- d2spy/schemas/data_product.py +37 -0
- d2spy/schemas/flight.py +41 -0
- d2spy/schemas/geojson.py +34 -0
- d2spy/schemas/project.py +97 -0
- d2spy/schemas/raw_data.py +29 -0
- d2spy/schemas/session.py +7 -0
- d2spy/schemas/stac_properties.py +28 -0
- d2spy/workspace.py +162 -0
- d2spy-0.2.1.dist-info/LICENSE +21 -0
- d2spy-0.2.1.dist-info/METADATA +52 -0
- d2spy-0.2.1.dist-info/RECORD +38 -0
- d2spy-0.2.1.dist-info/WHEEL +4 -0
d2spy/__init__.py
ADDED
|
File without changes
|
d2spy/api_client.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Union
|
|
2
|
+
|
|
3
|
+
from requests import Session
|
|
4
|
+
|
|
5
|
+
from d2spy.extras.utils import pretty_print_response
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class APIClient:
|
|
9
|
+
"""Makes API requests to D2S API."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, base_url: str, session: Session):
|
|
12
|
+
"""Constructor for APIClient class.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
base_url (str): Base URL for D2S instance.
|
|
16
|
+
session (Session): Session set by Auth.
|
|
17
|
+
|
|
18
|
+
Raises:
|
|
19
|
+
ValueError: Raised if access token missing from session.
|
|
20
|
+
"""
|
|
21
|
+
self.base_url = base_url
|
|
22
|
+
self.session = session
|
|
23
|
+
|
|
24
|
+
# Check if access token in session cookies
|
|
25
|
+
if not self.session.cookies.get("access_token"):
|
|
26
|
+
raise ValueError("Session missing access token. Must sign in first.")
|
|
27
|
+
|
|
28
|
+
def make_get_request(
|
|
29
|
+
self, endpoint: str, **kwargs
|
|
30
|
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
|
31
|
+
"""Makes GET request to D2S API.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
endpoint (str): D2S endpoint for request.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Union[Dict, List]: JSON response from request.
|
|
38
|
+
"""
|
|
39
|
+
url = self.base_url + endpoint
|
|
40
|
+
response = self.session.get(url, **kwargs)
|
|
41
|
+
|
|
42
|
+
if response.status_code != 200:
|
|
43
|
+
pretty_print_response(response)
|
|
44
|
+
response.raise_for_status()
|
|
45
|
+
|
|
46
|
+
return response.json()
|
|
47
|
+
|
|
48
|
+
def make_post_request(self, endpoint: str, **kwargs) -> Dict[Any, Any]:
|
|
49
|
+
"""Make POST request to D2S API.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
endpoint (str): D2S endpoint for request.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Dict: JSON response from request.
|
|
56
|
+
"""
|
|
57
|
+
url = self.base_url + endpoint
|
|
58
|
+
response = self.session.post(url, **kwargs)
|
|
59
|
+
|
|
60
|
+
if (
|
|
61
|
+
response.status_code != 200
|
|
62
|
+
and response.status_code != 201
|
|
63
|
+
and response.status_code != 202
|
|
64
|
+
):
|
|
65
|
+
pretty_print_response(response)
|
|
66
|
+
response.raise_for_status()
|
|
67
|
+
|
|
68
|
+
if response.status_code == 202:
|
|
69
|
+
return {"status": "accepted"}
|
|
70
|
+
|
|
71
|
+
return response.json()
|
|
72
|
+
|
|
73
|
+
def make_put_request(self, endpoint: str, **kwargs) -> Dict[Any, Any]:
|
|
74
|
+
"""Make PUT request to D2S API.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
endpoint (str): D2S endpoint for request.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Dict: JSON response from request.
|
|
81
|
+
"""
|
|
82
|
+
url = self.base_url + endpoint
|
|
83
|
+
response = self.session.put(url, **kwargs)
|
|
84
|
+
|
|
85
|
+
if response.status_code != 200:
|
|
86
|
+
pretty_print_response(response)
|
|
87
|
+
response.raise_for_status()
|
|
88
|
+
|
|
89
|
+
return response.json()
|
d2spy/auth.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import getpass
|
|
2
|
+
import os
|
|
3
|
+
import requests
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from d2spy.extras.utils import pretty_print_response
|
|
7
|
+
from d2spy.models.user import User
|
|
8
|
+
from d2spy.schemas.session import D2SpySession
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Auth:
|
|
12
|
+
"""Authenticates with D2S."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, base_url: str) -> None:
|
|
15
|
+
"""Constructor for Auth class.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
base_url (str): Base URL for D2S instance.
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
ValueError: Raised if unable to communicate with host.
|
|
22
|
+
"""
|
|
23
|
+
self.base_url: str = base_url
|
|
24
|
+
|
|
25
|
+
if is_valid_base_url(self.base_url) is False:
|
|
26
|
+
raise ValueError("unable to connect to provided host")
|
|
27
|
+
|
|
28
|
+
self.session: D2SpySession = D2SpySession()
|
|
29
|
+
|
|
30
|
+
def login(
|
|
31
|
+
self, email: Optional[str] = None, password: Optional[str] = None
|
|
32
|
+
) -> Optional[D2SpySession]:
|
|
33
|
+
"""Login to D2S platform with email and password. Alternatively, use
|
|
34
|
+
environment variables `D2S_EMAIL` and `D2S_PASSWORD` to set email and password.
|
|
35
|
+
If the password is not passed as an argument and `D2S_PASSWORD` is not set,
|
|
36
|
+
`getpass` will be used to prompt user for password.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
email Optional[str]: Email address used to sign in to D2S.
|
|
40
|
+
password Optional[str]: Password used to sign in to D2S.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Optional[D2SpySession]: Session with user access cookie.
|
|
44
|
+
"""
|
|
45
|
+
# Check for email environment variable if not provided as argument
|
|
46
|
+
if not email:
|
|
47
|
+
email = os.environ.get("D2S_EMAIL")
|
|
48
|
+
if not email:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"Must provide 'email' to login method as argument or set email "
|
|
51
|
+
"as environment variable 'D2S_EMAIL'"
|
|
52
|
+
)
|
|
53
|
+
# Check for password environment variable if not provided as argument
|
|
54
|
+
if not password:
|
|
55
|
+
password = os.environ.get("D2S_PASSWORD")
|
|
56
|
+
# Request password from user if not set as environment variable
|
|
57
|
+
if not password:
|
|
58
|
+
password = getpass.getpass(prompt="Enter your D2S password:")
|
|
59
|
+
# Credentials that will be sent to D2S auth API
|
|
60
|
+
credentials = {"username": email, "password": password}
|
|
61
|
+
# URL for D2S access-token endpoint
|
|
62
|
+
url = f"{self.base_url}/api/v1/auth/access-token"
|
|
63
|
+
# Post credentials to access-token endpoint
|
|
64
|
+
response = requests.post(url, data=credentials)
|
|
65
|
+
# JWT access token returned for successful request
|
|
66
|
+
if response.status_code == 200 and "access_token" in response.cookies:
|
|
67
|
+
# Add JWT access token to session cookies
|
|
68
|
+
self.session.cookies.set("access_token", response.cookies["access_token"])
|
|
69
|
+
# Fetch user object associated with access token
|
|
70
|
+
user = self.get_current_user()
|
|
71
|
+
# Return dictionary of user attributes and values
|
|
72
|
+
if user:
|
|
73
|
+
# Check if user has api key and set it to session header if so
|
|
74
|
+
if hasattr(user, "api_access_token") and user.api_access_token:
|
|
75
|
+
self.session.d2s_data = {"API_KEY": user.api_access_token}
|
|
76
|
+
return self.session
|
|
77
|
+
else:
|
|
78
|
+
return None
|
|
79
|
+
else:
|
|
80
|
+
# Print response if request fails
|
|
81
|
+
pretty_print_response(response)
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
def logout(self) -> None:
|
|
85
|
+
"""Logout of D2S platform."""
|
|
86
|
+
# Delete access-token cookie from session and end session
|
|
87
|
+
self.session.cookies.clear(domain="", path="/", name="access_token")
|
|
88
|
+
self.session.close()
|
|
89
|
+
print("session ended")
|
|
90
|
+
|
|
91
|
+
def get_current_user(self) -> Optional[User]:
|
|
92
|
+
"""Get user object for logged in user.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Optional[User]: User object or None.
|
|
96
|
+
"""
|
|
97
|
+
# D2S endpoint for fetching user object for signed in user
|
|
98
|
+
url = f"{self.base_url}/api/v1/users/current"
|
|
99
|
+
# Request user object from D2S instance
|
|
100
|
+
response = self.session.get(url)
|
|
101
|
+
# Return user object if request successful
|
|
102
|
+
if response.status_code == 200:
|
|
103
|
+
return User.from_dict(response.json())
|
|
104
|
+
else:
|
|
105
|
+
# Print response if request fails
|
|
106
|
+
pretty_print_response(response)
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_valid_base_url(base_url: str) -> bool:
|
|
111
|
+
"""Return true if base_url returns HTTP 200 else false.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
base_url (str): Base URL for D2S instance.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
bool: Returns True if D2S instance returns status OK, otherwise False
|
|
118
|
+
"""
|
|
119
|
+
response: Optional[requests.Response] = None
|
|
120
|
+
try:
|
|
121
|
+
response = requests.get(f"{base_url}/api/v1/health")
|
|
122
|
+
except requests.exceptions.ConnectionError:
|
|
123
|
+
response = None
|
|
124
|
+
finally:
|
|
125
|
+
if response and response.status_code == 200:
|
|
126
|
+
return True
|
|
127
|
+
return False
|
d2spy/extras/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.3"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from typing import Dict, Optional
|
|
2
|
+
|
|
3
|
+
from d2spy.extras.third_party.tusclient.uploader import Uploader
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TusClient:
|
|
7
|
+
"""
|
|
8
|
+
Object representation of Tus client.
|
|
9
|
+
|
|
10
|
+
:Attributes:
|
|
11
|
+
- url (str):
|
|
12
|
+
represents the tus server's create extension url. On instantiation this argument
|
|
13
|
+
must be passed to the constructor.
|
|
14
|
+
- headers (dict):
|
|
15
|
+
This can be used to set the server specific headers. These headers would be sent
|
|
16
|
+
along with every request made by the client to the server. This may be used to set
|
|
17
|
+
authentication headers. These headers should not include headers required by tus
|
|
18
|
+
protocol. If not set this defaults to an empty dictionary.
|
|
19
|
+
- cookies (dict):
|
|
20
|
+
This can be used to set the server specific cookies. These cookies would be sent
|
|
21
|
+
along with every request made by the client to the server. This may be used to set
|
|
22
|
+
authorization cookies.
|
|
23
|
+
|
|
24
|
+
:Constructor Args:
|
|
25
|
+
- url (str)
|
|
26
|
+
- headers (Optional[dict])
|
|
27
|
+
- cookies (Optional[dict])
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
url: str,
|
|
33
|
+
headers: Optional[Dict[str, str]] = None,
|
|
34
|
+
cookies: Optional[Dict[str, str]] = None,
|
|
35
|
+
):
|
|
36
|
+
self.url = url
|
|
37
|
+
self.headers = headers or {}
|
|
38
|
+
self.cookies = cookies or {}
|
|
39
|
+
|
|
40
|
+
def set_headers(self, headers: Dict[str, str]):
|
|
41
|
+
"""
|
|
42
|
+
Set tus client headers.
|
|
43
|
+
|
|
44
|
+
Update and/or set new headers that would be sent along with every request made
|
|
45
|
+
to the server.
|
|
46
|
+
|
|
47
|
+
:Args:
|
|
48
|
+
- headers (dict):
|
|
49
|
+
key, value pairs of the headers to be set. This argument is required.
|
|
50
|
+
"""
|
|
51
|
+
self.headers.update(headers)
|
|
52
|
+
|
|
53
|
+
def set_cookies(self, cookies: Dict[str, str]):
|
|
54
|
+
"""
|
|
55
|
+
Set tus client cookies.
|
|
56
|
+
|
|
57
|
+
Update and/or set new cookies that would be sent along with every request made
|
|
58
|
+
to the server.
|
|
59
|
+
|
|
60
|
+
:Args:
|
|
61
|
+
- cookies (dict):
|
|
62
|
+
key, value pairs of the cookies to be set. This argument is required.
|
|
63
|
+
"""
|
|
64
|
+
self.cookies.update(cookies)
|
|
65
|
+
|
|
66
|
+
def uploader(self, *args, **kwargs) -> Uploader:
|
|
67
|
+
"""
|
|
68
|
+
Return uploader instance pointing at current client instance.
|
|
69
|
+
|
|
70
|
+
Return uploader instance with which you can control the upload of a specific
|
|
71
|
+
file. The current instance of the tus client is passed to the uploader on creation.
|
|
72
|
+
|
|
73
|
+
:Args:
|
|
74
|
+
see tusclient.uploader.Uploader for required and optional arguments.
|
|
75
|
+
"""
|
|
76
|
+
kwargs["client"] = self
|
|
77
|
+
return Uploader(*args, **kwargs)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Global Tusclient exception and warning classes.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TusCommunicationError(Exception):
|
|
7
|
+
"""
|
|
8
|
+
Should be raised when communications with tus-server behaves
|
|
9
|
+
unexpectedly.
|
|
10
|
+
|
|
11
|
+
:Attributes:
|
|
12
|
+
- message (str):
|
|
13
|
+
Main message of the exception
|
|
14
|
+
- status_code (int):
|
|
15
|
+
Status code of response indicating an error
|
|
16
|
+
- response_content (str):
|
|
17
|
+
Content of response indicating an error
|
|
18
|
+
:Constructor Args:
|
|
19
|
+
- message (Optional[str])
|
|
20
|
+
- status_code (Optional[int])
|
|
21
|
+
- response_content (Optional[str])
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, message, status_code=None, response_content=None):
|
|
25
|
+
default_message = "Communication with tus server failed with status {}".format(
|
|
26
|
+
status_code
|
|
27
|
+
)
|
|
28
|
+
message = message or default_message
|
|
29
|
+
super(TusCommunicationError, self).__init__(message)
|
|
30
|
+
self.status_code = status_code
|
|
31
|
+
self.response_content = response_content
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TusUploadFailed(TusCommunicationError):
|
|
35
|
+
"""Should be raised when an attempted upload fails"""
|
|
File without changes
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
import base64
|
|
3
|
+
from functools import wraps
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from d2spy.extras.third_party.tusclient.exceptions import (
|
|
8
|
+
TusUploadFailed,
|
|
9
|
+
TusCommunicationError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Catches requests exceptions and throws custom tuspy errors.
|
|
14
|
+
def catch_requests_error(func):
|
|
15
|
+
"""Deocrator to catch requests exceptions"""
|
|
16
|
+
|
|
17
|
+
@wraps(func)
|
|
18
|
+
def _wrapper(*args, **kwargs):
|
|
19
|
+
try:
|
|
20
|
+
return func(*args, **kwargs)
|
|
21
|
+
except requests.exceptions.RequestException as error:
|
|
22
|
+
raise TusCommunicationError(error)
|
|
23
|
+
|
|
24
|
+
return _wrapper
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BaseTusRequest:
|
|
28
|
+
"""
|
|
29
|
+
Http Request Abstraction.
|
|
30
|
+
|
|
31
|
+
Sets up tus custom http request on instantiation.
|
|
32
|
+
|
|
33
|
+
requires argument 'uploader' an instance of tusclient.uploader.Uploader
|
|
34
|
+
on instantiation.
|
|
35
|
+
|
|
36
|
+
:Attributes:
|
|
37
|
+
- response_headers (dict)
|
|
38
|
+
- file (file):
|
|
39
|
+
The file that is being uploaded.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, uploader):
|
|
43
|
+
self._url = uploader.url
|
|
44
|
+
self.response_headers = {}
|
|
45
|
+
self.status_code = None
|
|
46
|
+
self.response_content = None
|
|
47
|
+
self.verify_tls_cert = bool(uploader.verify_tls_cert)
|
|
48
|
+
self.file = uploader.get_file_stream()
|
|
49
|
+
self.file.seek(uploader.offset)
|
|
50
|
+
|
|
51
|
+
self._request_headers = {
|
|
52
|
+
"upload-offset": str(uploader.offset),
|
|
53
|
+
"Content-Type": "application/offset+octet-stream",
|
|
54
|
+
}
|
|
55
|
+
self._request_cookies = {}
|
|
56
|
+
self._request_headers.update(uploader.get_headers())
|
|
57
|
+
self._request_cookies.update(uploader.get_cookies())
|
|
58
|
+
self._content_length = uploader.get_request_length()
|
|
59
|
+
self._upload_checksum = uploader.upload_checksum
|
|
60
|
+
self._checksum_algorithm = uploader.checksum_algorithm
|
|
61
|
+
self._checksum_algorithm_name = uploader.checksum_algorithm_name
|
|
62
|
+
|
|
63
|
+
def add_checksum(self, chunk: bytes):
|
|
64
|
+
if self._upload_checksum:
|
|
65
|
+
self._request_headers["upload-checksum"] = " ".join(
|
|
66
|
+
(
|
|
67
|
+
self._checksum_algorithm_name,
|
|
68
|
+
base64.b64encode(self._checksum_algorithm(chunk).digest()).decode(
|
|
69
|
+
"ascii"
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TusRequest(BaseTusRequest):
|
|
76
|
+
"""Class to handle async Tus upload requests"""
|
|
77
|
+
|
|
78
|
+
def perform(self):
|
|
79
|
+
"""
|
|
80
|
+
Perform actual request.
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
chunk = self.file.read(self._content_length)
|
|
84
|
+
self.add_checksum(chunk)
|
|
85
|
+
resp = requests.patch(
|
|
86
|
+
self._url,
|
|
87
|
+
data=chunk,
|
|
88
|
+
headers=self._request_headers,
|
|
89
|
+
cookies=self._request_cookies,
|
|
90
|
+
verify=self.verify_tls_cert,
|
|
91
|
+
)
|
|
92
|
+
self.status_code = resp.status_code
|
|
93
|
+
self.response_content = resp.content
|
|
94
|
+
self.response_headers = {k.lower(): v for k, v in resp.headers.items()}
|
|
95
|
+
except requests.exceptions.RequestException as error:
|
|
96
|
+
raise TusUploadFailed(error)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from d2spy.extras.third_party.tusclient.uploader.uploader import Uploader
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
from typing import Optional, IO, Dict, TYPE_CHECKING
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from base64 import b64encode
|
|
5
|
+
from sys import maxsize as MAXSIZE
|
|
6
|
+
import hashlib
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from d2spy.extras.third_party.tusclient.exceptions import TusCommunicationError
|
|
11
|
+
from d2spy.extras.third_party.tusclient.request import TusRequest, catch_requests_error
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from d2spy.extras.third_party.tusclient.client import TusClient
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseUploader:
|
|
18
|
+
"""
|
|
19
|
+
Object to control upload related functions.
|
|
20
|
+
|
|
21
|
+
:Attributes:
|
|
22
|
+
- file_path (str):
|
|
23
|
+
This is the path(absolute/relative) to the file that is intended for upload
|
|
24
|
+
to the tus server. On instantiation this attribute is required.
|
|
25
|
+
- file_stream (file):
|
|
26
|
+
As an alternative to the `file_path`, an instance of the file to be uploaded
|
|
27
|
+
can be passed to the constructor as `file_stream`. Do note that either the
|
|
28
|
+
`file_stream` or the `file_path` must be passed on instantiation.
|
|
29
|
+
- url (str):
|
|
30
|
+
If the upload url for the file is known, it can be passed to the constructor.
|
|
31
|
+
This may happen when you resume an upload.
|
|
32
|
+
- client (<tusclient.client.TusClient>):
|
|
33
|
+
An instance of `tusclient.client.TusClient`. This would tell the uploader instance
|
|
34
|
+
what client it is operating with. Although this argument is optional, it is only
|
|
35
|
+
optional if the 'url' argument is specified.
|
|
36
|
+
- chunk_size (int):
|
|
37
|
+
This tells the uploader what chunk size(in bytes) should be uploaded when the
|
|
38
|
+
method `upload_chunk` is called. This defaults to the maximum possible integer if not
|
|
39
|
+
specified.
|
|
40
|
+
- metadata (dict):
|
|
41
|
+
A dictionary containing the upload-metadata. This would be encoded internally
|
|
42
|
+
by the method `encode_metadata` to conform with the tus protocol.
|
|
43
|
+
- metadata_encoding (str):
|
|
44
|
+
Encoding used for each upload-metadata value. This defaults to 'utf-8'.
|
|
45
|
+
- offset (int):
|
|
46
|
+
The offset value of the upload indicates the current position of the file upload.
|
|
47
|
+
- stop_at (int):
|
|
48
|
+
At what offset value the upload should stop.
|
|
49
|
+
- request (<tusclient.request.TusRequest>):
|
|
50
|
+
A http Request instance of the last chunk uploaded.
|
|
51
|
+
- retries (int):
|
|
52
|
+
The number of attempts the uploader should make in the case of a failed upload.
|
|
53
|
+
If not specified, it defaults to 0.
|
|
54
|
+
- retry_delay (int):
|
|
55
|
+
How long (in seconds) the uploader should wait before retrying a failed upload attempt.
|
|
56
|
+
If not specified, it defaults to 30.
|
|
57
|
+
- verify_tls_cert (bool):
|
|
58
|
+
Whether or not to verify the TLS certificate of the server.
|
|
59
|
+
If not specified, it defaults to True.
|
|
60
|
+
- upload_checksum (bool):
|
|
61
|
+
Whether or not to supply the Upload-Checksum header along with each
|
|
62
|
+
chunk. Defaults to False.
|
|
63
|
+
|
|
64
|
+
:Constructor Args:
|
|
65
|
+
- file_path (str)
|
|
66
|
+
- file_stream (Optional[file])
|
|
67
|
+
- url (Optional[str])
|
|
68
|
+
- client (Optional [<tusclient.client.TusClient>])
|
|
69
|
+
- chunk_size (Optional[int])
|
|
70
|
+
- metadata (Optional[dict])
|
|
71
|
+
- metadata_encoding (Optional[str])
|
|
72
|
+
- retries (Optional[int])
|
|
73
|
+
- retry_delay (Optional[int])
|
|
74
|
+
- verify_tls_cert (Optional[bool])
|
|
75
|
+
- upload_checksum (Optional[bool])
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
DEFAULT_HEADERS = {"Tus-Resumable": "1.0.0"}
|
|
79
|
+
DEFAULT_CHUNK_SIZE = MAXSIZE
|
|
80
|
+
CHECKSUM_ALGORITHM_PAIR = (
|
|
81
|
+
"sha1",
|
|
82
|
+
hashlib.sha1,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
file_path: Optional[str] = None,
|
|
88
|
+
file_stream: Optional[IO] = None,
|
|
89
|
+
url: Optional[str] = None,
|
|
90
|
+
client: Optional["TusClient"] = None,
|
|
91
|
+
chunk_size: int = MAXSIZE,
|
|
92
|
+
metadata: Optional[Dict] = None,
|
|
93
|
+
metadata_encoding: Optional[str] = "utf-8",
|
|
94
|
+
retries: int = 0,
|
|
95
|
+
retry_delay: int = 30,
|
|
96
|
+
verify_tls_cert: bool = True,
|
|
97
|
+
upload_checksum=False,
|
|
98
|
+
):
|
|
99
|
+
if file_path is None and file_stream is None:
|
|
100
|
+
raise ValueError("Either 'file_path' or 'file_stream' cannot be None.")
|
|
101
|
+
|
|
102
|
+
if url is None and client is None:
|
|
103
|
+
raise ValueError("Either 'url' or 'client' cannot be None.")
|
|
104
|
+
|
|
105
|
+
self.verify_tls_cert = verify_tls_cert
|
|
106
|
+
self.file_path = file_path
|
|
107
|
+
self.file_stream = file_stream
|
|
108
|
+
self.stop_at = self.get_file_size()
|
|
109
|
+
self.client = client
|
|
110
|
+
self.metadata = metadata or {}
|
|
111
|
+
self.metadata_encoding = metadata_encoding
|
|
112
|
+
self.offset = 0
|
|
113
|
+
self.url = None
|
|
114
|
+
self.__init_url_and_offset(url)
|
|
115
|
+
self.chunk_size = chunk_size
|
|
116
|
+
self.retries = retries
|
|
117
|
+
self.request = None
|
|
118
|
+
self._retried = 0
|
|
119
|
+
self.retry_delay = retry_delay
|
|
120
|
+
self.upload_checksum = upload_checksum
|
|
121
|
+
(
|
|
122
|
+
self.__checksum_algorithm_name,
|
|
123
|
+
self.__checksum_algorithm,
|
|
124
|
+
) = self.CHECKSUM_ALGORITHM_PAIR
|
|
125
|
+
|
|
126
|
+
def get_cookies(self):
|
|
127
|
+
"""
|
|
128
|
+
Return cookies of the uploader instance. This would include the cookies of the
|
|
129
|
+
client instance.
|
|
130
|
+
"""
|
|
131
|
+
client_cookies = getattr(self.client, "cookies", {})
|
|
132
|
+
return dict(**client_cookies)
|
|
133
|
+
|
|
134
|
+
def get_headers(self):
|
|
135
|
+
"""
|
|
136
|
+
Return headers of the uploader instance. This would include the headers of the
|
|
137
|
+
client instance.
|
|
138
|
+
"""
|
|
139
|
+
client_headers = getattr(self.client, "headers", {})
|
|
140
|
+
return dict(self.DEFAULT_HEADERS, **client_headers)
|
|
141
|
+
|
|
142
|
+
def get_url_creation_headers(self):
|
|
143
|
+
"""Return headers required to create upload url"""
|
|
144
|
+
headers = self.get_headers()
|
|
145
|
+
headers["upload-length"] = str(self.get_file_size())
|
|
146
|
+
headers["upload-metadata"] = ",".join(self.encode_metadata())
|
|
147
|
+
return headers
|
|
148
|
+
|
|
149
|
+
def get_url_creation_cookies(self):
|
|
150
|
+
"""Return cookies required to create upload url"""
|
|
151
|
+
cookies = self.get_cookies()
|
|
152
|
+
return cookies
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def checksum_algorithm(self):
|
|
156
|
+
"""The checksum algorithm to be used for the Upload-Checksum extension."""
|
|
157
|
+
return self.__checksum_algorithm
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def checksum_algorithm_name(self):
|
|
161
|
+
"""The name of the checksum algorithm to be used for the Upload-Checksum
|
|
162
|
+
extension.
|
|
163
|
+
"""
|
|
164
|
+
return self.__checksum_algorithm_name
|
|
165
|
+
|
|
166
|
+
@catch_requests_error
|
|
167
|
+
def get_offset(self):
|
|
168
|
+
"""
|
|
169
|
+
Return offset from tus server.
|
|
170
|
+
|
|
171
|
+
This is different from the instance attribute 'offset' because this makes an
|
|
172
|
+
http request to the tus server to retrieve the offset.
|
|
173
|
+
"""
|
|
174
|
+
resp = requests.head(
|
|
175
|
+
self.url, headers=self.get_headers(), verify=self.verify_tls_cert
|
|
176
|
+
)
|
|
177
|
+
offset = resp.headers.get("upload-offset")
|
|
178
|
+
if offset is None:
|
|
179
|
+
msg = "Attempt to retrieve offset fails with status {}".format(
|
|
180
|
+
resp.status_code
|
|
181
|
+
)
|
|
182
|
+
raise TusCommunicationError(msg, resp.status_code, resp.content)
|
|
183
|
+
return int(offset)
|
|
184
|
+
|
|
185
|
+
def encode_metadata(self):
|
|
186
|
+
"""
|
|
187
|
+
Return list of encoded metadata as defined by the Tus protocol.
|
|
188
|
+
"""
|
|
189
|
+
encoded_list = []
|
|
190
|
+
for key, value in self.metadata.items():
|
|
191
|
+
key_str = str(key) # dict keys may be of any object type.
|
|
192
|
+
|
|
193
|
+
# confirm that the key does not contain unwanted characters.
|
|
194
|
+
if re.search(r"^$|[\s,]+", key_str):
|
|
195
|
+
msg = 'Upload-metadata key "{}" cannot be empty nor contain spaces or commas.'
|
|
196
|
+
raise ValueError(msg.format(key_str))
|
|
197
|
+
|
|
198
|
+
value_bytes = value.encode(self.metadata_encoding)
|
|
199
|
+
encoded_list.append(
|
|
200
|
+
"{} {}".format(key_str, b64encode(value_bytes).decode("ascii"))
|
|
201
|
+
)
|
|
202
|
+
return encoded_list
|
|
203
|
+
|
|
204
|
+
def __init_url_and_offset(self, url: Optional[str] = None):
|
|
205
|
+
"""
|
|
206
|
+
Return the tus upload url.
|
|
207
|
+
|
|
208
|
+
If resumability is enabled, this would try to get the url from storage if available,
|
|
209
|
+
otherwise it would request a new upload url from the tus server.
|
|
210
|
+
"""
|
|
211
|
+
if url:
|
|
212
|
+
self.set_url(url)
|
|
213
|
+
|
|
214
|
+
if self.url:
|
|
215
|
+
self.offset = self.get_offset()
|
|
216
|
+
|
|
217
|
+
def set_url(self, url: str):
|
|
218
|
+
"""Set the upload URL"""
|
|
219
|
+
self.url = url # type: ignore
|
|
220
|
+
|
|
221
|
+
def get_request_length(self):
|
|
222
|
+
"""
|
|
223
|
+
Return length of next chunk upload.
|
|
224
|
+
"""
|
|
225
|
+
remainder = self.stop_at - self.offset
|
|
226
|
+
return self.chunk_size if remainder > self.chunk_size else remainder
|
|
227
|
+
|
|
228
|
+
def get_file_stream(self):
|
|
229
|
+
"""
|
|
230
|
+
Return a file stream instance of the upload.
|
|
231
|
+
"""
|
|
232
|
+
if self.file_stream:
|
|
233
|
+
self.file_stream.seek(0)
|
|
234
|
+
return self.file_stream
|
|
235
|
+
elif os.path.isfile(self.file_path):
|
|
236
|
+
return open(self.file_path, "rb")
|
|
237
|
+
else:
|
|
238
|
+
raise ValueError("invalid file {}".format(self.file_path))
|
|
239
|
+
|
|
240
|
+
def get_file_size(self):
|
|
241
|
+
"""
|
|
242
|
+
Return size of the file.
|
|
243
|
+
"""
|
|
244
|
+
stream = self.get_file_stream()
|
|
245
|
+
stream.seek(0, os.SEEK_END)
|
|
246
|
+
return stream.tell()
|