mapillary-tools 0.14.0a2__py3-none-any.whl → 0.14.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.
- mapillary_tools/__init__.py +1 -1
- mapillary_tools/api_v4.py +66 -262
- mapillary_tools/authenticate.py +54 -46
- mapillary_tools/blackvue_parser.py +79 -22
- mapillary_tools/commands/__main__.py +15 -16
- mapillary_tools/commands/upload.py +33 -4
- mapillary_tools/config.py +38 -17
- mapillary_tools/constants.py +127 -43
- mapillary_tools/exceptions.py +4 -0
- mapillary_tools/exif_read.py +2 -1
- mapillary_tools/exif_write.py +3 -1
- mapillary_tools/exiftool_read_video.py +52 -15
- mapillary_tools/exiftool_runner.py +4 -24
- mapillary_tools/ffmpeg.py +406 -232
- mapillary_tools/geo.py +16 -0
- mapillary_tools/geotag/__init__.py +0 -0
- mapillary_tools/geotag/base.py +8 -4
- mapillary_tools/geotag/factory.py +106 -89
- mapillary_tools/geotag/geotag_images_from_exiftool.py +27 -20
- mapillary_tools/geotag/geotag_images_from_gpx.py +7 -6
- mapillary_tools/geotag/geotag_images_from_video.py +35 -0
- mapillary_tools/geotag/geotag_videos_from_exiftool.py +61 -14
- mapillary_tools/geotag/geotag_videos_from_gpx.py +22 -9
- mapillary_tools/geotag/options.py +25 -3
- mapillary_tools/geotag/utils.py +9 -12
- mapillary_tools/geotag/video_extractors/base.py +1 -1
- mapillary_tools/geotag/video_extractors/exiftool.py +1 -1
- mapillary_tools/geotag/video_extractors/gpx.py +61 -70
- mapillary_tools/geotag/video_extractors/native.py +34 -31
- mapillary_tools/history.py +128 -8
- mapillary_tools/http.py +211 -0
- mapillary_tools/mp4/construct_mp4_parser.py +8 -2
- mapillary_tools/process_geotag_properties.py +47 -35
- mapillary_tools/process_sequence_properties.py +340 -325
- mapillary_tools/sample_video.py +8 -8
- mapillary_tools/serializer/description.py +587 -0
- mapillary_tools/serializer/gpx.py +132 -0
- mapillary_tools/types.py +44 -610
- mapillary_tools/upload.py +327 -352
- mapillary_tools/upload_api_v4.py +125 -72
- mapillary_tools/uploader.py +797 -216
- mapillary_tools/utils.py +57 -5
- {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/METADATA +91 -34
- mapillary_tools-0.14.1.dist-info/RECORD +76 -0
- {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/WHEEL +1 -1
- mapillary_tools-0.14.0a2.dist-info/RECORD +0 -72
- {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/entry_points.txt +0 -0
- {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/licenses/LICENSE +0 -0
- {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/top_level.txt +0 -0
mapillary_tools/upload_api_v4.py
CHANGED
|
@@ -6,80 +6,99 @@ import random
|
|
|
6
6
|
import sys
|
|
7
7
|
import typing as T
|
|
8
8
|
import uuid
|
|
9
|
+
from pathlib import Path
|
|
9
10
|
|
|
10
11
|
if sys.version_info >= (3, 12):
|
|
11
12
|
from typing import override
|
|
12
13
|
else:
|
|
13
14
|
from typing_extensions import override
|
|
14
15
|
|
|
16
|
+
import tempfile
|
|
17
|
+
|
|
15
18
|
import requests
|
|
16
19
|
|
|
17
|
-
from .api_v4 import
|
|
20
|
+
from .api_v4 import HTTPContentError, jsonify_response, REQUESTS_TIMEOUT
|
|
18
21
|
|
|
19
22
|
MAPILLARY_UPLOAD_ENDPOINT = os.getenv(
|
|
20
23
|
"MAPILLARY_UPLOAD_ENDPOINT", "https://rupload.facebook.com/mapillary_public_uploads"
|
|
21
24
|
)
|
|
22
|
-
# According to the docs, UPLOAD_REQUESTS_TIMEOUT can be a tuple of
|
|
23
|
-
# (connection_timeout, read_timeout): https://requests.readthedocs.io/en/latest/user/advanced/#timeouts
|
|
24
|
-
# In my test, however, the connection_timeout rules both connection timeout and read timeout.
|
|
25
|
-
# i.e. if your the server does not respond within this timeout, it will throw:
|
|
26
|
-
# ConnectionError: ('Connection aborted.', timeout('The write operation timed out'))
|
|
27
|
-
# So let us make sure the largest possible chunks can be uploaded before this timeout for now,
|
|
28
|
-
UPLOAD_REQUESTS_TIMEOUT = (30 * 60, 30 * 60) # 30 minutes
|
|
29
25
|
|
|
30
26
|
|
|
31
27
|
class UploadService:
|
|
28
|
+
"""
|
|
29
|
+
Upload byte streams to the Upload Service.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
32
|
user_access_token: str
|
|
33
33
|
session_key: str
|
|
34
|
-
cluster_filetype: ClusterFileType
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
ClusterFileType.BLACKVUE: "video/mp4",
|
|
39
|
-
ClusterFileType.CAMM: "video/mp4",
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
def __init__(
|
|
43
|
-
self,
|
|
44
|
-
user_access_token: str,
|
|
45
|
-
session_key: str,
|
|
46
|
-
cluster_filetype: ClusterFileType,
|
|
47
|
-
):
|
|
48
|
-
self.user_access_token = user_access_token
|
|
35
|
+
def __init__(self, user_session: requests.Session, session_key: str):
|
|
36
|
+
self.user_session = user_session
|
|
49
37
|
self.session_key = session_key
|
|
50
|
-
# Validate the input
|
|
51
|
-
self.cluster_filetype = cluster_filetype
|
|
52
38
|
|
|
53
39
|
def fetch_offset(self) -> int:
|
|
54
|
-
headers = {
|
|
55
|
-
"Authorization": f"OAuth {self.user_access_token}",
|
|
56
|
-
}
|
|
57
40
|
url = f"{MAPILLARY_UPLOAD_ENDPOINT}/{self.session_key}"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
headers=headers,
|
|
61
|
-
timeout=REQUESTS_TIMEOUT,
|
|
62
|
-
)
|
|
41
|
+
|
|
42
|
+
resp = self.user_session.get(url, timeout=REQUESTS_TIMEOUT)
|
|
63
43
|
resp.raise_for_status()
|
|
64
|
-
|
|
65
|
-
|
|
44
|
+
|
|
45
|
+
data = jsonify_response(resp)
|
|
46
|
+
try:
|
|
47
|
+
return data["offset"]
|
|
48
|
+
except KeyError:
|
|
49
|
+
raise HTTPContentError("Offset not found in the response", resp)
|
|
66
50
|
|
|
67
51
|
@classmethod
|
|
68
52
|
def chunkize_byte_stream(
|
|
69
53
|
cls, stream: T.IO[bytes], chunk_size: int
|
|
70
54
|
) -> T.Generator[bytes, None, None]:
|
|
55
|
+
"""
|
|
56
|
+
Chunkize a byte stream into chunks of the specified size.
|
|
57
|
+
|
|
58
|
+
>>> list(UploadService.chunkize_byte_stream(io.BytesIO(b"foo"), 1))
|
|
59
|
+
[b'f', b'o', b'o']
|
|
60
|
+
|
|
61
|
+
>>> list(UploadService.chunkize_byte_stream(io.BytesIO(b"foo"), 10))
|
|
62
|
+
[b'foo']
|
|
63
|
+
"""
|
|
64
|
+
|
|
71
65
|
if chunk_size <= 0:
|
|
72
66
|
raise ValueError("Expect positive chunk size")
|
|
67
|
+
|
|
73
68
|
while True:
|
|
74
69
|
data = stream.read(chunk_size)
|
|
75
70
|
if not data:
|
|
76
71
|
break
|
|
77
72
|
yield data
|
|
78
73
|
|
|
74
|
+
@classmethod
|
|
79
75
|
def shift_chunks(
|
|
80
|
-
|
|
76
|
+
cls, chunks: T.Iterable[bytes], offset: int
|
|
81
77
|
) -> T.Generator[bytes, None, None]:
|
|
82
|
-
|
|
78
|
+
"""
|
|
79
|
+
Shift the chunks by the offset.
|
|
80
|
+
|
|
81
|
+
>>> list(UploadService.shift_chunks([b"foo", b"bar"], 0))
|
|
82
|
+
[b'foo', b'bar']
|
|
83
|
+
|
|
84
|
+
>>> list(UploadService.shift_chunks([b"foo", b"bar"], 1))
|
|
85
|
+
[b'oo', b'bar']
|
|
86
|
+
|
|
87
|
+
>>> list(UploadService.shift_chunks([b"foo", b"bar"], 3))
|
|
88
|
+
[b'bar']
|
|
89
|
+
|
|
90
|
+
>>> list(UploadService.shift_chunks([b"foo", b"bar"], 6))
|
|
91
|
+
[]
|
|
92
|
+
|
|
93
|
+
>>> list(UploadService.shift_chunks([b"foo", b"bar"], 7))
|
|
94
|
+
[]
|
|
95
|
+
|
|
96
|
+
>>> list(UploadService.shift_chunks([], 0))
|
|
97
|
+
[]
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
if offset < 0:
|
|
101
|
+
raise ValueError(f"Expect non-negative offset but got {offset}")
|
|
83
102
|
|
|
84
103
|
for chunk in chunks:
|
|
85
104
|
if offset:
|
|
@@ -96,69 +115,90 @@ class UploadService:
|
|
|
96
115
|
stream: T.IO[bytes],
|
|
97
116
|
offset: int | None = None,
|
|
98
117
|
chunk_size: int = 2 * 1024 * 1024, # 2MB
|
|
118
|
+
read_timeout: float | None = None,
|
|
99
119
|
) -> str:
|
|
100
120
|
if offset is None:
|
|
101
121
|
offset = self.fetch_offset()
|
|
102
|
-
return self.upload_chunks(
|
|
122
|
+
return self.upload_chunks(
|
|
123
|
+
self.chunkize_byte_stream(stream, chunk_size),
|
|
124
|
+
offset,
|
|
125
|
+
read_timeout=read_timeout,
|
|
126
|
+
)
|
|
103
127
|
|
|
104
128
|
def upload_chunks(
|
|
105
129
|
self,
|
|
106
130
|
chunks: T.Iterable[bytes],
|
|
107
131
|
offset: int | None = None,
|
|
132
|
+
read_timeout: float | None = None,
|
|
108
133
|
) -> str:
|
|
109
134
|
if offset is None:
|
|
110
135
|
offset = self.fetch_offset()
|
|
111
136
|
shifted_chunks = self.shift_chunks(chunks, offset)
|
|
112
|
-
return self.upload_shifted_chunks(
|
|
137
|
+
return self.upload_shifted_chunks(
|
|
138
|
+
shifted_chunks, offset, read_timeout=read_timeout
|
|
139
|
+
)
|
|
113
140
|
|
|
114
141
|
def upload_shifted_chunks(
|
|
115
142
|
self,
|
|
116
143
|
shifted_chunks: T.Iterable[bytes],
|
|
117
144
|
offset: int,
|
|
145
|
+
read_timeout: float | None = None,
|
|
118
146
|
) -> str:
|
|
119
147
|
"""
|
|
120
|
-
Upload the chunks that must already be shifted by the offset (e.g. fp.seek(
|
|
148
|
+
Upload the chunks that must already be shifted by the offset (e.g. fp.seek(offset, io.SEEK_SET))
|
|
121
149
|
"""
|
|
122
150
|
|
|
151
|
+
url = f"{MAPILLARY_UPLOAD_ENDPOINT}/{self.session_key}"
|
|
123
152
|
headers = {
|
|
124
|
-
"Authorization": f"OAuth {self.user_access_token}",
|
|
125
153
|
"Offset": f"{offset}",
|
|
126
154
|
"X-Entity-Name": self.session_key,
|
|
127
|
-
"X-Entity-Type": self.MIME_BY_CLUSTER_TYPE[self.cluster_filetype],
|
|
128
155
|
}
|
|
129
|
-
|
|
130
|
-
resp =
|
|
156
|
+
|
|
157
|
+
resp = self.user_session.post(
|
|
131
158
|
url,
|
|
132
159
|
headers=headers,
|
|
133
160
|
data=shifted_chunks,
|
|
134
|
-
timeout=
|
|
161
|
+
timeout=(REQUESTS_TIMEOUT, read_timeout), # type: ignore
|
|
135
162
|
)
|
|
136
|
-
|
|
137
163
|
resp.raise_for_status()
|
|
138
164
|
|
|
139
|
-
|
|
165
|
+
data = jsonify_response(resp)
|
|
140
166
|
try:
|
|
141
|
-
return
|
|
167
|
+
return data["h"]
|
|
142
168
|
except KeyError:
|
|
143
|
-
raise
|
|
144
|
-
f"Upload server error: File handle not found in the upload response {resp.text}"
|
|
145
|
-
)
|
|
169
|
+
raise HTTPContentError("File handle not found in the response", resp)
|
|
146
170
|
|
|
147
171
|
|
|
148
172
|
# A mock class for testing only
|
|
149
173
|
class FakeUploadService(UploadService):
|
|
150
|
-
|
|
174
|
+
"""
|
|
175
|
+
A mock upload service that simulates the upload process for testing purposes.
|
|
176
|
+
It writes the uploaded data to a file in a temporary directory and generates a fake file handle.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
FILE_HANDLE_DIR: str = "file_handles"
|
|
180
|
+
|
|
181
|
+
def __init__(
|
|
182
|
+
self,
|
|
183
|
+
*args,
|
|
184
|
+
upload_path: Path | None = None,
|
|
185
|
+
transient_error_ratio: float = 0.0,
|
|
186
|
+
**kwargs,
|
|
187
|
+
):
|
|
151
188
|
super().__init__(*args, **kwargs)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
189
|
+
if upload_path is None:
|
|
190
|
+
upload_path = Path(tempfile.gettempdir()).joinpath(
|
|
191
|
+
"mapillary_public_uploads"
|
|
192
|
+
)
|
|
193
|
+
self._upload_path = upload_path
|
|
194
|
+
self._transient_error_ratio = transient_error_ratio
|
|
156
195
|
|
|
157
196
|
@override
|
|
158
197
|
def upload_shifted_chunks(
|
|
159
198
|
self,
|
|
160
199
|
shifted_chunks: T.Iterable[bytes],
|
|
161
200
|
offset: int,
|
|
201
|
+
read_timeout: float | None = None,
|
|
162
202
|
) -> str:
|
|
163
203
|
expected_offset = self.fetch_offset()
|
|
164
204
|
if offset != expected_offset:
|
|
@@ -167,29 +207,42 @@ class FakeUploadService(UploadService):
|
|
|
167
207
|
)
|
|
168
208
|
|
|
169
209
|
os.makedirs(self._upload_path, exist_ok=True)
|
|
170
|
-
filename =
|
|
171
|
-
with open(
|
|
210
|
+
filename = self._upload_path.joinpath(self.session_key)
|
|
211
|
+
with filename.open("ab") as fp:
|
|
172
212
|
for chunk in shifted_chunks:
|
|
173
|
-
|
|
174
|
-
raise requests.ConnectionError(
|
|
175
|
-
f"TEST ONLY: Failed to upload with error ratio {self._error_ratio}"
|
|
176
|
-
)
|
|
213
|
+
self._randomly_raise_transient_error()
|
|
177
214
|
fp.write(chunk)
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
215
|
+
self._randomly_raise_transient_error()
|
|
216
|
+
|
|
217
|
+
file_handle_dir = self._upload_path.joinpath(self.FILE_HANDLE_DIR)
|
|
218
|
+
file_handle_path = file_handle_dir.joinpath(self.session_key)
|
|
219
|
+
if not file_handle_path.exists():
|
|
220
|
+
os.makedirs(file_handle_dir, exist_ok=True)
|
|
221
|
+
random_file_handle = uuid.uuid4().hex
|
|
222
|
+
file_handle_path.write_text(random_file_handle)
|
|
223
|
+
|
|
224
|
+
return file_handle_path.read_text()
|
|
183
225
|
|
|
184
226
|
@override
|
|
185
227
|
def fetch_offset(self) -> int:
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
)
|
|
190
|
-
filename = os.path.join(self._upload_path, self.session_key)
|
|
191
|
-
if not os.path.exists(filename):
|
|
228
|
+
self._randomly_raise_transient_error()
|
|
229
|
+
filename = self._upload_path.joinpath(self.session_key)
|
|
230
|
+
if not filename.exists():
|
|
192
231
|
return 0
|
|
193
232
|
with open(filename, "rb") as fp:
|
|
194
233
|
fp.seek(0, io.SEEK_END)
|
|
195
234
|
return fp.tell()
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def upload_path(self) -> Path:
|
|
238
|
+
return self._upload_path
|
|
239
|
+
|
|
240
|
+
def _randomly_raise_transient_error(self):
|
|
241
|
+
"""
|
|
242
|
+
Randomly raise a transient error based on the configured error ratio.
|
|
243
|
+
This is for testing purposes only.
|
|
244
|
+
"""
|
|
245
|
+
if random.random() <= self._transient_error_ratio:
|
|
246
|
+
raise requests.ConnectionError(
|
|
247
|
+
f"[TEST ONLY]: Transient error with ratio {self._transient_error_ratio}"
|
|
248
|
+
)
|