kleinkram 0.37.0.dev20241113182530__py3-none-any.whl → 0.37.0.dev20241118113347__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.

Potentially problematic release.


This version of kleinkram might be problematic. Click here for more details.

Files changed (48) hide show
  1. kleinkram/__init__.py +6 -0
  2. kleinkram/__main__.py +6 -0
  3. kleinkram/_version.py +6 -0
  4. kleinkram/api/__init__.py +0 -0
  5. kleinkram/api/client.py +65 -0
  6. kleinkram/api/file_transfer.py +337 -0
  7. kleinkram/api/routes.py +460 -0
  8. kleinkram/app.py +180 -0
  9. kleinkram/auth.py +96 -0
  10. kleinkram/commands/__init__.py +1 -0
  11. kleinkram/commands/download.py +103 -0
  12. kleinkram/commands/endpoint.py +62 -0
  13. kleinkram/commands/list.py +93 -0
  14. kleinkram/commands/mission.py +57 -0
  15. kleinkram/commands/project.py +24 -0
  16. kleinkram/commands/upload.py +138 -0
  17. kleinkram/commands/verify.py +117 -0
  18. kleinkram/config.py +171 -0
  19. kleinkram/consts.py +8 -1
  20. kleinkram/core.py +14 -0
  21. kleinkram/enums.py +10 -0
  22. kleinkram/errors.py +59 -0
  23. kleinkram/main.py +6 -489
  24. kleinkram/models.py +186 -0
  25. kleinkram/utils.py +179 -0
  26. {kleinkram-0.37.0.dev20241113182530.dist-info/licenses → kleinkram-0.37.0.dev20241118113347.dist-info}/LICENSE +1 -1
  27. kleinkram-0.37.0.dev20241118113347.dist-info/METADATA +113 -0
  28. kleinkram-0.37.0.dev20241118113347.dist-info/RECORD +33 -0
  29. {kleinkram-0.37.0.dev20241113182530.dist-info → kleinkram-0.37.0.dev20241118113347.dist-info}/WHEEL +2 -1
  30. kleinkram-0.37.0.dev20241118113347.dist-info/entry_points.txt +2 -0
  31. kleinkram-0.37.0.dev20241118113347.dist-info/top_level.txt +2 -0
  32. tests/__init__.py +0 -0
  33. tests/test_utils.py +153 -0
  34. kleinkram/api_client.py +0 -63
  35. kleinkram/auth/auth.py +0 -160
  36. kleinkram/endpoint/endpoint.py +0 -58
  37. kleinkram/error_handling.py +0 -177
  38. kleinkram/file/file.py +0 -144
  39. kleinkram/helper.py +0 -272
  40. kleinkram/mission/mission.py +0 -310
  41. kleinkram/project/project.py +0 -138
  42. kleinkram/queue/queue.py +0 -8
  43. kleinkram/tag/tag.py +0 -71
  44. kleinkram/topic/topic.py +0 -55
  45. kleinkram/user/user.py +0 -75
  46. kleinkram-0.37.0.dev20241113182530.dist-info/METADATA +0 -24
  47. kleinkram-0.37.0.dev20241113182530.dist-info/RECORD +0 -20
  48. kleinkram-0.37.0.dev20241113182530.dist-info/entry_points.txt +0 -2
@@ -0,0 +1,460 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from typing import cast
5
+ from typing import Dict
6
+ from typing import List
7
+ from typing import Optional
8
+ from typing import Tuple
9
+ from typing import Union
10
+ from uuid import UUID
11
+
12
+ import httpx
13
+ from kleinkram.api.client import AuthenticatedClient
14
+ from kleinkram.config import Config
15
+ from kleinkram.errors import MissionDoesNotExist
16
+ from kleinkram.errors import MissionExists
17
+ from kleinkram.errors import NoPermission
18
+ from kleinkram.models import DataType
19
+ from kleinkram.models import File
20
+ from kleinkram.models import FilesById
21
+ from kleinkram.models import FilesByMission
22
+ from kleinkram.models import FileState
23
+ from kleinkram.models import Mission
24
+ from kleinkram.models import MissionById
25
+ from kleinkram.models import MissionByName
26
+ from kleinkram.models import Project
27
+ from kleinkram.models import TagType
28
+ from kleinkram.utils import is_valid_uuid4
29
+
30
+
31
+ # TODO: change to 10000
32
+ MAX_PAGINATION = 1_000
33
+
34
+ TEMP_CREDS = "/file/temporaryAccess"
35
+ CLAIM_ADMIN = "/user/claimAdmin"
36
+
37
+ PROJECT_BY_NAME = "/project/byName"
38
+ PROJECT_BY_ID = "/project/one"
39
+ PROJECT_CREATE = "/project/create"
40
+ PROJECT_ALL = "/project/filtered"
41
+
42
+ MISSION_BY_NAME = "/mission/byName"
43
+ MISSION_BY_ID = "/mission/one"
44
+ MISSION_CREATE = "/mission/create"
45
+ MISSION_BY_PROJECT_NAME = "/mission/filteredByProjectName"
46
+ MISSION_UPDATE_METADATA = "/mission/tags"
47
+
48
+ ALL_USERS = "/user/all"
49
+ USER_INFO = "/user/me"
50
+ PROMOTE_USER = "/user/promote"
51
+ DEMOTE_USER = "/user/demote"
52
+
53
+ FILE_DOWNLOAD = "/file/download"
54
+ FILE_QUERY = "/file/filteredByNames"
55
+ FILE_ONE = "/file/one"
56
+ FILE_OF_MISSION = "/file/ofMission"
57
+
58
+ TAG_TYPE_BY_NAME = "/tag/filtered"
59
+
60
+ GET_STATUS = "/user/me"
61
+
62
+
63
+ def claim_admin(client: AuthenticatedClient) -> None:
64
+ """\
65
+ the first user on the system could call this
66
+ """
67
+ response = client.post(CLAIM_ADMIN)
68
+ response.raise_for_status()
69
+ return
70
+
71
+
72
+ def get_project(
73
+ client: AuthenticatedClient, identifier: Union[str, UUID]
74
+ ) -> Union[tuple[UUID, Dict[str, Any]], tuple[None, None]]:
75
+
76
+ if isinstance(identifier, UUID):
77
+ params = {"uuid": str(identifier)}
78
+ else:
79
+ params = {"name": identifier}
80
+
81
+ resp = client.get("/missions", params=params)
82
+
83
+ if resp.status_code in (403, 404):
84
+ return None, None
85
+
86
+ # TODO: handle other status codes
87
+ resp.raise_for_status()
88
+
89
+ details = resp.json()
90
+ return UUID(details["uuid"], version=4), details
91
+
92
+
93
+ def create_project(
94
+ client: AuthenticatedClient,
95
+ project_name: str,
96
+ *,
97
+ description: str | None = None,
98
+ check_exists: bool = False,
99
+ ) -> UUID:
100
+ """\
101
+ creates a new mission with the given name and project_id
102
+
103
+ if check_exists is True, the function will return the existing mission_id,
104
+ otherwise if the mission already exists an error will be raised
105
+ """
106
+ if description is None:
107
+ description = "autogenerated by CLI"
108
+
109
+ if check_exists:
110
+ project_id, _ = get_project(client, project_name)
111
+ if project_id is not None:
112
+ return project_id
113
+
114
+ if is_valid_uuid4(project_name):
115
+ raise ValueError(
116
+ f"Project name: `{project_name}` is a valid UUIDv4, "
117
+ "project names must not be valid UUIDv4's"
118
+ )
119
+
120
+ resp = client.post(
121
+ MISSION_CREATE,
122
+ json={
123
+ "name": project_name,
124
+ "description": description,
125
+ "requiredTags": [],
126
+ },
127
+ )
128
+
129
+ if resp.status_code >= 400:
130
+ raise ValueError(
131
+ f"Failed to create project. Status Code: "
132
+ f"{str(resp.status_code)}\n"
133
+ f"{resp.json()['message'][0]}"
134
+ )
135
+
136
+ return UUID(resp.json()["uuid"], version=4)
137
+
138
+
139
+ def get_mission_id_by_name(
140
+ client: AuthenticatedClient, mission_name, project_id: UUID
141
+ ) -> Optional[UUID]:
142
+ params = {"name": mission_name, "projectUUID": str(project_id)}
143
+ resp = client.get(MISSION_BY_NAME, params=params)
144
+
145
+ if resp.status_code in (403, 404):
146
+ return None
147
+
148
+ # TODO: handle other status codes
149
+ resp.raise_for_status()
150
+
151
+ data = resp.json()
152
+
153
+ return UUID(data["uuid"], version=4)
154
+
155
+
156
+ def get_mission_by_id(
157
+ client: AuthenticatedClient, mission_id: UUID
158
+ ) -> Optional[Mission]:
159
+ params = {"uuid": str(mission_id), "take": MAX_PAGINATION}
160
+ resp = client.get(FILE_OF_MISSION, params=params)
161
+
162
+ if resp.status_code in (403, 404):
163
+ return None
164
+
165
+ resp.raise_for_status()
166
+ data = resp.json()[0]
167
+ files = [_parse_file(file) for file in data]
168
+
169
+ resp = client.get(MISSION_BY_ID, params={"uuid": str(mission_id)})
170
+ resp.raise_for_status()
171
+
172
+ mission_data = resp.json()
173
+ mission = Mission(
174
+ id=mission_id,
175
+ name=mission_data["name"],
176
+ project_id=UUID(mission_data["project"]["uuid"], version=4),
177
+ project_name=mission_data["project"]["name"],
178
+ files=files,
179
+ )
180
+
181
+ return mission
182
+
183
+
184
+ def get_project_id_by_name(
185
+ client: AuthenticatedClient, project_name: str
186
+ ) -> Optional[UUID]:
187
+ params = {"name": project_name}
188
+ resp = client.get(PROJECT_BY_NAME, params=params)
189
+
190
+ if resp.status_code in (403, 404):
191
+ return None
192
+
193
+ resp.raise_for_status()
194
+
195
+ return UUID(resp.json()["uuid"], version=4)
196
+
197
+
198
+ def create_mission(
199
+ client: AuthenticatedClient,
200
+ project_id: UUID,
201
+ mission_name: str,
202
+ *,
203
+ tags: Optional[Dict[UUID, str]] = None,
204
+ ignore_missing_tags: bool = False,
205
+ ) -> UUID:
206
+ """\
207
+ creates a new mission with the given name and project_id
208
+
209
+ if check_exists is True, the function will return the existing mission_id,
210
+ otherwise if the mission already exists an error will be raised
211
+ """
212
+
213
+ if get_mission_id_by_name(client, mission_name, project_id) is not None:
214
+ raise MissionExists(f"Mission with name: `{mission_name}` already exists")
215
+
216
+ if is_valid_uuid4(mission_name):
217
+ raise ValueError(
218
+ f"Mission name: `{mission_name}` is a valid UUIDv4, "
219
+ "mission names must not be valid UUIDv4's"
220
+ )
221
+
222
+ payload = {
223
+ "name": mission_name,
224
+ "projectUUID": str(project_id),
225
+ "tags": {str(k): v for k, v in tags.items()} if tags else {},
226
+ "ignoreTags": ignore_missing_tags,
227
+ }
228
+
229
+ resp = client.post(MISSION_CREATE, json=payload)
230
+ resp.raise_for_status()
231
+
232
+ return UUID(resp.json()["uuid"], version=4)
233
+
234
+
235
+ def get_project_permission_level(client: AuthenticatedClient, project_id: UUID) -> int:
236
+ """\
237
+ we need this to check if a user has the permissions to
238
+ create a mission in an existing project
239
+ """
240
+
241
+ resp = client.get("/user/permissions")
242
+ resp.raise_for_status()
243
+
244
+ project_group: List[Dict[str, Union[str, int]]] = resp.json().get("projects", [])
245
+ filtered_by_id = filter(lambda x: x.get("uuid") == str(project_id), project_group)
246
+
247
+ # it is possilbe that a user has access to a project via multiple groups
248
+ # in this case we take the highest permission level
249
+ return cast(int, max(map(lambda x: x.get("access", 0), filtered_by_id)))
250
+
251
+
252
+ def _parse_file(file: Dict[str, Any]) -> File:
253
+ project_id = UUID(file["mission"]["project"]["uuid"], version=4)
254
+ project_name = file["mission"]["project"]["name"]
255
+
256
+ mission_id = UUID(file["mission"]["uuid"], version=4)
257
+ mission_name = file["mission"]["name"]
258
+
259
+ filename = file["filename"]
260
+ file_id = UUID(file["uuid"], version=4)
261
+ file_size = file["size"]
262
+ file_hash = file["hash"]
263
+
264
+ parsed = File(
265
+ id=file_id,
266
+ name=filename,
267
+ size=file_size,
268
+ hash=file_hash,
269
+ project_id=project_id,
270
+ project_name=project_name,
271
+ mission_id=mission_id,
272
+ mission_name=mission_name,
273
+ state=FileState(file["state"]),
274
+ )
275
+ return parsed
276
+
277
+
278
+ def get_file(client: AuthenticatedClient, id: UUID) -> File:
279
+ resp = client.get(FILE_ONE, params={"uuid": str(id)})
280
+ resp.raise_for_status()
281
+
282
+ return _parse_file(resp.json())
283
+
284
+
285
+ def get_files(
286
+ client: AuthenticatedClient,
287
+ name: Optional[str] = None,
288
+ project: Optional[str] = None,
289
+ mission: Optional[str] = None,
290
+ topics: Optional[List[str]] = None,
291
+ tags: Optional[Dict[str, str]] = None,
292
+ ) -> List[File]:
293
+ # TODO: allow to search by id
294
+
295
+ params: Dict[str, Any] = {"take": MAX_PAGINATION}
296
+ if name is not None:
297
+ params["name"] = name
298
+ if project is not None:
299
+ params["projectName"] = project
300
+ if mission is not None:
301
+ params["missionName"] = mission
302
+ if topics:
303
+ params["topics"] = ",".join(topics)
304
+ if tags:
305
+ params["tags"] = tags
306
+
307
+ resp = client.get(FILE_QUERY, params=params)
308
+ resp.raise_for_status()
309
+
310
+ files = []
311
+ data = resp.json()
312
+
313
+ for file in data:
314
+ try:
315
+ parsed = _parse_file(file)
316
+ files.append(parsed)
317
+ except Exception:
318
+ print(f"Error parsing file: {file}")
319
+ return files
320
+
321
+
322
+ def get_missions(
323
+ client: AuthenticatedClient,
324
+ project: Optional[str] = None,
325
+ tags: Optional[Dict[str, str]] = None,
326
+ ) -> list[Mission]:
327
+ # TODO: use a better endpoint once this exists
328
+ matching_files = get_files(client, project=project, tags=tags)
329
+
330
+ ret = {}
331
+ for file in matching_files:
332
+ ret[file.mission_id] = Mission(
333
+ id=file.mission_id,
334
+ name=file.mission_name,
335
+ project_id=file.project_id,
336
+ project_name=file.project_name,
337
+ )
338
+
339
+ return list(ret.values())
340
+
341
+
342
+ def get_projects(client: AuthenticatedClient) -> list[Project]:
343
+ resp = client.get(PROJECT_ALL)
344
+ resp.raise_for_status()
345
+
346
+ ret = []
347
+ for pr in resp.json()[0]:
348
+ id = UUID(pr["uuid"], version=4)
349
+ name = pr["name"]
350
+ description = pr["description"]
351
+ ret.append(Project(id=id, name=name, description=description))
352
+
353
+ return ret
354
+
355
+
356
+ def get_mission_by_spec(
357
+ client: AuthenticatedClient, spec: Union[MissionById, MissionByName]
358
+ ) -> Optional[Mission]:
359
+ if isinstance(spec, MissionById):
360
+ return get_mission_by_id(client, spec.id)
361
+
362
+ if isinstance(spec.project, UUID):
363
+ project_id = spec.project
364
+ else:
365
+ project_id = get_project_id_by_name(client, spec.project)
366
+ if project_id is None:
367
+ return None
368
+
369
+ mission_id = get_mission_id_by_name(client, spec.name, project_id)
370
+ if mission_id is None:
371
+ return None
372
+
373
+ return get_mission_by_id(client, mission_id)
374
+
375
+
376
+ def get_files_by_file_spec(
377
+ client: AuthenticatedClient, spec: Union[FilesByMission, FilesById]
378
+ ) -> List[File]:
379
+ if isinstance(spec, FilesById):
380
+ return [get_file(client, file_id) for file_id in spec.ids]
381
+
382
+ parsed_mission = get_mission_by_spec(client, spec.mission)
383
+ if parsed_mission is None:
384
+ raise ValueError("mission not found")
385
+
386
+ if spec.files:
387
+ filtered = [
388
+ f
389
+ for f in parsed_mission.files
390
+ if f.id in spec.files or f.name in spec.files
391
+ ]
392
+ return filtered
393
+
394
+ return parsed_mission.files
395
+
396
+
397
+ def get_tag_type_by_name(
398
+ client: AuthenticatedClient, tag_name: str
399
+ ) -> Optional[TagType]:
400
+ resp = client.get(TAG_TYPE_BY_NAME, params={"name": tag_name, "take": 1})
401
+
402
+ if resp.status_code in (403, 404):
403
+ return None
404
+
405
+ resp.raise_for_status()
406
+
407
+ data = resp.json()[0]
408
+ tag_type = TagType(
409
+ name=data["name"],
410
+ id=UUID(data["uuid"], version=4),
411
+ data_type=DataType(data["datatype"]),
412
+ description=data["description"],
413
+ )
414
+ return tag_type
415
+
416
+
417
+ def get_tags_map(
418
+ client: AuthenticatedClient, metadata: Dict[str, str]
419
+ ) -> Dict[UUID, str]:
420
+ # TODO: this needs a better endpoint
421
+ ret = {}
422
+ for key, val in metadata.items():
423
+ tag_type = get_tag_type_by_name(client, key)
424
+
425
+ if tag_type is None:
426
+ print(f"tag: {key} not found")
427
+ continue
428
+
429
+ ret[tag_type.id] = val
430
+
431
+ return ret
432
+
433
+
434
+ def update_mission_metadata(
435
+ client: AuthenticatedClient, mission_id: UUID, metadata: Dict[str, str]
436
+ ) -> None:
437
+ tags_dct = get_tags_map(client, metadata)
438
+ payload = {
439
+ "missionUUID": str(mission_id),
440
+ "tags": {str(k): v for k, v in tags_dct.items()},
441
+ }
442
+ resp = client.post(MISSION_UPDATE_METADATA, json=payload)
443
+
444
+ if resp.status_code == 404:
445
+ raise MissionDoesNotExist
446
+
447
+ if resp.status_code == 403:
448
+ raise NoPermission
449
+
450
+ resp.raise_for_status()
451
+
452
+
453
+ def get_api_version() -> Tuple[int, int, int]:
454
+ config = Config()
455
+ client = httpx.Client()
456
+
457
+ resp = client.get(f"{config.endpoint}{GET_STATUS}")
458
+ vers = resp.headers["kleinkram-version"].split(".")
459
+
460
+ return tuple(map(int, vers)) # type: ignore
kleinkram/app.py ADDED
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import OrderedDict
4
+ from enum import Enum
5
+ from typing import Any
6
+ from typing import Callable
7
+ from typing import List
8
+ from typing import Optional
9
+ from typing import Type
10
+
11
+ import typer
12
+ from click import Context
13
+ from kleinkram._version import __version__
14
+ from kleinkram.api.client import AuthenticatedClient
15
+ from kleinkram.api.routes import claim_admin
16
+ from kleinkram.api.routes import get_api_version
17
+ from kleinkram.auth import login_flow
18
+ from kleinkram.commands.download import download_typer
19
+ from kleinkram.commands.endpoint import endpoint_typer
20
+ from kleinkram.commands.list import list_typer
21
+ from kleinkram.commands.mission import mission_typer
22
+ from kleinkram.commands.project import project_typer
23
+ from kleinkram.commands.upload import upload_typer
24
+ from kleinkram.commands.verify import verify_typer
25
+ from kleinkram.config import Config
26
+ from kleinkram.config import get_shared_state
27
+ from kleinkram.errors import InvalidCLIVersion
28
+ from kleinkram.utils import get_supported_api_version
29
+ from rich.console import Console
30
+ from typer.core import TyperGroup
31
+
32
+
33
+ CLI_HELP = """\
34
+ Kleinkram CLI
35
+
36
+ The Kleinkram CLI is a command line interface for Kleinkram.
37
+ For a list of available commands, run `klein --help` or visit \
38
+ https://docs.datasets.leggedrobotics.com/usage/cli/cli-getting-started.html \
39
+ for more information.
40
+ """
41
+
42
+
43
+ class CommandTypes(str, Enum):
44
+ AUTH = "Authentication Commands"
45
+ CORE = "Core Commands"
46
+ CRUD = "Create Update Delete Commands"
47
+
48
+
49
+ class OrderCommands(TyperGroup):
50
+ def list_commands(self, ctx: Context) -> List[str]:
51
+ _ = ctx # suppress unused variable warning
52
+ return list(self.commands)
53
+
54
+
55
+ ExceptionHandler = Callable[[Exception], int]
56
+
57
+
58
+ class ErrorHandledTyper(typer.Typer):
59
+ """\
60
+ error handlers that are last added will be used first
61
+ """
62
+
63
+ _error_handlers: OrderedDict[Type[Exception], ExceptionHandler]
64
+
65
+ def error_handler(
66
+ self, exc: type[Exception]
67
+ ) -> Callable[[ExceptionHandler], ExceptionHandler]:
68
+ def dec(func: ExceptionHandler) -> ExceptionHandler:
69
+ self._error_handlers[exc] = func
70
+ return func
71
+
72
+ return dec
73
+
74
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
75
+ super().__init__(*args, **kwargs)
76
+ self._error_handlers = OrderedDict()
77
+
78
+ def __call__(self, *args: Any, **kwargs: Any) -> int:
79
+ try:
80
+ return super().__call__(*args, **kwargs)
81
+ except Exception as e:
82
+ for tp, handler in reversed(self._error_handlers.items()):
83
+ if isinstance(e, tp):
84
+ exit_code = handler(e)
85
+ raise SystemExit(exit_code)
86
+ raise
87
+
88
+
89
+ app = ErrorHandledTyper(
90
+ cls=OrderCommands,
91
+ help=CLI_HELP,
92
+ context_settings={"help_option_names": ["-h", "--help"]},
93
+ no_args_is_help=True,
94
+ )
95
+
96
+
97
+ @app.error_handler(Exception)
98
+ def base_handler(exc: Exception) -> int:
99
+ if not get_shared_state().debug:
100
+ console = Console()
101
+ console.print(f"{type(exc).__name__}: {exc}", style="red")
102
+ return 1
103
+
104
+ raise exc
105
+
106
+
107
+ app.add_typer(download_typer, name="download", rich_help_panel=CommandTypes.CORE)
108
+ app.add_typer(upload_typer, name="upload", rich_help_panel=CommandTypes.CORE)
109
+ app.add_typer(verify_typer, name="verify", rich_help_panel=CommandTypes.CORE)
110
+ app.add_typer(list_typer, name="list", rich_help_panel=CommandTypes.CORE)
111
+ app.add_typer(endpoint_typer, name="endpoint", rich_help_panel=CommandTypes.AUTH)
112
+ app.add_typer(mission_typer, name="mission", rich_help_panel=CommandTypes.CRUD)
113
+ app.add_typer(project_typer, name="project", rich_help_panel=CommandTypes.CRUD)
114
+
115
+
116
+ @app.command(rich_help_panel=CommandTypes.AUTH)
117
+ def login(
118
+ key: Optional[str] = typer.Option(None, help="CLI key"),
119
+ headless: bool = typer.Option(False),
120
+ ) -> None:
121
+ login_flow(key=key, headless=headless)
122
+
123
+
124
+ @app.command(rich_help_panel=CommandTypes.AUTH)
125
+ def logout(all: bool = typer.Option(False, help="logout on all enpoints")) -> None:
126
+ config = Config()
127
+ config.clear_credentials(all=all)
128
+
129
+
130
+ @app.command(hidden=True)
131
+ def claim():
132
+ client = AuthenticatedClient()
133
+ claim_admin(client)
134
+ print("admin rights claimed successfully.")
135
+
136
+
137
+ def _version_cb(value: bool) -> None:
138
+ if value:
139
+ typer.echo(__version__)
140
+ raise typer.Exit()
141
+
142
+
143
+ def check_version_compatiblity() -> None:
144
+ cli_version = get_supported_api_version()
145
+ api_version = get_api_version()
146
+ api_vers_str = ".".join(map(str, api_version))
147
+
148
+ if cli_version[0] != api_version[0]:
149
+ raise InvalidCLIVersion(
150
+ f"CLI version {__version__} is not compatible with API version {api_vers_str}"
151
+ )
152
+
153
+ if cli_version[1] != api_version[1]:
154
+ console = Console()
155
+ console.print(
156
+ f"CLI version {__version__} might not be compatible with API version {api_vers_str}",
157
+ style="red",
158
+ )
159
+
160
+
161
+ @app.callback()
162
+ def cli(
163
+ verbose: bool = typer.Option(True, help="Enable verbose mode."),
164
+ debug: bool = typer.Option(False, help="Enable debug mode."),
165
+ version: Optional[bool] = typer.Option(
166
+ None, "--version", "-v", callback=_version_cb
167
+ ),
168
+ ):
169
+ _ = version # suppress unused variable warning
170
+ shared_state = get_shared_state()
171
+ shared_state.verbose = verbose
172
+ shared_state.debug = debug
173
+
174
+ try:
175
+ check_version_compatiblity()
176
+ except InvalidCLIVersion:
177
+ raise
178
+ except Exception:
179
+ console = Console()
180
+ console.print("failed to check version compatibility", style="yellow")