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
@@ -2,10 +2,12 @@ import argparse
2
2
  import enum
3
3
  import logging
4
4
  import sys
5
- import typing as T
6
5
  from pathlib import Path
7
6
 
8
- from .. import constants, exceptions, VERSION
7
+ import requests
8
+
9
+ from .. import api_v4, constants, exceptions, VERSION
10
+ from ..upload import log_exception
9
11
  from . import (
10
12
  authenticate,
11
13
  process,
@@ -84,7 +86,7 @@ def configure_logger(logger: logging.Logger, stream=None) -> None:
84
86
  logger.addHandler(handler)
85
87
 
86
88
 
87
- def _log_params(argvars: T.Dict) -> None:
89
+ def _log_params(argvars: dict) -> None:
88
90
  MAX_ENTRIES = 5
89
91
 
90
92
  def _stringify(x) -> str:
@@ -160,11 +162,18 @@ def main():
160
162
 
161
163
  try:
162
164
  args.func(argvars)
163
- except exceptions.MapillaryUserError as exc:
164
- LOG.error(
165
- "%s: %s", exc.__class__.__name__, exc, exc_info=log_level == logging.DEBUG
166
- )
167
- sys.exit(exc.exit_code)
165
+ except requests.HTTPError as ex:
166
+ log_exception(ex)
167
+ # TODO: standardize exit codes as exceptions.MapillaryUserError
168
+ sys.exit(16)
169
+
170
+ except api_v4.HTTPContentError as ex:
171
+ log_exception(ex)
172
+ sys.exit(17)
173
+
174
+ except exceptions.MapillaryUserError as ex:
175
+ log_exception(ex)
176
+ sys.exit(ex.exit_code)
168
177
 
169
178
 
170
179
  if __name__ == "__main__":
@@ -10,7 +10,7 @@ class Command:
10
10
 
11
11
  def add_basic_arguments(self, parser: argparse.ArgumentParser):
12
12
  parser.add_argument(
13
- "--user_name", help="Mapillary user name", default=None, required=False
13
+ "--user_name", help="Mapillary user profile", default=None, required=False
14
14
  )
15
15
  parser.add_argument(
16
16
  "--user_email",
@@ -27,6 +27,13 @@ class Command:
27
27
  parser.add_argument(
28
28
  "--jwt", help="Mapillary user access token", default=None, required=False
29
29
  )
30
+ parser.add_argument(
31
+ "--delete",
32
+ help="Delete the specified user profile",
33
+ default=False,
34
+ required=False,
35
+ action="store_true",
36
+ )
30
37
 
31
38
  def run(self, vars_args: dict):
32
39
  authenticate(
@@ -1,41 +1,37 @@
1
+ from __future__ import annotations
2
+
1
3
  import argparse
2
4
  import inspect
3
- import typing as T
4
5
  from pathlib import Path
5
6
 
6
- from .. import constants
7
+ from .. import constants, types
7
8
  from ..process_geotag_properties import (
8
- FileType,
9
- GeotagSource,
9
+ DEFAULT_GEOTAG_SOURCE_OPTIONS,
10
10
  process_finalize,
11
11
  process_geotag_properties,
12
+ SourceType,
12
13
  )
13
14
  from ..process_sequence_properties import process_sequence_properties
14
15
 
15
16
 
17
+ def bold_text(text: str) -> str:
18
+ ANSI_BOLD = "\033[1m"
19
+ ANSI_RESET_ALL = "\033[0m"
20
+ return f"{ANSI_BOLD}{text}{ANSI_RESET_ALL}"
21
+
22
+
16
23
  class Command:
17
24
  name = "process"
18
25
  help = "process images and videos"
19
26
 
20
27
  def add_basic_arguments(self, parser: argparse.ArgumentParser):
21
- geotag_sources: T.List[GeotagSource] = [
22
- "blackvue_videos",
23
- "camm",
24
- "exif",
25
- "exiftool",
26
- "gopro_videos",
27
- "gpx",
28
- "nmea",
28
+ geotag_gpx_based_sources: list[str] = [
29
+ SourceType.GPX.value,
30
+ SourceType.NMEA.value,
31
+ SourceType.GOPRO.value,
32
+ SourceType.BLACKVUE.value,
33
+ SourceType.CAMM.value,
29
34
  ]
30
- geotag_gpx_based_sources: T.List[GeotagSource] = [
31
- "gpx",
32
- "gopro_videos",
33
- "nmea",
34
- "blackvue_videos",
35
- "camm",
36
- ]
37
- for source in geotag_gpx_based_sources:
38
- assert source in geotag_sources
39
35
 
40
36
  parser.add_argument(
41
37
  "--skip_process_errors",
@@ -47,14 +43,12 @@ class Command:
47
43
  parser.add_argument(
48
44
  "--filetypes",
49
45
  "--file_types",
50
- help=f"Process files of the specified types only. Supported file types: {','.join(sorted(t.value for t in FileType))} [default: %(default)s]",
51
- type=lambda option: set(FileType(t) for t in option.split(",")),
52
- default=",".join(sorted(t.value for t in FileType)),
46
+ help=f"Process files of the specified types only. Supported file types: {','.join(sorted(t.value for t in types.FileType))} [default: %(default)s]",
47
+ type=lambda option: set(types.FileType(t) for t in option.split(",")),
48
+ default=None,
53
49
  required=False,
54
50
  )
55
- group = parser.add_argument_group(
56
- f"{constants.ANSI_BOLD}PROCESS EXIF OPTIONS{constants.ANSI_RESET_ALL}"
57
- )
51
+ group = parser.add_argument_group(bold_text("PROCESS EXIF OPTIONS"))
58
52
  group.add_argument(
59
53
  "--overwrite_all_EXIF_tags",
60
54
  help="Overwrite all of the relevant EXIF tags with the values obtained in process. It is equivalent to supplying all the --overwrite_EXIF_*_tag flags.",
@@ -92,7 +86,7 @@ class Command:
92
86
  )
93
87
 
94
88
  group_metadata = parser.add_argument_group(
95
- f"{constants.ANSI_BOLD}PROCESS METADATA OPTIONS{constants.ANSI_RESET_ALL}"
89
+ bold_text("PROCESS METADATA OPTIONS")
96
90
  )
97
91
  group_metadata.add_argument(
98
92
  "--device_make",
@@ -108,7 +102,7 @@ class Command:
108
102
  )
109
103
 
110
104
  group_geotagging = parser.add_argument_group(
111
- f"{constants.ANSI_BOLD}PROCESS GEOTAGGING OPTIONS{constants.ANSI_RESET_ALL}"
105
+ bold_text("PROCESS GEOTAGGING OPTIONS")
112
106
  )
113
107
  group_geotagging.add_argument(
114
108
  "--desc_path",
@@ -118,10 +112,9 @@ class Command:
118
112
  )
119
113
  group_geotagging.add_argument(
120
114
  "--geotag_source",
121
- help="Provide the source of date/time and GPS information needed for geotagging. [default: %(default)s]",
122
- action="store",
123
- choices=geotag_sources,
124
- default="exif",
115
+ help=f"Provide the source of date/time and GPS information needed for geotagging. Supported source types: {', '.join(g.value for g in SourceType)} [default: {','.join(DEFAULT_GEOTAG_SOURCE_OPTIONS)}]",
116
+ action="append",
117
+ default=[],
125
118
  required=False,
126
119
  )
127
120
  group_geotagging.add_argument(
@@ -174,7 +167,7 @@ class Command:
174
167
  )
175
168
 
176
169
  group_sequence = parser.add_argument_group(
177
- f"{constants.ANSI_BOLD}PROCESS SEQUENCE OPTIONS{constants.ANSI_RESET_ALL}"
170
+ bold_text("PROCESS SEQUENCE OPTIONS")
178
171
  )
179
172
  group_sequence.add_argument(
180
173
  "--cutoff_distance",
@@ -212,24 +205,7 @@ class Command:
212
205
  )
213
206
 
214
207
  def run(self, vars_args: dict):
215
- if (
216
- "geotag_source" in vars_args
217
- and vars_args["geotag_source"] == "blackvue_videos"
218
- and (
219
- "device_make" not in vars_args
220
- or ("device_make" in vars_args and not vars_args["device_make"])
221
- )
222
- ):
223
- vars_args["device_make"] = "Blackvue"
224
- if (
225
- "device_make" in vars_args
226
- and vars_args["device_make"]
227
- and vars_args["device_make"].lower() == "blackvue"
228
- ):
229
- vars_args["duplicate_angle"] = 360
230
-
231
208
  metadatas = process_geotag_properties(
232
- vars_args=vars_args,
233
209
  **(
234
210
  {
235
211
  k: v
@@ -1,3 +1,7 @@
1
+ import inspect
2
+
3
+ from ..authenticate import fetch_user_items
4
+
1
5
  from .process import Command as ProcessCommand
2
6
  from .upload import Command as UploadCommand
3
7
 
@@ -10,10 +14,20 @@ class Command:
10
14
  ProcessCommand().add_basic_arguments(parser)
11
15
  UploadCommand().add_basic_arguments(parser)
12
16
 
13
- def run(self, args: dict):
14
- if args.get("desc_path") is None:
17
+ def run(self, vars_args: dict):
18
+ if vars_args.get("desc_path") is None:
15
19
  # \x00 is a special path similiar to /dev/null
16
20
  # it tells process command do not write anything
17
- args["desc_path"] = "\x00"
18
- ProcessCommand().run(args)
19
- UploadCommand().run(args)
21
+ vars_args["desc_path"] = "\x00"
22
+
23
+ if "user_items" not in vars_args:
24
+ vars_args["user_items"] = fetch_user_items(
25
+ **{
26
+ k: v
27
+ for k, v in vars_args.items()
28
+ if k in inspect.getfullargspec(fetch_user_items).args
29
+ }
30
+ )
31
+
32
+ ProcessCommand().run(vars_args)
33
+ UploadCommand().run(vars_args)
@@ -4,6 +4,7 @@ from pathlib import Path
4
4
 
5
5
  from .. import constants
6
6
  from ..sample_video import sample_video
7
+ from .process import bold_text
7
8
 
8
9
 
9
10
  class Command:
@@ -11,9 +12,7 @@ class Command:
11
12
  help = "sample video into images"
12
13
 
13
14
  def add_basic_arguments(self, parser: argparse.ArgumentParser):
14
- group = parser.add_argument_group(
15
- f"{constants.ANSI_BOLD}VIDEO PROCESS OPTIONS{constants.ANSI_RESET_ALL}"
16
- )
15
+ group = parser.add_argument_group(bold_text("VIDEO PROCESS OPTIONS"))
17
16
  group.add_argument(
18
17
  "--video_sample_distance",
19
18
  help="The minimal distance interval, in meters, for sampling video frames. [default: %(default)s]",
@@ -1,18 +1,20 @@
1
1
  import inspect
2
2
 
3
3
  from .. import constants
4
+ from ..authenticate import fetch_user_items
4
5
  from ..upload import upload
6
+ from .process import bold_text
5
7
 
6
8
 
7
9
  class Command:
8
10
  name = "upload"
9
- help = "upload images to Mapillary"
11
+ help = "Upload processed data to Mapillary"
10
12
 
11
13
  @staticmethod
12
14
  def add_common_upload_options(group):
13
15
  group.add_argument(
14
16
  "--user_name",
15
- help="The Mapillary user account to upload to. If you only have one account authorized, it will upload to that account by default.",
17
+ help="The Mapillary user account to upload to.",
16
18
  required=False,
17
19
  )
18
20
  group.add_argument(
@@ -21,30 +23,59 @@ class Command:
21
23
  default=None,
22
24
  required=False,
23
25
  )
26
+ group.add_argument(
27
+ "--reupload",
28
+ help="Re-upload data that has already been uploaded.",
29
+ action="store_true",
30
+ default=False,
31
+ required=False,
32
+ )
24
33
  group.add_argument(
25
34
  "--dry_run",
26
- help='Instead of uploading to the Mapillary server, simulate uploading to the local directory "mapillary_public_uploads" for debugging purposes.',
35
+ "--dryrun",
36
+ help="[DEVELOPMENT] Simulate upload by sending data to a local directory instead of Mapillary servers. Uses a temporary directory by default unless specified by MAPILLARY_UPLOAD_ENDPOINT environment variable.",
37
+ action="store_true",
38
+ default=False,
39
+ required=False,
40
+ )
41
+ group.add_argument(
42
+ "--nofinish",
43
+ help="[DEVELOPMENT] Upload data without finalizing. The data will NOT be stored permanently or appear on the Mapillary website.",
44
+ action="store_true",
45
+ default=False,
46
+ required=False,
47
+ )
48
+ group.add_argument(
49
+ "--noresume",
50
+ help="[DEVELOPMENT] Start upload from the beginning, ignoring any previously interrupted upload sessions.",
27
51
  action="store_true",
28
52
  default=False,
29
53
  required=False,
30
54
  )
31
55
 
32
56
  def add_basic_arguments(self, parser):
33
- group = parser.add_argument_group(
34
- f"{constants.ANSI_BOLD}UPLOAD OPTIONS{constants.ANSI_RESET_ALL}"
35
- )
57
+ group = parser.add_argument_group(bold_text("UPLOAD OPTIONS"))
36
58
  group.add_argument(
37
59
  "--desc_path",
38
- help=f'Path to the description file generated by the process command. The hyphen "-" indicates STDIN. [default: {{IMPORT_PATH}}/{constants.IMAGE_DESCRIPTION_FILENAME}]',
60
+ help=f'Path to the description file with processed image and video metadata (from process command). Use "-" for STDIN. [default: {{IMPORT_PATH}}/{constants.IMAGE_DESCRIPTION_FILENAME}]',
39
61
  default=None,
40
62
  required=False,
41
63
  )
42
64
  Command.add_common_upload_options(group)
43
65
 
44
66
  def run(self, vars_args: dict):
45
- args = {
46
- k: v
47
- for k, v in vars_args.items()
48
- if k in inspect.getfullargspec(upload).args
49
- }
50
- upload(**args)
67
+ if "user_items" not in vars_args:
68
+ user_items_args = {
69
+ k: v
70
+ for k, v in vars_args.items()
71
+ if k in inspect.getfullargspec(fetch_user_items).args
72
+ }
73
+ vars_args["user_items"] = fetch_user_items(**user_items_args)
74
+
75
+ upload(
76
+ **{
77
+ k: v
78
+ for k, v in vars_args.items()
79
+ if k in inspect.getfullargspec(upload).args
80
+ }
81
+ )
@@ -1,3 +1,7 @@
1
+ import inspect
2
+
3
+ from ..authenticate import fetch_user_items
4
+
1
5
  from .upload import Command as UploadCommand
2
6
  from .video_process import Command as VideoProcessCommand
3
7
 
@@ -10,10 +14,20 @@ class Command:
10
14
  VideoProcessCommand().add_basic_arguments(parser)
11
15
  UploadCommand().add_basic_arguments(parser)
12
16
 
13
- def run(self, args: dict):
14
- if args.get("desc_path") is None:
17
+ def run(self, vars_args: dict):
18
+ if vars_args.get("desc_path") is None:
15
19
  # \x00 is a special path similiar to /dev/null
16
20
  # it tells process command do not write anything
17
- args["desc_path"] = "\x00"
18
- VideoProcessCommand().run(args)
19
- UploadCommand().run(args)
21
+ vars_args["desc_path"] = "\x00"
22
+
23
+ if "user_items" not in vars_args:
24
+ vars_args["user_items"] = fetch_user_items(
25
+ **{
26
+ k: v
27
+ for k, v in vars_args.items()
28
+ if k in inspect.getfullargspec(fetch_user_items).args
29
+ }
30
+ )
31
+
32
+ VideoProcessCommand().run(vars_args)
33
+ UploadCommand().run(vars_args)
mapillary_tools/config.py CHANGED
@@ -1,30 +1,55 @@
1
+ from __future__ import annotations
2
+
1
3
  import configparser
2
4
  import os
5
+ import sys
3
6
  import typing as T
7
+ from typing import TypedDict
4
8
 
5
- from . import api_v4, types
6
-
9
+ if sys.version_info >= (3, 11):
10
+ from typing import Required
11
+ else:
12
+ from typing_extensions import Required
7
13
 
8
- _CLIENT_ID = api_v4.MAPILLARY_CLIENT_TOKEN
9
- # Windows is not happy with | so we convert MLY|ID|TOKEN to MLY_ID_TOKEN
10
- _CLIENT_ID = _CLIENT_ID.replace("|", "_", 2)
14
+ from . import api_v4
11
15
 
12
- DEFAULT_MAPILLARY_FOLDER = os.path.join(
13
- os.path.expanduser("~"),
14
- ".config",
15
- "mapillary",
16
- )
17
16
 
17
+ DEFAULT_MAPILLARY_FOLDER = os.path.join(os.path.expanduser("~"), ".config", "mapillary")
18
18
  MAPILLARY_CONFIG_PATH = os.getenv(
19
19
  "MAPILLARY_CONFIG_PATH",
20
20
  os.path.join(
21
21
  DEFAULT_MAPILLARY_FOLDER,
22
22
  "configs",
23
- _CLIENT_ID,
23
+ # Windows is not happy with | so we convert MLY|ID|TOKEN to MLY_ID_TOKEN
24
+ api_v4.MAPILLARY_CLIENT_TOKEN.replace("|", "_"),
24
25
  ),
25
26
  )
26
27
 
27
28
 
29
+ class UserItem(TypedDict, total=False):
30
+ MAPOrganizationKey: int | str
31
+ # Username
32
+ MAPSettingsUsername: str
33
+ # User ID
34
+ MAPSettingsUserKey: str
35
+ # User access token
36
+ user_upload_token: Required[str]
37
+
38
+
39
+ UserItemSchema = {
40
+ "type": "object",
41
+ "properties": {
42
+ "MAPOrganizationKey": {"type": ["integer", "string"]},
43
+ # Not in use. Keep here for back-compatibility
44
+ "MAPSettingsUsername": {"type": "string"},
45
+ "MAPSettingsUserKey": {"type": "string"},
46
+ "user_upload_token": {"type": "string"},
47
+ },
48
+ "required": ["user_upload_token"],
49
+ "additionalProperties": True,
50
+ }
51
+
52
+
28
53
  def _load_config(config_path: str) -> configparser.ConfigParser:
29
54
  config = configparser.ConfigParser()
30
55
  # Override to not change option names (by default it will lower them)
@@ -34,38 +59,52 @@ def _load_config(config_path: str) -> configparser.ConfigParser:
34
59
  return config
35
60
 
36
61
 
37
- def load_user(
38
- user_name: str, config_path: T.Optional[str] = None
39
- ) -> T.Optional[types.UserItem]:
62
+ def load_user(profile_name: str, config_path: str | None = None) -> UserItem | None:
40
63
  if config_path is None:
41
64
  config_path = MAPILLARY_CONFIG_PATH
42
65
  config = _load_config(config_path)
43
- if not config.has_section(user_name):
66
+ if not config.has_section(profile_name):
44
67
  return None
45
- user_items = dict(config.items(user_name))
46
- return T.cast(types.UserItem, user_items)
68
+ user_items = dict(config.items(profile_name))
69
+ return T.cast(UserItem, user_items)
47
70
 
48
71
 
49
- def list_all_users(config_path: T.Optional[str] = None) -> T.List[types.UserItem]:
72
+ def list_all_users(config_path: str | None = None) -> dict[str, UserItem]:
50
73
  if config_path is None:
51
74
  config_path = MAPILLARY_CONFIG_PATH
52
75
  cp = _load_config(config_path)
53
- users = [
54
- load_user(user_name, config_path=config_path) for user_name in cp.sections()
55
- ]
56
- return [item for item in users if item is not None]
76
+ users = {
77
+ profile_name: load_user(profile_name, config_path=config_path)
78
+ for profile_name in cp.sections()
79
+ }
80
+ return {profile: item for profile, item in users.items() if item is not None}
57
81
 
58
82
 
59
83
  def update_config(
60
- user_name: str, user_items: types.UserItem, config_path: T.Optional[str] = None
84
+ profile_name: str, user_items: UserItem, config_path: str | None = None
61
85
  ) -> None:
62
86
  if config_path is None:
63
87
  config_path = MAPILLARY_CONFIG_PATH
64
88
  config = _load_config(config_path)
65
- if not config.has_section(user_name):
66
- config.add_section(user_name)
89
+ if not config.has_section(profile_name):
90
+ config.add_section(profile_name)
67
91
  for key, val in user_items.items():
68
- config.set(user_name, key, T.cast(str, val))
92
+ config.set(profile_name, key, T.cast(str, val))
93
+ os.makedirs(os.path.dirname(os.path.abspath(config_path)), exist_ok=True)
94
+ with open(config_path, "w") as fp:
95
+ config.write(fp)
96
+
97
+
98
+ def remove_config(profile_name: str, config_path: str | None = None) -> None:
99
+ if config_path is None:
100
+ config_path = MAPILLARY_CONFIG_PATH
101
+
102
+ config = _load_config(config_path)
103
+ if not config.has_section(profile_name):
104
+ return
105
+
106
+ config.remove_section(profile_name)
107
+
69
108
  os.makedirs(os.path.dirname(os.path.abspath(config_path)), exist_ok=True)
70
109
  with open(config_path, "w") as fp:
71
110
  config.write(fp)