mapillary-tools 0.13.3__tar.gz → 0.14.0a1__tar.gz

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 (107) hide show
  1. {mapillary_tools-0.13.3/mapillary_tools.egg-info → mapillary_tools-0.14.0a1}/PKG-INFO +3 -2
  2. mapillary_tools-0.14.0a1/mapillary_tools/__init__.py +1 -0
  3. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/api_v4.py +106 -7
  4. mapillary_tools-0.14.0a1/mapillary_tools/authenticate.py +363 -0
  5. {mapillary_tools-0.13.3/mapillary_tools/geotag → mapillary_tools-0.14.0a1/mapillary_tools}/blackvue_parser.py +74 -54
  6. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/camm/camm_builder.py +55 -97
  7. mapillary_tools-0.14.0a1/mapillary_tools/camm/camm_parser.py +590 -0
  8. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/commands/__main__.py +2 -0
  9. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/commands/authenticate.py +8 -1
  10. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/commands/process.py +27 -51
  11. mapillary_tools-0.14.0a1/mapillary_tools/commands/process_and_upload.py +33 -0
  12. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/commands/sample_video.py +2 -3
  13. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/commands/upload.py +18 -9
  14. mapillary_tools-0.14.0a1/mapillary_tools/commands/video_process_and_upload.py +33 -0
  15. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/config.py +28 -12
  16. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/constants.py +46 -4
  17. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/exceptions.py +34 -35
  18. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/exif_read.py +158 -53
  19. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/exiftool_read.py +19 -5
  20. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/exiftool_read_video.py +12 -1
  21. mapillary_tools-0.14.0a1/mapillary_tools/exiftool_runner.py +77 -0
  22. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/geo.py +148 -107
  23. mapillary_tools-0.14.0a1/mapillary_tools/geotag/factory.py +298 -0
  24. mapillary_tools-0.14.0a1/mapillary_tools/geotag/geotag_from_generic.py +163 -0
  25. mapillary_tools-0.14.0a1/mapillary_tools/geotag/geotag_images_from_exif.py +60 -0
  26. mapillary_tools-0.14.0a1/mapillary_tools/geotag/geotag_images_from_exiftool.py +105 -0
  27. mapillary_tools-0.14.0a1/mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +77 -0
  28. mapillary_tools-0.14.0a1/mapillary_tools/geotag/geotag_images_from_gpx.py +150 -0
  29. mapillary_tools-0.14.0a1/mapillary_tools/geotag/geotag_images_from_gpx_file.py +72 -0
  30. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/geotag/geotag_images_from_nmea_file.py +4 -2
  31. mapillary_tools-0.14.0a1/mapillary_tools/geotag/geotag_images_from_video.py +90 -0
  32. mapillary_tools-0.14.0a1/mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +151 -0
  33. mapillary_tools-0.14.0a1/mapillary_tools/geotag/geotag_videos_from_gpx.py +140 -0
  34. mapillary_tools-0.14.0a1/mapillary_tools/geotag/geotag_videos_from_video.py +165 -0
  35. mapillary_tools-0.14.0a1/mapillary_tools/geotag/options.py +159 -0
  36. {mapillary_tools-0.13.3/mapillary_tools/geotag → mapillary_tools-0.14.0a1/mapillary_tools/gpmf}/gpmf_parser.py +194 -171
  37. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/history.py +3 -11
  38. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/mp4/io_utils.py +0 -1
  39. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/mp4/mp4_sample_parser.py +11 -3
  40. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/mp4/simple_mp4_parser.py +0 -10
  41. mapillary_tools-0.14.0a1/mapillary_tools/process_geotag_properties.py +417 -0
  42. mapillary_tools-0.14.0a1/mapillary_tools/process_sequence_properties.py +697 -0
  43. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/sample_video.py +8 -15
  44. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/telemetry.py +24 -12
  45. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/types.py +80 -22
  46. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/upload.py +311 -261
  47. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/upload_api_v4.py +55 -95
  48. mapillary_tools-0.14.0a1/mapillary_tools/uploader.py +581 -0
  49. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/utils.py +26 -0
  50. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/video_data_extraction/extract_video_data.py +17 -36
  51. mapillary_tools-0.14.0a1/mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +49 -0
  52. mapillary_tools-0.14.0a1/mapillary_tools/video_data_extraction/extractors/camm_parser.py +62 -0
  53. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +4 -1
  54. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +1 -2
  55. mapillary_tools-0.14.0a1/mapillary_tools/video_data_extraction/extractors/gopro_parser.py +58 -0
  56. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1/mapillary_tools.egg-info}/PKG-INFO +3 -2
  57. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools.egg-info/SOURCES.txt +8 -5
  58. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/setup.py +1 -0
  59. mapillary_tools-0.13.3/mapillary_tools/__init__.py +0 -1
  60. mapillary_tools-0.13.3/mapillary_tools/authenticate.py +0 -102
  61. mapillary_tools-0.13.3/mapillary_tools/camm/camm_parser.py +0 -342
  62. mapillary_tools-0.13.3/mapillary_tools/commands/process_and_upload.py +0 -20
  63. mapillary_tools-0.13.3/mapillary_tools/commands/video_process_and_upload.py +0 -19
  64. mapillary_tools-0.13.3/mapillary_tools/geotag/geotag_from_generic.py +0 -22
  65. mapillary_tools-0.13.3/mapillary_tools/geotag/geotag_images_from_exif.py +0 -141
  66. mapillary_tools-0.13.3/mapillary_tools/geotag/geotag_images_from_exiftool.py +0 -109
  67. mapillary_tools-0.13.3/mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +0 -93
  68. mapillary_tools-0.13.3/mapillary_tools/geotag/geotag_images_from_gpx.py +0 -225
  69. mapillary_tools-0.13.3/mapillary_tools/geotag/geotag_images_from_gpx_file.py +0 -153
  70. mapillary_tools-0.13.3/mapillary_tools/geotag/geotag_images_from_video.py +0 -90
  71. mapillary_tools-0.13.3/mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +0 -145
  72. mapillary_tools-0.13.3/mapillary_tools/geotag/geotag_videos_from_video.py +0 -197
  73. mapillary_tools-0.13.3/mapillary_tools/geotag/utils.py +0 -26
  74. mapillary_tools-0.13.3/mapillary_tools/process_geotag_properties.py +0 -652
  75. mapillary_tools-0.13.3/mapillary_tools/process_sequence_properties.py +0 -345
  76. mapillary_tools-0.13.3/mapillary_tools/uploader.py +0 -439
  77. mapillary_tools-0.13.3/mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +0 -34
  78. mapillary_tools-0.13.3/mapillary_tools/video_data_extraction/extractors/camm_parser.py +0 -38
  79. mapillary_tools-0.13.3/mapillary_tools/video_data_extraction/extractors/gopro_parser.py +0 -43
  80. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/LICENSE +0 -0
  81. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/MANIFEST.in +0 -0
  82. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/README.md +0 -0
  83. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/commands/__init__.py +0 -0
  84. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/commands/video_process.py +0 -0
  85. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/commands/zip.py +0 -0
  86. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/exif_write.py +0 -0
  87. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/ffmpeg.py +0 -0
  88. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/geotag/__init__.py +0 -0
  89. {mapillary_tools-0.13.3/mapillary_tools/geotag → mapillary_tools-0.14.0a1/mapillary_tools/gpmf}/gpmf_gps_filter.py +0 -0
  90. {mapillary_tools-0.13.3/mapillary_tools/geotag → mapillary_tools-0.14.0a1/mapillary_tools/gpmf}/gps_filter.py +0 -0
  91. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/ipc.py +0 -0
  92. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/mp4/__init__.py +0 -0
  93. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/mp4/construct_mp4_parser.py +0 -0
  94. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/mp4/simple_mp4_builder.py +0 -0
  95. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/video_data_extraction/cli_options.py +0 -0
  96. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/video_data_extraction/extractors/base_parser.py +0 -0
  97. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/video_data_extraction/extractors/generic_video_parser.py +0 -0
  98. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/video_data_extraction/extractors/gpx_parser.py +0 -0
  99. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/video_data_extraction/extractors/nmea_parser.py +0 -0
  100. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools/video_data_extraction/video_data_parser_factory.py +0 -0
  101. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools.egg-info/dependency_links.txt +0 -0
  102. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools.egg-info/entry_points.txt +0 -0
  103. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools.egg-info/requires.txt +0 -0
  104. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/mapillary_tools.egg-info/top_level.txt +0 -0
  105. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/requirements.txt +0 -0
  106. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/schema/image_description_schema.json +0 -0
  107. {mapillary_tools-0.13.3 → mapillary_tools-0.14.0a1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: mapillary_tools
3
- Version: 0.13.3
3
+ Version: 0.14.0a1
4
4
  Summary: Mapillary Image/Video Import Pipeline
5
5
  Home-page: https://github.com/mapillary/mapillary_tools
6
6
  Author: Mapillary
@@ -23,6 +23,7 @@ Dynamic: description
23
23
  Dynamic: description-content-type
24
24
  Dynamic: home-page
25
25
  Dynamic: license
26
+ Dynamic: license-file
26
27
  Dynamic: requires-dist
27
28
  Dynamic: requires-python
28
29
  Dynamic: summary
@@ -0,0 +1 @@
1
+ VERSION = "0.14.0a1"
@@ -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,12 @@ REQUESTS_TIMEOUT = 60 # 1 minutes
18
21
  USE_SYSTEM_CERTS: bool = False
19
22
 
20
23
 
24
+ class ClusterFileType(enum.Enum):
25
+ ZIP = "zip"
26
+ BLACKVUE = "mly_blackvue_video"
27
+ CAMM = "mly_camm_video"
28
+
29
+
21
30
  class HTTPSystemCertsAdapter(HTTPAdapter):
22
31
  """
23
32
  This adapter uses the system's certificate store instead of the certifi module.
@@ -70,7 +79,7 @@ def _truncate(s, limit=512):
70
79
  return s
71
80
 
72
81
 
73
- def _sanitize(headers: T.Dict):
82
+ def _sanitize(headers: T.Mapping[T.Any, T.Any]) -> T.Mapping[T.Any, T.Any]:
74
83
  new_headers = {}
75
84
 
76
85
  for k, v in headers.items():
@@ -81,6 +90,7 @@ def _sanitize(headers: T.Dict):
81
90
  "access-token",
82
91
  "access_token",
83
92
  "password",
93
+ "user_upload_token",
84
94
  ]:
85
95
  new_headers[k] = "[REDACTED]"
86
96
  else:
@@ -92,9 +102,9 @@ def _sanitize(headers: T.Dict):
92
102
  def _log_debug_request(
93
103
  method: str,
94
104
  url: str,
95
- json: T.Optional[T.Dict] = None,
96
- params: T.Optional[T.Dict] = None,
97
- headers: T.Optional[T.Dict] = None,
105
+ json: dict | None = None,
106
+ params: dict | None = None,
107
+ headers: dict | None = None,
98
108
  timeout: T.Any = None,
99
109
  ):
100
110
  if logging.getLogger().getEffectiveLevel() <= logging.DEBUG:
@@ -149,8 +159,8 @@ def readable_http_error(ex: requests.HTTPError) -> str:
149
159
 
150
160
  def request_post(
151
161
  url: str,
152
- data: T.Optional[T.Any] = None,
153
- json: T.Optional[dict] = None,
162
+ data: T.Any | None = None,
163
+ json: dict | None = None,
154
164
  **kwargs,
155
165
  ) -> requests.Response:
156
166
  global USE_SYSTEM_CERTS
@@ -189,7 +199,7 @@ def request_post(
189
199
 
190
200
  def request_get(
191
201
  url: str,
192
- params: T.Optional[dict] = None,
202
+ params: dict | None = None,
193
203
  **kwargs,
194
204
  ) -> requests.Response:
195
205
  global USE_SYSTEM_CERTS
@@ -224,6 +234,44 @@ def request_get(
224
234
  return resp
225
235
 
226
236
 
237
+ def is_auth_error(resp: requests.Response) -> bool:
238
+ if resp.status_code in [401, 403]:
239
+ return True
240
+
241
+ if resp.status_code in [400]:
242
+ try:
243
+ error_body = resp.json()
244
+ except Exception:
245
+ error_body = {}
246
+
247
+ type = error_body.get("debug_info", {}).get("type")
248
+ if type in ["NotAuthorizedError"]:
249
+ return True
250
+
251
+ return False
252
+
253
+
254
+ def extract_auth_error_message(resp: requests.Response) -> str:
255
+ assert is_auth_error(resp), "has to be an auth error"
256
+
257
+ try:
258
+ error_body = resp.json()
259
+ except Exception:
260
+ error_body = {}
261
+
262
+ # from Graph APIs
263
+ message = error_body.get("error", {}).get("message")
264
+ if message is not None:
265
+ return str(message)
266
+
267
+ # from upload service
268
+ message = error_body.get("debug_info", {}).get("message")
269
+ if message is not None:
270
+ return str(message)
271
+
272
+ return resp.text
273
+
274
+
227
275
  def get_upload_token(email: str, password: str) -> requests.Response:
228
276
  resp = request_post(
229
277
  f"{MAPILLARY_GRAPH_API_ENDPOINT}/login",
@@ -252,6 +300,30 @@ def fetch_organization(
252
300
  return resp
253
301
 
254
302
 
303
+ def fetch_user_or_me(
304
+ user_access_token: str,
305
+ user_id: int | str | None = None,
306
+ ) -> requests.Response:
307
+ if user_id is None:
308
+ url = f"{MAPILLARY_GRAPH_API_ENDPOINT}/me"
309
+ else:
310
+ url = f"{MAPILLARY_GRAPH_API_ENDPOINT}/{user_id}"
311
+
312
+ resp = request_get(
313
+ url,
314
+ params={
315
+ "fields": ",".join(["id", "username"]),
316
+ },
317
+ headers={
318
+ "Authorization": f"OAuth {user_access_token}",
319
+ },
320
+ timeout=REQUESTS_TIMEOUT,
321
+ )
322
+
323
+ resp.raise_for_status()
324
+ return resp
325
+
326
+
255
327
  ActionType = T.Literal[
256
328
  "upload_started_upload", "upload_finished_upload", "upload_failed_upload"
257
329
  ]
@@ -271,3 +343,30 @@ def log_event(action_type: ActionType, properties: T.Dict) -> requests.Response:
271
343
  )
272
344
  resp.raise_for_status()
273
345
  return resp
346
+
347
+
348
+ def finish_upload(
349
+ user_access_token: str,
350
+ file_handle: str,
351
+ cluster_filetype: ClusterFileType,
352
+ organization_id: int | str | None = None,
353
+ ) -> requests.Response:
354
+ data: dict[str, str | int] = {
355
+ "file_handle": file_handle,
356
+ "file_type": cluster_filetype.value,
357
+ }
358
+ if organization_id is not None:
359
+ data["organization_id"] = organization_id
360
+
361
+ resp = request_post(
362
+ f"{MAPILLARY_GRAPH_API_ENDPOINT}/finish_upload",
363
+ headers={
364
+ "Authorization": f"OAuth {user_access_token}",
365
+ },
366
+ json=data,
367
+ timeout=REQUESTS_TIMEOUT,
368
+ )
369
+
370
+ resp.raise_for_status()
371
+
372
+ return resp
@@ -0,0 +1,363 @@
1
+ from __future__ import annotations
2
+
3
+ import getpass
4
+ import json
5
+ import logging
6
+ import re
7
+ import sys
8
+ import typing as T
9
+
10
+ import jsonschema
11
+
12
+ import requests
13
+
14
+ from . import api_v4, config, constants, exceptions, types
15
+
16
+
17
+ LOG = logging.getLogger(__name__)
18
+
19
+
20
+ def authenticate(
21
+ user_name: str | None = None,
22
+ user_email: str | None = None,
23
+ user_password: str | None = None,
24
+ jwt: str | None = None,
25
+ delete: bool = False,
26
+ ):
27
+ """
28
+ Prompt for authentication information and save it to the config file
29
+ """
30
+
31
+ # We still have to accept --user_name for the back compatibility
32
+ profile_name = user_name
33
+
34
+ all_user_items = config.list_all_users()
35
+ if all_user_items:
36
+ _list_all_profiles(all_user_items)
37
+ else:
38
+ _welcome()
39
+
40
+ # Make sure profile name either validated or existed
41
+ if profile_name is not None:
42
+ profile_name = profile_name.strip()
43
+ else:
44
+ if not _prompt_enabled():
45
+ raise exceptions.MapillaryBadParameterError(
46
+ "Profile name is required, please specify one with --user_name"
47
+ )
48
+ profile_name = _prompt_choose_profile_name(
49
+ list(all_user_items.keys()), must_exist=delete
50
+ )
51
+
52
+ assert profile_name is not None, "profile_name should be set"
53
+
54
+ if delete:
55
+ config.remove_config(profile_name)
56
+ LOG.info('Profile "%s" deleted', profile_name)
57
+ else:
58
+ if profile_name in all_user_items:
59
+ LOG.warning(
60
+ 'The profile "%s" already exists and will be overridden',
61
+ profile_name,
62
+ )
63
+ else:
64
+ LOG.info('Creating new profile: "%s"', profile_name)
65
+
66
+ if jwt:
67
+ user_items: types.UserItem = {"user_upload_token": jwt}
68
+ user_items = _verify_user_auth(_validate_profile(user_items))
69
+ else:
70
+ user_items = _prompt_login(
71
+ user_email=user_email, user_password=user_password
72
+ )
73
+ _validate_profile(user_items)
74
+
75
+ # Update the config with the new user items
76
+ config.update_config(profile_name, user_items)
77
+
78
+ # TODO: print more user information
79
+ if profile_name in all_user_items:
80
+ LOG.info(
81
+ 'Profile "%s" updated: %s', profile_name, api_v4._sanitize(user_items)
82
+ )
83
+ else:
84
+ LOG.info(
85
+ 'Profile "%s" created: %s', profile_name, api_v4._sanitize(user_items)
86
+ )
87
+
88
+
89
+ def fetch_user_items(
90
+ user_name: str | None = None,
91
+ organization_key: str | None = None,
92
+ ) -> types.UserItem:
93
+ """
94
+ Read user information from the config file,
95
+ or prompt the user to authenticate if the specified profile does not exist
96
+ """
97
+
98
+ # we still have to accept --user_name for the back compatibility
99
+ profile_name = user_name
100
+
101
+ all_user_items = config.list_all_users()
102
+ if not all_user_items:
103
+ authenticate(user_name=profile_name)
104
+
105
+ # Fetch user information only here
106
+ all_user_items = config.list_all_users()
107
+ assert len(all_user_items) >= 1, "should have at least 1 profile"
108
+ if profile_name is None:
109
+ if len(all_user_items) > 1:
110
+ if not _prompt_enabled():
111
+ raise exceptions.MapillaryBadParameterError(
112
+ "Multiple user profiles found, please choose one with --user_name"
113
+ )
114
+ _list_all_profiles(all_user_items)
115
+ profile_name = _prompt_choose_profile_name(
116
+ list(all_user_items.keys()), must_exist=True
117
+ )
118
+ user_items = all_user_items[profile_name]
119
+ else:
120
+ profile_name, user_items = list(all_user_items.items())[0]
121
+ else:
122
+ if profile_name in all_user_items:
123
+ user_items = all_user_items[profile_name]
124
+ else:
125
+ _list_all_profiles(all_user_items)
126
+ raise exceptions.MapillaryBadParameterError(
127
+ f'Profile "{profile_name}" not found'
128
+ )
129
+
130
+ assert profile_name is not None, "profile_name should be set"
131
+
132
+ user_items = _verify_user_auth(_validate_profile(user_items))
133
+
134
+ LOG.info(
135
+ 'Uploading to profile "%s": %s', profile_name, api_v4._sanitize(user_items)
136
+ )
137
+
138
+ if organization_key is not None:
139
+ resp = api_v4.fetch_organization(
140
+ user_items["user_upload_token"], organization_key
141
+ )
142
+ LOG.info("Uploading to Mapillary organization: %s", json.dumps(resp.json()))
143
+ user_items["MAPOrganizationKey"] = organization_key
144
+
145
+ return user_items
146
+
147
+
148
+ def _echo(*args, **kwargs):
149
+ print(*args, **kwargs, file=sys.stderr)
150
+
151
+
152
+ def _prompt(message: str) -> str:
153
+ """Display prompt on stderr and get input from stdin"""
154
+ print(message, end="", file=sys.stderr, flush=True)
155
+ return input()
156
+
157
+
158
+ def _validate_profile(user_items: types.UserItem) -> types.UserItem:
159
+ try:
160
+ jsonschema.validate(user_items, types.UserItemSchema)
161
+ except jsonschema.ValidationError as ex:
162
+ raise exceptions.MapillaryBadParameterError(
163
+ f"Invalid profile format: {ex.message}"
164
+ )
165
+ return user_items
166
+
167
+
168
+ def _verify_user_auth(user_items: types.UserItem) -> types.UserItem:
169
+ """
170
+ Verify that the user access token is valid
171
+ """
172
+ if constants._AUTH_VERIFICATION_DISABLED:
173
+ return user_items
174
+
175
+ try:
176
+ resp = api_v4.fetch_user_or_me(
177
+ user_access_token=user_items["user_upload_token"]
178
+ )
179
+ except requests.HTTPError as ex:
180
+ if api_v4.is_auth_error(ex.response):
181
+ message = api_v4.extract_auth_error_message(ex.response)
182
+ raise exceptions.MapillaryUploadUnauthorizedError(message)
183
+ else:
184
+ raise ex
185
+
186
+ user_json = resp.json()
187
+
188
+ return {
189
+ **user_items,
190
+ "MAPSettingsUsername": user_json.get("username"),
191
+ "MAPSettingsUserKey": user_json.get("id"),
192
+ }
193
+
194
+
195
+ def _validate_profile_name(profile_name: str):
196
+ if not (2 <= len(profile_name) <= 32):
197
+ raise exceptions.MapillaryBadParameterError(
198
+ "Profile name must be between 2 and 32 characters long"
199
+ )
200
+
201
+ pattern = re.compile(r"^[a-zA-Z]+[a-zA-Z0-9_-]*$")
202
+ if not bool(pattern.match(profile_name)):
203
+ raise exceptions.MapillaryBadParameterError(
204
+ "Invalid profile name. Use only letters, numbers, hyphens and underscores"
205
+ )
206
+
207
+
208
+ def _list_all_profiles(profiles: dict[str, types.UserItem]) -> None:
209
+ _echo("Existing Mapillary profiles:")
210
+
211
+ # Header
212
+ _echo(f"{'':>5} {'Profile name':<32} {'User ID':>16} {'Username':>32}")
213
+
214
+ # List all profiles
215
+ for idx, name in enumerate(profiles, 1):
216
+ items = profiles[name]
217
+ user_id = items.get("MAPSettingsUserKey", "N/A")
218
+ username = items.get("MAPSettingsUsername", "N/A")
219
+ _echo(f"{idx:>5}. {name:<32} {user_id:>16} {username:>32}")
220
+
221
+
222
+ def _is_interactive():
223
+ # Check if stdout is connected to a terminal
224
+ stdout_interactive = sys.stdout.isatty() if hasattr(sys.stdout, "isatty") else False
225
+
226
+ # Optionally, also check stdin and stderr
227
+ stdin_interactive = sys.stdin.isatty() if hasattr(sys.stdin, "isatty") else False
228
+ stderr_interactive = sys.stderr.isatty() if hasattr(sys.stderr, "isatty") else False
229
+
230
+ # Return True if any stream is interactive
231
+ return stdout_interactive or stdin_interactive or stderr_interactive
232
+
233
+
234
+ def _prompt_enabled() -> bool:
235
+ if constants.PROMPT_DISABLED:
236
+ return False
237
+
238
+ if not _is_interactive():
239
+ return False
240
+
241
+ return True
242
+
243
+
244
+ def _is_login_retryable(ex: requests.HTTPError) -> bool:
245
+ if 400 <= ex.response.status_code < 500:
246
+ r = ex.response.json()
247
+ subcode = r.get("error", {}).get("error_subcode")
248
+ if subcode in [1348028, 1348092, 3404005, 1348131]:
249
+ title = r.get("error", {}).get("error_user_title")
250
+ message = r.get("error", {}).get("error_user_msg")
251
+ LOG.error("%s: %s", title, message)
252
+ return True
253
+ return False
254
+
255
+
256
+ def _prompt_login(
257
+ user_email: str | None = None,
258
+ user_password: str | None = None,
259
+ ) -> types.UserItem:
260
+ _enabled = _prompt_enabled()
261
+
262
+ if user_email is None:
263
+ if not _enabled:
264
+ raise exceptions.MapillaryBadParameterError("user_email is required")
265
+ while not user_email:
266
+ user_email = _prompt("Enter Mapillary user email: ").strip()
267
+ else:
268
+ user_email = user_email.strip()
269
+
270
+ if user_password is None:
271
+ if not _enabled:
272
+ raise exceptions.MapillaryBadParameterError("user_password is required")
273
+ while True:
274
+ user_password = getpass.getpass("Enter Mapillary user password: ")
275
+ if user_password:
276
+ break
277
+
278
+ try:
279
+ resp = api_v4.get_upload_token(user_email, user_password)
280
+ except requests.HTTPError as ex:
281
+ if not _enabled:
282
+ raise ex
283
+
284
+ if _is_login_retryable(ex):
285
+ return _prompt_login()
286
+
287
+ raise ex
288
+
289
+ data = resp.json()
290
+
291
+ user_items: types.UserItem = {
292
+ "user_upload_token": str(data["access_token"]),
293
+ "MAPSettingsUserKey": str(data["user_id"]),
294
+ }
295
+
296
+ return user_items
297
+
298
+
299
+ def _prompt_choose_profile_name(
300
+ existing_profile_names: T.Sequence[str], must_exist: bool = False
301
+ ) -> str:
302
+ assert _prompt_enabled(), "should not get here if prompting is disabled"
303
+
304
+ existed = set(existing_profile_names)
305
+
306
+ while True:
307
+ if must_exist:
308
+ prompt = "Enter an existing profile: "
309
+ else:
310
+ prompt = "Enter an existing profile or create a new one: "
311
+
312
+ profile_name = _prompt(prompt).strip()
313
+
314
+ if not profile_name:
315
+ continue
316
+
317
+ # Exit if it's found
318
+ if profile_name in existed:
319
+ break
320
+
321
+ # Try to find by index
322
+ try:
323
+ profile_name = existing_profile_names[int(profile_name) - 1]
324
+ except (ValueError, IndexError):
325
+ pass
326
+ else:
327
+ # Exit if it's found
328
+ break
329
+
330
+ assert profile_name not in existed, (
331
+ f"Profile {profile_name} must not exist here"
332
+ )
333
+
334
+ if must_exist:
335
+ LOG.error('Profile "%s" not found', profile_name)
336
+ else:
337
+ try:
338
+ _validate_profile_name(profile_name)
339
+ except exceptions.MapillaryBadParameterError as ex:
340
+ LOG.error("Error validating profile name: %s", ex)
341
+ profile_name = ""
342
+ else:
343
+ break
344
+
345
+ if must_exist:
346
+ assert profile_name in existed, f"Profile {profile_name} must exist"
347
+
348
+ return profile_name
349
+
350
+
351
+ def _welcome():
352
+ _echo(
353
+ """
354
+ ================================================================================
355
+ Welcome to Mapillary!
356
+ ================================================================================
357
+ If you haven't registered yet, please visit https://www.mapillary.com/signup
358
+ to create your account first.
359
+
360
+ Once registered, proceed here to sign in.
361
+ ================================================================================
362
+ """
363
+ )