mapillary-tools 0.13.3a1__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 +287 -22
  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 +17 -8
  8. mapillary_tools/commands/authenticate.py +8 -1
  9. mapillary_tools/commands/process.py +27 -51
  10. mapillary_tools/commands/process_and_upload.py +19 -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 +408 -416
  61. mapillary_tools/upload_api_v4.py +172 -174
  62. mapillary_tools/uploader.py +804 -284
  63. mapillary_tools/utils.py +49 -18
  64. {mapillary_tools-0.13.3a1.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.3a1.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.3a1.dist-info/RECORD +0 -75
  84. /mapillary_tools/{geotag → gpmf}/gpmf_gps_filter.py +0 -0
  85. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0.dist-info}/entry_points.txt +0 -0
  86. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0.dist-info/licenses}/LICENSE +0 -0
  87. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0.dist-info}/top_level.txt +0 -0
@@ -1 +1 @@
1
- VERSION = "0.13.3a1"
1
+ VERSION = "0.14.0"
mapillary_tools/api_v4.py CHANGED
@@ -1,7 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ import enum
1
4
  import logging
2
5
  import os
3
6
  import ssl
4
7
  import typing as T
8
+ from json import dumps
5
9
 
6
10
  import requests
7
11
  from requests.adapters import HTTPAdapter
@@ -17,6 +21,24 @@ REQUESTS_TIMEOUT = 60 # 1 minutes
17
21
  USE_SYSTEM_CERTS: bool = False
18
22
 
19
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
+
20
42
  class HTTPSystemCertsAdapter(HTTPAdapter):
21
43
  """
22
44
  This adapter uses the system's certificate store instead of the certifi module.
@@ -46,22 +68,155 @@ class HTTPSystemCertsAdapter(HTTPAdapter):
46
68
  conn.ca_certs = None
47
69
 
48
70
 
71
+ @T.overload
72
+ def _truncate(s: bytes, limit: int = 256) -> bytes | str: ...
73
+
74
+
75
+ @T.overload
76
+ def _truncate(s: str, limit: int = 256) -> str: ...
77
+
78
+
79
+ def _truncate(s, limit=256):
80
+ if limit < len(s):
81
+ if isinstance(s, bytes):
82
+ try:
83
+ s = s.decode("utf-8")
84
+ except UnicodeDecodeError:
85
+ pass
86
+ remaining = len(s) - limit
87
+ if isinstance(s, bytes):
88
+ return s[:limit] + f"...({remaining} bytes truncated)".encode("utf-8")
89
+ else:
90
+ return str(s[:limit]) + f"...({remaining} chars truncated)"
91
+ else:
92
+ return s
93
+
94
+
95
+ def _sanitize(headers: T.Mapping[T.Any, T.Any]) -> T.Mapping[T.Any, T.Any]:
96
+ new_headers = {}
97
+
98
+ for k, v in headers.items():
99
+ if k.lower() in [
100
+ "authorization",
101
+ "cookie",
102
+ "x-fb-access-token",
103
+ "access-token",
104
+ "access_token",
105
+ "password",
106
+ "user_upload_token",
107
+ ]:
108
+ new_headers[k] = "[REDACTED]"
109
+ else:
110
+ if isinstance(v, (str, bytes)):
111
+ new_headers[k] = T.cast(T.Any, _truncate(v))
112
+ else:
113
+ new_headers[k] = v
114
+
115
+ return new_headers
116
+
117
+
118
+ def _log_debug_request(
119
+ method: str,
120
+ url: str,
121
+ json: dict | None = None,
122
+ params: dict | None = None,
123
+ headers: dict | None = None,
124
+ timeout: T.Any = None,
125
+ ):
126
+ if logging.getLogger().getEffectiveLevel() <= logging.DEBUG:
127
+ return
128
+
129
+ msg = f"HTTP {method} {url}"
130
+
131
+ if USE_SYSTEM_CERTS:
132
+ msg += " (w/sys_certs)"
133
+
134
+ if json:
135
+ t = _truncate(dumps(_sanitize(json)))
136
+ msg += f" JSON={t}"
137
+
138
+ if params:
139
+ msg += f" PARAMS={_sanitize(params)}"
140
+
141
+ if headers:
142
+ msg += f" HEADERS={_sanitize(headers)}"
143
+
144
+ if timeout is not None:
145
+ msg += f" TIMEOUT={timeout}"
146
+
147
+ msg = msg.replace("\n", "\\n")
148
+
149
+ LOG.debug(msg)
150
+
151
+
152
+ def _log_debug_response(resp: requests.Response):
153
+ if logging.getLogger().getEffectiveLevel() <= logging.DEBUG:
154
+ return
155
+
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:
163
+ try:
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")
178
+
179
+ elif isinstance(data, str):
180
+ return data.replace("\n", "\\n")
181
+
182
+ return data
183
+
184
+
185
+ def readable_http_error(ex: requests.HTTPError) -> str:
186
+ return readable_http_response(ex.response)
187
+
188
+
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))}"
191
+
192
+
49
193
  def request_post(
50
194
  url: str,
51
- data: T.Optional[T.Any] = None,
52
- json: T.Optional[dict] = None,
195
+ data: T.Any | None = None,
196
+ json: dict | None = None,
197
+ disable_debug=False,
53
198
  **kwargs,
54
199
  ) -> requests.Response:
55
200
  global USE_SYSTEM_CERTS
56
201
 
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
+ )
211
+
57
212
  if USE_SYSTEM_CERTS:
58
213
  with requests.Session() as session:
59
214
  session.mount("https://", HTTPSystemCertsAdapter())
60
- return session.post(url, data=data, json=json, **kwargs)
215
+ resp = session.post(url, data=data, json=json, **kwargs)
61
216
 
62
217
  else:
63
218
  try:
64
- return requests.post(url, data=data, json=json, **kwargs)
219
+ resp = requests.post(url, data=data, json=json, **kwargs)
65
220
  except requests.exceptions.SSLError as ex:
66
221
  if "SSLCertVerificationError" not in str(ex):
67
222
  raise ex
@@ -70,25 +225,36 @@ def request_post(
70
225
  LOG.warning(
71
226
  "SSL error occurred, falling back to system SSL certificates: %s", ex
72
227
  )
73
- with requests.Session() as session:
74
- session.mount("https://", HTTPSystemCertsAdapter())
75
- return session.post(url, data=data, json=json, **kwargs)
228
+ return request_post(url, data=data, json=json, **kwargs)
229
+
230
+ if not disable_debug:
231
+ _log_debug_response(resp)
232
+
233
+ return resp
76
234
 
77
235
 
78
236
  def request_get(
79
- url: str,
80
- params: T.Optional[dict] = None,
81
- **kwargs,
237
+ url: str, params: dict | None = None, disable_debug=False, **kwargs
82
238
  ) -> requests.Response:
83
239
  global USE_SYSTEM_CERTS
84
240
 
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
+ )
250
+
85
251
  if USE_SYSTEM_CERTS:
86
252
  with requests.Session() as session:
87
253
  session.mount("https://", HTTPSystemCertsAdapter())
88
- return session.get(url, params=params, **kwargs)
254
+ resp = session.get(url, params=params, **kwargs)
89
255
  else:
90
256
  try:
91
- return requests.get(url, params=params, **kwargs)
257
+ resp = requests.get(url, params=params, **kwargs)
92
258
  except requests.exceptions.SSLError as ex:
93
259
  if "SSLCertVerificationError" not in str(ex):
94
260
  raise ex
@@ -97,15 +263,56 @@ def request_get(
97
263
  LOG.warning(
98
264
  "SSL error occurred, falling back to system SSL certificates: %s", ex
99
265
  )
100
- with requests.Session() as session:
101
- session.mount("https://", HTTPSystemCertsAdapter())
102
- return session.get(url, params=params, **kwargs)
266
+ resp = request_get(url, params=params, **kwargs)
267
+
268
+ if not disable_debug:
269
+ _log_debug_response(resp)
270
+
271
+ return resp
272
+
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
103
310
 
104
311
 
105
312
  def get_upload_token(email: str, password: str) -> requests.Response:
106
313
  resp = request_post(
107
314
  f"{MAPILLARY_GRAPH_API_ENDPOINT}/login",
108
- params={"access_token": MAPILLARY_CLIENT_TOKEN},
315
+ headers={"Authorization": f"OAuth {MAPILLARY_CLIENT_TOKEN}"},
109
316
  json={"email": email, "password": password, "locale": "en_US"},
110
317
  timeout=REQUESTS_TIMEOUT,
111
318
  )
@@ -114,7 +321,7 @@ def get_upload_token(email: str, password: str) -> requests.Response:
114
321
 
115
322
 
116
323
  def fetch_organization(
117
- user_access_token: str, organization_id: T.Union[int, str]
324
+ user_access_token: str, organization_id: int | str
118
325
  ) -> requests.Response:
119
326
  resp = request_get(
120
327
  f"{MAPILLARY_GRAPH_API_ENDPOINT}/{organization_id}",
@@ -130,22 +337,80 @@ def fetch_organization(
130
337
  return resp
131
338
 
132
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
+
133
363
  ActionType = T.Literal[
134
364
  "upload_started_upload", "upload_finished_upload", "upload_failed_upload"
135
365
  ]
136
366
 
137
367
 
138
- def log_event(action_type: ActionType, properties: T.Dict) -> requests.Response:
368
+ def log_event(action_type: ActionType, properties: dict) -> requests.Response:
139
369
  resp = request_post(
140
370
  f"{MAPILLARY_GRAPH_API_ENDPOINT}/logging",
141
- json={
142
- "action_type": action_type,
143
- "properties": properties,
144
- },
371
+ json={"action_type": action_type, "properties": properties},
145
372
  headers={
146
373
  "Authorization": f"OAuth {MAPILLARY_CLIENT_TOKEN}",
147
374
  },
148
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,
149
402
  )
403
+
150
404
  resp.raise_for_status()
405
+
151
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