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.
Files changed (49) hide show
  1. mapillary_tools/__init__.py +1 -1
  2. mapillary_tools/api_v4.py +66 -262
  3. mapillary_tools/authenticate.py +54 -46
  4. mapillary_tools/blackvue_parser.py +79 -22
  5. mapillary_tools/commands/__main__.py +15 -16
  6. mapillary_tools/commands/upload.py +33 -4
  7. mapillary_tools/config.py +38 -17
  8. mapillary_tools/constants.py +127 -43
  9. mapillary_tools/exceptions.py +4 -0
  10. mapillary_tools/exif_read.py +2 -1
  11. mapillary_tools/exif_write.py +3 -1
  12. mapillary_tools/exiftool_read_video.py +52 -15
  13. mapillary_tools/exiftool_runner.py +4 -24
  14. mapillary_tools/ffmpeg.py +406 -232
  15. mapillary_tools/geo.py +16 -0
  16. mapillary_tools/geotag/__init__.py +0 -0
  17. mapillary_tools/geotag/base.py +8 -4
  18. mapillary_tools/geotag/factory.py +106 -89
  19. mapillary_tools/geotag/geotag_images_from_exiftool.py +27 -20
  20. mapillary_tools/geotag/geotag_images_from_gpx.py +7 -6
  21. mapillary_tools/geotag/geotag_images_from_video.py +35 -0
  22. mapillary_tools/geotag/geotag_videos_from_exiftool.py +61 -14
  23. mapillary_tools/geotag/geotag_videos_from_gpx.py +22 -9
  24. mapillary_tools/geotag/options.py +25 -3
  25. mapillary_tools/geotag/utils.py +9 -12
  26. mapillary_tools/geotag/video_extractors/base.py +1 -1
  27. mapillary_tools/geotag/video_extractors/exiftool.py +1 -1
  28. mapillary_tools/geotag/video_extractors/gpx.py +61 -70
  29. mapillary_tools/geotag/video_extractors/native.py +34 -31
  30. mapillary_tools/history.py +128 -8
  31. mapillary_tools/http.py +211 -0
  32. mapillary_tools/mp4/construct_mp4_parser.py +8 -2
  33. mapillary_tools/process_geotag_properties.py +47 -35
  34. mapillary_tools/process_sequence_properties.py +340 -325
  35. mapillary_tools/sample_video.py +8 -8
  36. mapillary_tools/serializer/description.py +587 -0
  37. mapillary_tools/serializer/gpx.py +132 -0
  38. mapillary_tools/types.py +44 -610
  39. mapillary_tools/upload.py +327 -352
  40. mapillary_tools/upload_api_v4.py +125 -72
  41. mapillary_tools/uploader.py +797 -216
  42. mapillary_tools/utils.py +57 -5
  43. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/METADATA +91 -34
  44. mapillary_tools-0.14.1.dist-info/RECORD +76 -0
  45. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/WHEEL +1 -1
  46. mapillary_tools-0.14.0a2.dist-info/RECORD +0 -72
  47. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/entry_points.txt +0 -0
  48. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/licenses/LICENSE +0 -0
  49. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/top_level.txt +0 -0
@@ -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 ClusterFileType, request_get, request_post, REQUESTS_TIMEOUT
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
- MIME_BY_CLUSTER_TYPE: dict[ClusterFileType, str] = {
37
- ClusterFileType.ZIP: "application/zip",
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
- resp = request_get(
59
- url,
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
- data = resp.json()
65
- return data["offset"]
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
- self, chunks: T.Iterable[bytes], offset: int
76
+ cls, chunks: T.Iterable[bytes], offset: int
81
77
  ) -> T.Generator[bytes, None, None]:
82
- assert offset >= 0, f"Expect non-negative offset but got {offset}"
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(self.chunkize_byte_stream(stream, chunk_size), offset)
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(shifted_chunks, offset)
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(begin_offset, io.SEEK_SET))
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
- url = f"{MAPILLARY_UPLOAD_ENDPOINT}/{self.session_key}"
130
- resp = request_post(
156
+
157
+ resp = self.user_session.post(
131
158
  url,
132
159
  headers=headers,
133
160
  data=shifted_chunks,
134
- timeout=UPLOAD_REQUESTS_TIMEOUT,
161
+ timeout=(REQUESTS_TIMEOUT, read_timeout), # type: ignore
135
162
  )
136
-
137
163
  resp.raise_for_status()
138
164
 
139
- payload = resp.json()
165
+ data = jsonify_response(resp)
140
166
  try:
141
- return payload["h"]
167
+ return data["h"]
142
168
  except KeyError:
143
- raise RuntimeError(
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
- def __init__(self, *args, **kwargs):
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
- self._upload_path = os.getenv(
153
- "MAPILLARY_UPLOAD_PATH", "mapillary_public_uploads"
154
- )
155
- self._error_ratio = 0.02
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 = os.path.join(self._upload_path, self.session_key)
171
- with open(filename, "ab") as fp:
210
+ filename = self._upload_path.joinpath(self.session_key)
211
+ with filename.open("ab") as fp:
172
212
  for chunk in shifted_chunks:
173
- if random.random() <= self._error_ratio:
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
- if random.random() <= self._error_ratio:
179
- raise requests.ConnectionError(
180
- f"TEST ONLY: Partially uploaded with error ratio {self._error_ratio}"
181
- )
182
- return uuid.uuid4().hex
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
- if random.random() <= self._error_ratio:
187
- raise requests.ConnectionError(
188
- f"TEST ONLY: Partially uploaded with error ratio {self._error_ratio}"
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
+ )