mapillary-tools 0.13.3__py3-none-any.whl → 0.14.0__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 (87) hide show
  1. mapillary_tools/__init__.py +1 -1
  2. mapillary_tools/api_v4.py +198 -55
  3. mapillary_tools/authenticate.py +326 -64
  4. mapillary_tools/blackvue_parser.py +195 -0
  5. mapillary_tools/camm/camm_builder.py +55 -97
  6. mapillary_tools/camm/camm_parser.py +429 -181
  7. mapillary_tools/commands/__main__.py +10 -6
  8. mapillary_tools/commands/authenticate.py +8 -1
  9. mapillary_tools/commands/process.py +27 -51
  10. mapillary_tools/commands/process_and_upload.py +18 -5
  11. mapillary_tools/commands/sample_video.py +2 -3
  12. mapillary_tools/commands/upload.py +44 -13
  13. mapillary_tools/commands/video_process_and_upload.py +19 -5
  14. mapillary_tools/config.py +65 -26
  15. mapillary_tools/constants.py +141 -18
  16. mapillary_tools/exceptions.py +37 -34
  17. mapillary_tools/exif_read.py +221 -116
  18. mapillary_tools/exif_write.py +10 -8
  19. mapillary_tools/exiftool_read.py +33 -42
  20. mapillary_tools/exiftool_read_video.py +97 -47
  21. mapillary_tools/exiftool_runner.py +57 -0
  22. mapillary_tools/ffmpeg.py +417 -242
  23. mapillary_tools/geo.py +158 -118
  24. mapillary_tools/geotag/__init__.py +0 -1
  25. mapillary_tools/geotag/base.py +147 -0
  26. mapillary_tools/geotag/factory.py +307 -0
  27. mapillary_tools/geotag/geotag_images_from_exif.py +14 -131
  28. mapillary_tools/geotag/geotag_images_from_exiftool.py +136 -85
  29. mapillary_tools/geotag/geotag_images_from_gpx.py +60 -124
  30. mapillary_tools/geotag/geotag_images_from_gpx_file.py +13 -126
  31. mapillary_tools/geotag/geotag_images_from_nmea_file.py +4 -5
  32. mapillary_tools/geotag/geotag_images_from_video.py +88 -51
  33. mapillary_tools/geotag/geotag_videos_from_exiftool.py +123 -0
  34. mapillary_tools/geotag/geotag_videos_from_gpx.py +52 -0
  35. mapillary_tools/geotag/geotag_videos_from_video.py +20 -185
  36. mapillary_tools/geotag/image_extractors/base.py +18 -0
  37. mapillary_tools/geotag/image_extractors/exif.py +60 -0
  38. mapillary_tools/geotag/image_extractors/exiftool.py +18 -0
  39. mapillary_tools/geotag/options.py +182 -0
  40. mapillary_tools/geotag/utils.py +52 -16
  41. mapillary_tools/geotag/video_extractors/base.py +18 -0
  42. mapillary_tools/geotag/video_extractors/exiftool.py +70 -0
  43. mapillary_tools/geotag/video_extractors/gpx.py +116 -0
  44. mapillary_tools/geotag/video_extractors/native.py +160 -0
  45. mapillary_tools/{geotag → gpmf}/gpmf_parser.py +205 -182
  46. mapillary_tools/{geotag → gpmf}/gps_filter.py +5 -3
  47. mapillary_tools/history.py +134 -20
  48. mapillary_tools/mp4/construct_mp4_parser.py +17 -10
  49. mapillary_tools/mp4/io_utils.py +0 -1
  50. mapillary_tools/mp4/mp4_sample_parser.py +36 -28
  51. mapillary_tools/mp4/simple_mp4_builder.py +10 -9
  52. mapillary_tools/mp4/simple_mp4_parser.py +13 -22
  53. mapillary_tools/process_geotag_properties.py +184 -414
  54. mapillary_tools/process_sequence_properties.py +594 -225
  55. mapillary_tools/sample_video.py +20 -26
  56. mapillary_tools/serializer/description.py +587 -0
  57. mapillary_tools/serializer/gpx.py +132 -0
  58. mapillary_tools/telemetry.py +26 -13
  59. mapillary_tools/types.py +98 -611
  60. mapillary_tools/upload.py +411 -387
  61. mapillary_tools/upload_api_v4.py +167 -142
  62. mapillary_tools/uploader.py +804 -284
  63. mapillary_tools/utils.py +49 -18
  64. {mapillary_tools-0.13.3.dist-info → mapillary_tools-0.14.0.dist-info}/METADATA +93 -35
  65. mapillary_tools-0.14.0.dist-info/RECORD +75 -0
  66. {mapillary_tools-0.13.3.dist-info → mapillary_tools-0.14.0.dist-info}/WHEEL +1 -1
  67. mapillary_tools/geotag/blackvue_parser.py +0 -118
  68. mapillary_tools/geotag/geotag_from_generic.py +0 -22
  69. mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +0 -93
  70. mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +0 -145
  71. mapillary_tools/video_data_extraction/cli_options.py +0 -22
  72. mapillary_tools/video_data_extraction/extract_video_data.py +0 -176
  73. mapillary_tools/video_data_extraction/extractors/base_parser.py +0 -75
  74. mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +0 -34
  75. mapillary_tools/video_data_extraction/extractors/camm_parser.py +0 -38
  76. mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +0 -71
  77. mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +0 -53
  78. mapillary_tools/video_data_extraction/extractors/generic_video_parser.py +0 -52
  79. mapillary_tools/video_data_extraction/extractors/gopro_parser.py +0 -43
  80. mapillary_tools/video_data_extraction/extractors/gpx_parser.py +0 -108
  81. mapillary_tools/video_data_extraction/extractors/nmea_parser.py +0 -24
  82. mapillary_tools/video_data_extraction/video_data_parser_factory.py +0 -39
  83. mapillary_tools-0.13.3.dist-info/RECORD +0 -75
  84. /mapillary_tools/{geotag → gpmf}/gpmf_gps_filter.py +0 -0
  85. {mapillary_tools-0.13.3.dist-info → mapillary_tools-0.14.0.dist-info}/entry_points.txt +0 -0
  86. {mapillary_tools-0.13.3.dist-info → mapillary_tools-0.14.0.dist-info/licenses}/LICENSE +0 -0
  87. {mapillary_tools-0.13.3.dist-info → mapillary_tools-0.14.0.dist-info}/top_level.txt +0 -0
@@ -1 +1 @@
1
- VERSION = "0.13.3"
1
+ VERSION = "0.14.0"
mapillary_tools/api_v4.py CHANGED
@@ -1,3 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ import enum
1
4
  import logging
2
5
  import os
3
6
  import ssl
@@ -18,6 +21,24 @@ REQUESTS_TIMEOUT = 60 # 1 minutes
18
21
  USE_SYSTEM_CERTS: bool = False
19
22
 
20
23
 
24
+ class HTTPContentError(Exception):
25
+ """
26
+ Raised when the HTTP response is ok (200) but the content is not as expected
27
+ e.g. not JSON or not a valid response.
28
+ """
29
+
30
+ def __init__(self, message: str, response: requests.Response):
31
+ self.response = response
32
+ super().__init__(message)
33
+
34
+
35
+ class ClusterFileType(enum.Enum):
36
+ ZIP = "zip"
37
+ BLACKVUE = "mly_blackvue_video"
38
+ CAMM = "mly_camm_video"
39
+ MLY_BUNDLE_MANIFEST = "mly_bundle_manifest"
40
+
41
+
21
42
  class HTTPSystemCertsAdapter(HTTPAdapter):
22
43
  """
23
44
  This adapter uses the system's certificate store instead of the certifi module.
@@ -48,29 +69,30 @@ class HTTPSystemCertsAdapter(HTTPAdapter):
48
69
 
49
70
 
50
71
  @T.overload
51
- def _truncate(s: bytes, limit: int = 512) -> bytes: ...
72
+ def _truncate(s: bytes, limit: int = 256) -> bytes | str: ...
52
73
 
53
74
 
54
75
  @T.overload
55
- def _truncate(s: str, limit: int = 512) -> str: ...
76
+ def _truncate(s: str, limit: int = 256) -> str: ...
56
77
 
57
78
 
58
- def _truncate(s, limit=512):
79
+ def _truncate(s, limit=256):
59
80
  if limit < len(s):
81
+ if isinstance(s, bytes):
82
+ try:
83
+ s = s.decode("utf-8")
84
+ except UnicodeDecodeError:
85
+ pass
60
86
  remaining = len(s) - limit
61
87
  if isinstance(s, bytes):
62
- return (
63
- s[:limit]
64
- + b"..."
65
- + f"({remaining} more bytes truncated)".encode("utf-8")
66
- )
88
+ return s[:limit] + f"...({remaining} bytes truncated)".encode("utf-8")
67
89
  else:
68
- return str(s[:limit]) + f"...({remaining} more chars truncated)"
90
+ return str(s[:limit]) + f"...({remaining} chars truncated)"
69
91
  else:
70
92
  return s
71
93
 
72
94
 
73
- def _sanitize(headers: T.Dict):
95
+ def _sanitize(headers: T.Mapping[T.Any, T.Any]) -> T.Mapping[T.Any, T.Any]:
74
96
  new_headers = {}
75
97
 
76
98
  for k, v in headers.items():
@@ -81,10 +103,14 @@ def _sanitize(headers: T.Dict):
81
103
  "access-token",
82
104
  "access_token",
83
105
  "password",
106
+ "user_upload_token",
84
107
  ]:
85
108
  new_headers[k] = "[REDACTED]"
86
109
  else:
87
- new_headers[k] = _truncate(v)
110
+ if isinstance(v, (str, bytes)):
111
+ new_headers[k] = T.cast(T.Any, _truncate(v))
112
+ else:
113
+ new_headers[k] = v
88
114
 
89
115
  return new_headers
90
116
 
@@ -92,9 +118,9 @@ def _sanitize(headers: T.Dict):
92
118
  def _log_debug_request(
93
119
  method: str,
94
120
  url: str,
95
- json: T.Optional[T.Dict] = None,
96
- params: T.Optional[T.Dict] = None,
97
- headers: T.Optional[T.Dict] = None,
121
+ json: dict | None = None,
122
+ params: dict | None = None,
123
+ headers: dict | None = None,
98
124
  timeout: T.Any = None,
99
125
  ):
100
126
  if logging.getLogger().getEffectiveLevel() <= logging.DEBUG:
@@ -118,6 +144,8 @@ def _log_debug_request(
118
144
  if timeout is not None:
119
145
  msg += f" TIMEOUT={timeout}"
120
146
 
147
+ msg = msg.replace("\n", "\\n")
148
+
121
149
  LOG.debug(msg)
122
150
 
123
151
 
@@ -125,44 +153,61 @@ def _log_debug_response(resp: requests.Response):
125
153
  if logging.getLogger().getEffectiveLevel() <= logging.DEBUG:
126
154
  return
127
155
 
128
- data: T.Union[str, bytes]
156
+ elapsed = resp.elapsed.total_seconds() * 1000 # Convert to milliseconds
157
+ msg = f"HTTP {resp.status_code} {resp.reason} ({elapsed:.0f} ms): {str(_truncate_response_content(resp))}"
158
+
159
+ LOG.debug(msg)
160
+
161
+
162
+ def _truncate_response_content(resp: requests.Response) -> str | bytes:
129
163
  try:
130
- data = _truncate(dumps(_sanitize(resp.json())))
131
- except Exception:
132
- data = _truncate(resp.content)
164
+ json_data = resp.json()
165
+ except requests.JSONDecodeError:
166
+ if resp.content is not None:
167
+ data = _truncate(resp.content)
168
+ else:
169
+ data = ""
170
+ else:
171
+ if isinstance(json_data, dict):
172
+ data = _truncate(dumps(_sanitize(json_data)))
173
+ else:
174
+ data = _truncate(str(json_data))
175
+
176
+ if isinstance(data, bytes):
177
+ return data.replace(b"\n", b"\\n")
133
178
 
134
- LOG.debug(f"HTTP {resp.status_code} ({resp.reason}): %s", data)
179
+ elif isinstance(data, str):
180
+ return data.replace("\n", "\\n")
181
+
182
+ return data
135
183
 
136
184
 
137
185
  def readable_http_error(ex: requests.HTTPError) -> str:
138
- req = ex.request
139
- resp = ex.response
186
+ return readable_http_response(ex.response)
140
187
 
141
- data: T.Union[str, bytes]
142
- try:
143
- data = _truncate(dumps(_sanitize(resp.json())))
144
- except Exception:
145
- data = _truncate(resp.content)
146
188
 
147
- return f"{req.method} {resp.url} => {resp.status_code} ({resp.reason}): {str(data)}"
189
+ def readable_http_response(resp: requests.Response) -> str:
190
+ return f"{resp.request.method} {resp.url} => {resp.status_code} {resp.reason}: {str(_truncate_response_content(resp))}"
148
191
 
149
192
 
150
193
  def request_post(
151
194
  url: str,
152
- data: T.Optional[T.Any] = None,
153
- json: T.Optional[dict] = None,
195
+ data: T.Any | None = None,
196
+ json: dict | None = None,
197
+ disable_debug=False,
154
198
  **kwargs,
155
199
  ) -> requests.Response:
156
200
  global USE_SYSTEM_CERTS
157
201
 
158
- _log_debug_request(
159
- "POST",
160
- url,
161
- json=json,
162
- params=kwargs.get("params"),
163
- headers=kwargs.get("headers"),
164
- timeout=kwargs.get("timeout"),
165
- )
202
+ if not disable_debug:
203
+ _log_debug_request(
204
+ "POST",
205
+ url,
206
+ json=json,
207
+ params=kwargs.get("params"),
208
+ headers=kwargs.get("headers"),
209
+ timeout=kwargs.get("timeout"),
210
+ )
166
211
 
167
212
  if USE_SYSTEM_CERTS:
168
213
  with requests.Session() as session:
@@ -182,25 +227,26 @@ def request_post(
182
227
  )
183
228
  return request_post(url, data=data, json=json, **kwargs)
184
229
 
185
- _log_debug_response(resp)
230
+ if not disable_debug:
231
+ _log_debug_response(resp)
186
232
 
187
233
  return resp
188
234
 
189
235
 
190
236
  def request_get(
191
- url: str,
192
- params: T.Optional[dict] = None,
193
- **kwargs,
237
+ url: str, params: dict | None = None, disable_debug=False, **kwargs
194
238
  ) -> requests.Response:
195
239
  global USE_SYSTEM_CERTS
196
240
 
197
- _log_debug_request(
198
- "GET",
199
- url,
200
- params=kwargs.get("params"),
201
- headers=kwargs.get("headers"),
202
- timeout=kwargs.get("timeout"),
203
- )
241
+ if not disable_debug:
242
+ _log_debug_request(
243
+ "GET",
244
+ url,
245
+ params=kwargs.get("params"),
246
+ headers=kwargs.get("headers"),
247
+ # Do not log timeout here as it's always set to REQUESTS_TIMEOUT
248
+ timeout=None,
249
+ )
204
250
 
205
251
  if USE_SYSTEM_CERTS:
206
252
  with requests.Session() as session:
@@ -219,11 +265,50 @@ def request_get(
219
265
  )
220
266
  resp = request_get(url, params=params, **kwargs)
221
267
 
222
- _log_debug_response(resp)
268
+ if not disable_debug:
269
+ _log_debug_response(resp)
223
270
 
224
271
  return resp
225
272
 
226
273
 
274
+ def is_auth_error(resp: requests.Response) -> bool:
275
+ if resp.status_code in [401, 403]:
276
+ return True
277
+
278
+ if resp.status_code in [400]:
279
+ try:
280
+ error_body = resp.json()
281
+ except Exception:
282
+ error_body = {}
283
+
284
+ type = error_body.get("debug_info", {}).get("type")
285
+ if type in ["NotAuthorizedError"]:
286
+ return True
287
+
288
+ return False
289
+
290
+
291
+ def extract_auth_error_message(resp: requests.Response) -> str:
292
+ assert is_auth_error(resp), "has to be an auth error"
293
+
294
+ try:
295
+ error_body = resp.json()
296
+ except Exception:
297
+ error_body = {}
298
+
299
+ # from Graph APIs
300
+ message = error_body.get("error", {}).get("message")
301
+ if message is not None:
302
+ return str(message)
303
+
304
+ # from upload service
305
+ message = error_body.get("debug_info", {}).get("message")
306
+ if message is not None:
307
+ return str(message)
308
+
309
+ return resp.text
310
+
311
+
227
312
  def get_upload_token(email: str, password: str) -> requests.Response:
228
313
  resp = request_post(
229
314
  f"{MAPILLARY_GRAPH_API_ENDPOINT}/login",
@@ -236,7 +321,7 @@ def get_upload_token(email: str, password: str) -> requests.Response:
236
321
 
237
322
 
238
323
  def fetch_organization(
239
- user_access_token: str, organization_id: T.Union[int, str]
324
+ user_access_token: str, organization_id: int | str
240
325
  ) -> requests.Response:
241
326
  resp = request_get(
242
327
  f"{MAPILLARY_GRAPH_API_ENDPOINT}/{organization_id}",
@@ -252,22 +337,80 @@ def fetch_organization(
252
337
  return resp
253
338
 
254
339
 
340
+ def fetch_user_or_me(
341
+ user_access_token: str, user_id: int | str | None = None
342
+ ) -> requests.Response:
343
+ if user_id is None:
344
+ url = f"{MAPILLARY_GRAPH_API_ENDPOINT}/me"
345
+ else:
346
+ url = f"{MAPILLARY_GRAPH_API_ENDPOINT}/{user_id}"
347
+
348
+ resp = request_get(
349
+ url,
350
+ params={
351
+ "fields": ",".join(["id", "username"]),
352
+ },
353
+ headers={
354
+ "Authorization": f"OAuth {user_access_token}",
355
+ },
356
+ timeout=REQUESTS_TIMEOUT,
357
+ )
358
+
359
+ resp.raise_for_status()
360
+ return resp
361
+
362
+
255
363
  ActionType = T.Literal[
256
364
  "upload_started_upload", "upload_finished_upload", "upload_failed_upload"
257
365
  ]
258
366
 
259
367
 
260
- def log_event(action_type: ActionType, properties: T.Dict) -> requests.Response:
368
+ def log_event(action_type: ActionType, properties: dict) -> requests.Response:
261
369
  resp = request_post(
262
370
  f"{MAPILLARY_GRAPH_API_ENDPOINT}/logging",
263
- json={
264
- "action_type": action_type,
265
- "properties": properties,
266
- },
371
+ json={"action_type": action_type, "properties": properties},
267
372
  headers={
268
373
  "Authorization": f"OAuth {MAPILLARY_CLIENT_TOKEN}",
269
374
  },
270
375
  timeout=REQUESTS_TIMEOUT,
376
+ disable_debug=True,
377
+ )
378
+ resp.raise_for_status()
379
+ return resp
380
+
381
+
382
+ def finish_upload(
383
+ user_access_token: str,
384
+ file_handle: str,
385
+ cluster_filetype: ClusterFileType,
386
+ organization_id: int | str | None = None,
387
+ ) -> requests.Response:
388
+ data: dict[str, str | int] = {
389
+ "file_handle": file_handle,
390
+ "file_type": cluster_filetype.value,
391
+ }
392
+ if organization_id is not None:
393
+ data["organization_id"] = organization_id
394
+
395
+ resp = request_post(
396
+ f"{MAPILLARY_GRAPH_API_ENDPOINT}/finish_upload",
397
+ headers={
398
+ "Authorization": f"OAuth {user_access_token}",
399
+ },
400
+ json=data,
401
+ timeout=REQUESTS_TIMEOUT,
271
402
  )
403
+
272
404
  resp.raise_for_status()
405
+
273
406
  return resp
407
+
408
+
409
+ def jsonify_response(resp: requests.Response) -> T.Any:
410
+ """
411
+ Convert the response to JSON, raising HTTPContentError if the response is not JSON.
412
+ """
413
+ try:
414
+ return resp.json()
415
+ except requests.JSONDecodeError as ex:
416
+ raise HTTPContentError("Invalid JSON response", resp) from ex