kleinkram 0.50.2__tar.gz → 0.51.0__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.

Potentially problematic release.


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

Files changed (57) hide show
  1. {kleinkram-0.50.2 → kleinkram-0.51.0}/PKG-INFO +1 -1
  2. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/api/deser.py +28 -1
  3. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/api/file_transfer.py +14 -13
  4. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/api/pagination.py +3 -0
  5. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/api/routes.py +110 -17
  6. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/cli/_mission.py +16 -4
  7. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/cli/_project.py +1 -1
  8. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/cli/_upload.py +15 -0
  9. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/core.py +7 -4
  10. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/errors.py +6 -0
  11. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/models.py +1 -0
  12. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/printing.py +2 -1
  13. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/utils.py +1 -4
  14. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram.egg-info/PKG-INFO +1 -1
  15. {kleinkram-0.50.2 → kleinkram-0.51.0}/setup.cfg +1 -1
  16. {kleinkram-0.50.2 → kleinkram-0.51.0}/README.md +0 -0
  17. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/__init__.py +0 -0
  18. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/__main__.py +0 -0
  19. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/_version.py +0 -0
  20. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/api/__init__.py +0 -0
  21. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/api/client.py +0 -0
  22. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/api/query.py +0 -0
  23. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/auth.py +0 -0
  24. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/cli/__init__.py +0 -0
  25. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/cli/_download.py +0 -0
  26. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/cli/_endpoint.py +0 -0
  27. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/cli/_file.py +0 -0
  28. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/cli/_list.py +0 -0
  29. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/cli/_verify.py +0 -0
  30. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/cli/app.py +0 -0
  31. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/cli/error_handling.py +0 -0
  32. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/config.py +0 -0
  33. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/main.py +0 -0
  34. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/py.typed +0 -0
  35. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/types.py +0 -0
  36. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram/wrappers.py +0 -0
  37. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram.egg-info/SOURCES.txt +0 -0
  38. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram.egg-info/dependency_links.txt +0 -0
  39. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram.egg-info/entry_points.txt +0 -0
  40. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram.egg-info/requires.txt +0 -0
  41. {kleinkram-0.50.2 → kleinkram-0.51.0}/kleinkram.egg-info/top_level.txt +0 -0
  42. {kleinkram-0.50.2 → kleinkram-0.51.0}/pyproject.toml +0 -0
  43. {kleinkram-0.50.2 → kleinkram-0.51.0}/requirements.txt +0 -0
  44. {kleinkram-0.50.2 → kleinkram-0.51.0}/setup.py +0 -0
  45. {kleinkram-0.50.2 → kleinkram-0.51.0}/testing/__init__.py +0 -0
  46. {kleinkram-0.50.2 → kleinkram-0.51.0}/testing/backend_fixtures.py +0 -0
  47. {kleinkram-0.50.2 → kleinkram-0.51.0}/tests/__init__.py +0 -0
  48. {kleinkram-0.50.2 → kleinkram-0.51.0}/tests/conftest.py +0 -0
  49. {kleinkram-0.50.2 → kleinkram-0.51.0}/tests/test_config.py +0 -0
  50. {kleinkram-0.50.2 → kleinkram-0.51.0}/tests/test_core.py +0 -0
  51. {kleinkram-0.50.2 → kleinkram-0.51.0}/tests/test_end_to_end.py +0 -0
  52. {kleinkram-0.50.2 → kleinkram-0.51.0}/tests/test_error_handling.py +0 -0
  53. {kleinkram-0.50.2 → kleinkram-0.51.0}/tests/test_fixtures.py +0 -0
  54. {kleinkram-0.50.2 → kleinkram-0.51.0}/tests/test_printing.py +0 -0
  55. {kleinkram-0.50.2 → kleinkram-0.51.0}/tests/test_query.py +0 -0
  56. {kleinkram-0.50.2 → kleinkram-0.51.0}/tests/test_utils.py +0 -0
  57. {kleinkram-0.50.2 → kleinkram-0.51.0}/tests/test_wrappers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kleinkram
3
- Version: 0.50.2
3
+ Version: 0.51.0
4
4
  Summary: give me your bags
5
5
  Author: Cyrill Püntener, Dominique Garmier, Johann Schwabe
6
6
  Author-email: pucyril@ethz.ch, dgarmier@ethz.ch, jschwab@ethz.ch
@@ -4,6 +4,7 @@ from datetime import datetime
4
4
  from enum import Enum
5
5
  from typing import Any
6
6
  from typing import Dict
7
+ from typing import List
7
8
  from typing import Literal
8
9
  from typing import NewType
9
10
  from typing import Tuple
@@ -14,6 +15,7 @@ import dateutil.parser
14
15
  from kleinkram.errors import ParsingError
15
16
  from kleinkram.models import File
16
17
  from kleinkram.models import FileState
18
+ from kleinkram.models import MetadataValue
17
19
  from kleinkram.models import Mission
18
20
  from kleinkram.models import Project
19
21
 
@@ -51,6 +53,7 @@ class MissionObjectKeys(str, Enum):
51
53
  DESCRIPTION = "description"
52
54
  CREATED_AT = "createdAt"
53
55
  UPDATED_AT = "updatedAt"
56
+ TAGS = "tags"
54
57
 
55
58
 
56
59
  class ProjectObjectKeys(str, Enum):
@@ -59,6 +62,7 @@ class ProjectObjectKeys(str, Enum):
59
62
  DESCRIPTION = "description"
60
63
  CREATED_AT = "createdAt"
61
64
  UPDATED_AT = "updatedAt"
65
+ REQUIRED_TAGS = "requiredTags"
62
66
 
63
67
 
64
68
  def _get_nested_info(data, key: Literal["mission", "project"]) -> Tuple[UUID, str]:
@@ -83,6 +87,25 @@ def _parse_file_state(state: str) -> FileState:
83
87
  raise ParsingError(f"error parsing file state: {state}") from e
84
88
 
85
89
 
90
+ def _parse_metadata(tags: List[Dict]) -> Dict[str, MetadataValue]:
91
+ result = {}
92
+ try:
93
+ for tag in tags:
94
+ entry = {
95
+ tag.get("name"): MetadataValue(
96
+ tag.get("valueAsString"), tag.get("datatype")
97
+ )
98
+ }
99
+ result.update(entry)
100
+ return result
101
+ except ValueError as e:
102
+ raise ParsingError(f"error parsing metadata: {e}") from e
103
+
104
+
105
+ def _parse_required_tags(tags: List[Dict]) -> list[str]:
106
+ return list(_parse_metadata(tags).keys())
107
+
108
+
86
109
  def _parse_project(project_object: ProjectObject) -> Project:
87
110
  try:
88
111
  id_ = UUID(project_object[ProjectObjectKeys.UUID], version=4)
@@ -90,6 +113,9 @@ def _parse_project(project_object: ProjectObject) -> Project:
90
113
  description = project_object[ProjectObjectKeys.DESCRIPTION]
91
114
  created_at = _parse_datetime(project_object[ProjectObjectKeys.CREATED_AT])
92
115
  updated_at = _parse_datetime(project_object[ProjectObjectKeys.UPDATED_AT])
116
+ required_tags = _parse_required_tags(
117
+ project_object[ProjectObjectKeys.REQUIRED_TAGS]
118
+ )
93
119
  except Exception as e:
94
120
  raise ParsingError(f"error parsing project: {project_object}") from e
95
121
  return Project(
@@ -98,6 +124,7 @@ def _parse_project(project_object: ProjectObject) -> Project:
98
124
  description=description,
99
125
  created_at=created_at,
100
126
  updated_at=updated_at,
127
+ required_tags=required_tags,
101
128
  )
102
129
 
103
130
 
@@ -107,7 +134,7 @@ def _parse_mission(mission: MissionObject) -> Mission:
107
134
  name = mission[MissionObjectKeys.NAME]
108
135
  created_at = _parse_datetime(mission[MissionObjectKeys.CREATED_AT])
109
136
  updated_at = _parse_datetime(mission[MissionObjectKeys.UPDATED_AT])
110
- metadata = {} # TODO: this crap is really bad to parse
137
+ metadata = _parse_metadata(mission[MissionObjectKeys.TAGS])
111
138
 
112
139
  project_id, project_name = _get_nested_info(mission, PROJECT)
113
140
 
@@ -572,19 +572,20 @@ def upload_files(
572
572
 
573
573
  avg_speed_bps = total_uploaded_bytes / elapsed_time if elapsed_time > 0 else 0
574
574
 
575
- console.print(f"Upload took {elapsed_time:.2f} seconds")
576
- console.print(f"Total uploaded: {format_bytes(total_uploaded_bytes)}")
577
- console.print(f"Average speed: {format_bytes(avg_speed_bps, speed=True)}")
578
-
579
- if failed_files > 0:
580
- console.print(
581
- f"\nUploaded {len(files) - failed_files - skipped_files} files, {skipped_files} skipped, {failed_files} uploads failed",
582
- style="red",
583
- )
584
- else:
585
- console.print(
586
- f"\nUploaded {len(files) - skipped_files} files, {skipped_files} skipped"
587
- )
575
+ if verbose:
576
+ console.print(f"Upload took {elapsed_time:.2f} seconds")
577
+ console.print(f"Total uploaded: {format_bytes(total_uploaded_bytes)}")
578
+ console.print(f"Average speed: {format_bytes(avg_speed_bps, speed=True)}")
579
+
580
+ if failed_files > 0:
581
+ console.print(
582
+ f"\nUploaded {len(files) - failed_files - skipped_files} files, {skipped_files} skipped, {failed_files} uploads failed",
583
+ style="red",
584
+ )
585
+ else:
586
+ console.print(
587
+ f"\nUploaded {len(files) - skipped_files} files, {skipped_files} skipped"
588
+ )
588
589
 
589
590
 
590
591
  def download_files(
@@ -17,6 +17,7 @@ DataPage = Dict[str, Any]
17
17
  PAGE_SIZE = 128
18
18
  SKIP = "skip"
19
19
  TAKE = "take"
20
+ EXACT_MATCH = "exactMatch"
20
21
 
21
22
 
22
23
  def paginated_request(
@@ -25,6 +26,7 @@ def paginated_request(
25
26
  params: Optional[Mapping[str, Any]] = None,
26
27
  max_entries: Optional[int] = None,
27
28
  page_size: int = PAGE_SIZE,
29
+ exact_match: bool = False,
28
30
  ) -> Generator[DataPage, None, None]:
29
31
  total_entries_count = 0
30
32
 
@@ -32,6 +34,7 @@ def paginated_request(
32
34
 
33
35
  params[TAKE] = page_size
34
36
  params[SKIP] = 0
37
+ params[EXACT_MATCH] = str(exact_match).lower() # pass string rather than bool
35
38
 
36
39
  while True:
37
40
  resp = client.get(endpoint, params=params)
@@ -1,7 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import tempfile
4
5
  from enum import Enum
6
+ from pathlib import Path
5
7
  from typing import Any
6
8
  from typing import Dict
7
9
  from typing import Generator
@@ -38,12 +40,14 @@ from kleinkram.errors import InvalidMissionQuery
38
40
  from kleinkram.errors import InvalidProjectQuery
39
41
  from kleinkram.errors import MissionExists
40
42
  from kleinkram.errors import MissionNotFound
43
+ from kleinkram.errors import MissionValidationError
41
44
  from kleinkram.errors import ProjectExists
42
45
  from kleinkram.errors import ProjectNotFound
43
46
  from kleinkram.models import File
44
47
  from kleinkram.models import Mission
45
48
  from kleinkram.models import Project
46
49
  from kleinkram.utils import is_valid_uuid4
50
+ from kleinkram.utils import split_args
47
51
 
48
52
  __all__ = [
49
53
  "_get_api_version",
@@ -159,15 +163,22 @@ def get_projects(
159
163
  client: AuthenticatedClient,
160
164
  project_query: ProjectQuery,
161
165
  max_entries: Optional[int] = None,
166
+ exact_match: bool = False,
162
167
  ) -> Generator[Project, None, None]:
163
168
  params = _project_query_to_params(project_query)
164
169
  response_stream = paginated_request(
165
- client, PROJECT_ENDPOINT, params=params, max_entries=max_entries
170
+ client,
171
+ PROJECT_ENDPOINT,
172
+ params=params,
173
+ max_entries=max_entries,
174
+ exact_match=exact_match,
166
175
  )
167
176
  yield from map(lambda p: _parse_project(ProjectObject(p)), response_stream)
168
177
 
169
178
 
170
- def get_project(client: AuthenticatedClient, query: ProjectQuery) -> Project:
179
+ def get_project(
180
+ client: AuthenticatedClient, query: ProjectQuery, exact_match: bool = False
181
+ ) -> Project:
171
182
  """\
172
183
  get a unique project by specifying a project spec
173
184
  """
@@ -176,7 +187,7 @@ def get_project(client: AuthenticatedClient, query: ProjectQuery) -> Project:
176
187
  f"Project query does not uniquely determine project: {query}"
177
188
  )
178
189
  try:
179
- return next(get_projects(client, query))
190
+ return next(get_projects(client, query, exact_match=exact_match))
180
191
  except StopIteration:
181
192
  raise ProjectNotFound(f"Project not found: {query}")
182
193
 
@@ -220,6 +231,28 @@ def _mission_name_is_available(
220
231
  return False
221
232
 
222
233
 
234
+ def _validate_mission_name(
235
+ client: AuthenticatedClient, project_id: UUID, mission_name: str
236
+ ) -> None:
237
+ if not _mission_name_is_available(client, mission_name, project_id):
238
+ raise MissionExists(
239
+ f"Mission with name: `{mission_name}` already exists"
240
+ f" in project: {project_id}"
241
+ )
242
+
243
+ if is_valid_uuid4(mission_name):
244
+ raise ValueError(
245
+ f"Mission name: `{mission_name}` is a valid UUIDv4, "
246
+ "mission names must not be valid UUIDv4's"
247
+ )
248
+
249
+ if mission_name.endswith(" "):
250
+ raise ValueError(
251
+ "A mission name cannot end with a whitespace. "
252
+ f"The given mission name was '{mission_name}'"
253
+ )
254
+
255
+
223
256
  def _project_name_is_available(client: AuthenticatedClient, project_name: str) -> bool:
224
257
  project_query = ProjectQuery(patterns=[project_name])
225
258
  try:
@@ -229,6 +262,50 @@ def _project_name_is_available(client: AuthenticatedClient, project_name: str) -
229
262
  return False
230
263
 
231
264
 
265
+ def _validate_mission_created(
266
+ client: AuthenticatedClient, project_id: str, mission_name: str
267
+ ) -> None:
268
+ """
269
+ validate that a mission is successfully created
270
+ """
271
+ mission_ids, mission_patterns = split_args([mission_name])
272
+ project_ids, project_patterns = split_args([project_id])
273
+
274
+ project_query = ProjectQuery(ids=project_ids, patterns=project_patterns)
275
+ mission_query = MissionQuery(
276
+ ids=mission_ids,
277
+ patterns=mission_patterns,
278
+ project_query=project_query,
279
+ )
280
+ try:
281
+ with tempfile.NamedTemporaryFile(suffix=".mcap", delete=False) as tmp:
282
+ tmp.write(b"dummy content")
283
+ tmp_path = Path(tmp.name)
284
+
285
+ kleinkram.core.upload(
286
+ client=client,
287
+ query=mission_query,
288
+ file_paths=[tmp_path],
289
+ verbose=False,
290
+ )
291
+
292
+ file_query = FileQuery(
293
+ ids=[],
294
+ patterns=[tmp_path.name],
295
+ mission_query=mission_query,
296
+ )
297
+ file_parsed = get_file(client, file_query)
298
+
299
+ kleinkram.core.delete_files(client=client, file_ids=[file_parsed.id])
300
+
301
+ except Exception as e:
302
+ raise MissionValidationError(f"Mission validation failed: {e}")
303
+
304
+ finally:
305
+ if tmp_path.exists():
306
+ tmp_path.unlink()
307
+
308
+
232
309
  def _create_mission(
233
310
  client: AuthenticatedClient,
234
311
  project_id: UUID,
@@ -236,6 +313,7 @@ def _create_mission(
236
313
  *,
237
314
  metadata: Optional[Dict[str, str]] = None,
238
315
  ignore_missing_tags: bool = False,
316
+ required_tags: Optional[List[str]] = None,
239
317
  ) -> UUID:
240
318
  """\
241
319
  creates a new mission with the given name and project_id
@@ -246,16 +324,11 @@ def _create_mission(
246
324
  if metadata is None:
247
325
  metadata = {}
248
326
 
249
- if not _mission_name_is_available(client, mission_name, project_id):
250
- raise MissionExists(
251
- f"Mission with name: `{mission_name}` already exists"
252
- f" in project: {project_id}"
253
- )
327
+ _validate_mission_name(client, project_id, mission_name)
254
328
 
255
- if is_valid_uuid4(mission_name):
256
- raise ValueError(
257
- f"Mission name: `{mission_name}` is a valid UUIDv4, "
258
- "mission names must not be valid UUIDv4's"
329
+ if required_tags and not set(required_tags).issubset(metadata.keys()):
330
+ raise InvalidMissionMetadata(
331
+ f"Mission tags `{required_tags}` are required but missing from metadata: {metadata}"
259
332
  )
260
333
 
261
334
  # we need to translate tag keys to tag type ids
@@ -267,9 +340,9 @@ def _create_mission(
267
340
  "tags": {str(k): v for k, v in tags.items()},
268
341
  "ignoreTags": ignore_missing_tags,
269
342
  }
270
-
271
343
  resp = client.post(CREATE_MISSION, json=payload)
272
344
  resp.raise_for_status()
345
+ _validate_mission_created(client, str(project_id), mission_name)
273
346
 
274
347
  return UUID(resp.json()["uuid"], version=4)
275
348
 
@@ -288,18 +361,37 @@ def _create_project(
288
361
  return UUID(resp.json()["uuid"], version=4)
289
362
 
290
363
 
364
+ def _validate_tag_value(tag_value, tag_datatype) -> None:
365
+ if tag_datatype == "NUMBER":
366
+ try:
367
+ float(tag_value)
368
+ except ValueError:
369
+ raise InvalidMissionMetadata(f"Value '{tag_value}' is not a valid NUMBER")
370
+ elif tag_datatype == "BOOLEAN":
371
+ if tag_value.lower() not in {"true", "false"}:
372
+ raise InvalidMissionMetadata(
373
+ f"Value '{tag_value}' is not a valid BOOLEAN (expected 'true' or 'false')"
374
+ )
375
+ else:
376
+ pass # any string is fine
377
+ # TODO: add check for LOCATION tag datatype
378
+
379
+
291
380
  def _get_metadata_type_id_by_name(
292
381
  client: AuthenticatedClient, tag_name: str
293
- ) -> Optional[UUID]:
382
+ ) -> Tuple[Optional[UUID], str]:
294
383
  resp = client.get(TAG_TYPE_BY_NAME, params={"name": tag_name, "take": 1})
295
384
 
296
385
  if resp.status_code in (403, 404):
297
386
  return None
298
387
 
299
388
  resp.raise_for_status()
389
+ try:
390
+ data = resp.json()["data"][0]
391
+ except IndexError:
392
+ return None, None
300
393
 
301
- data = resp.json()[0]
302
- return UUID(data["uuid"], version=4)
394
+ return UUID(data["uuid"], version=4), data["datatype"]
303
395
 
304
396
 
305
397
  def _get_tags_map(
@@ -309,9 +401,10 @@ def _get_tags_map(
309
401
  # why are we using metadata type ids as keys???
310
402
  ret = {}
311
403
  for key, val in metadata.items():
312
- metadata_type_id = _get_metadata_type_id_by_name(client, key)
404
+ metadata_type_id, tag_datatype = _get_metadata_type_id_by_name(client, key)
313
405
  if metadata_type_id is None:
314
406
  raise InvalidMissionMetadata(f"metadata field: {key} does not exist")
407
+ _validate_tag_value(val, tag_datatype)
315
408
  ret[metadata_type_id] = val
316
409
  return ret
317
410
 
@@ -13,6 +13,7 @@ from kleinkram.api.query import ProjectQuery
13
13
  from kleinkram.api.routes import get_mission
14
14
  from kleinkram.api.routes import get_project
15
15
  from kleinkram.config import get_shared_state
16
+ from kleinkram.errors import InvalidMissionQuery
16
17
  from kleinkram.printing import print_mission_info
17
18
  from kleinkram.utils import load_metadata
18
19
  from kleinkram.utils import split_args
@@ -45,13 +46,16 @@ def create(
45
46
  metadata_dct = load_metadata(Path(metadata)) if metadata else {} # noqa
46
47
 
47
48
  client = AuthenticatedClient()
48
- project_id = get_project(client, project_query).id
49
+ project = get_project(client, project_query)
50
+ project_id = project.id
51
+ project_required_tags = project.required_tags
49
52
  mission_id = kleinkram.api.routes._create_mission(
50
53
  client,
51
54
  project_id,
52
55
  mission_name,
53
56
  metadata=metadata_dct,
54
57
  ignore_missing_tags=ignore_missing_tags,
58
+ required_tags=project_required_tags,
55
59
  )
56
60
 
57
61
  mission_parsed = get_mission(client, MissionQuery(ids=[mission_id]))
@@ -120,9 +124,6 @@ def delete(
120
124
  False, "--confirm", "-y", "--yes", help="confirm deletion"
121
125
  ),
122
126
  ) -> None:
123
- if not confirm:
124
- typer.confirm(f"delete {project} {mission}", abort=True)
125
-
126
127
  project_ids, project_patterns = split_args([project] if project else [])
127
128
  project_query = ProjectQuery(ids=project_ids, patterns=project_patterns)
128
129
 
@@ -132,9 +133,20 @@ def delete(
132
133
  patterns=mission_patterns,
133
134
  project_query=project_query,
134
135
  )
136
+ if mission_patterns and not (project_patterns or project_ids):
137
+ raise InvalidMissionQuery(
138
+ "Mission query does not uniquely determine mission. "
139
+ "Project name or id must be specified when deleting by mission name"
140
+ )
135
141
 
136
142
  client = AuthenticatedClient()
137
143
  mission_parsed = get_mission(client, mission_query)
144
+ if not confirm:
145
+ if project:
146
+ typer.confirm(f"delete {project} {mission}", abort=True)
147
+ else:
148
+ typer.confirm(f"delete {mission_parsed.name} {mission}", abort=True)
149
+
138
150
  kleinkram.core.delete_mission(client=client, mission_id=mission_parsed.id)
139
151
 
140
152
 
@@ -90,7 +90,7 @@ def delete(
90
90
  project_query = ProjectQuery(ids=project_ids, patterns=project_patterns)
91
91
 
92
92
  client = AuthenticatedClient()
93
- project_id = get_project(client=client, query=project_query).id
93
+ project_id = get_project(client=client, query=project_query, exact_match=True).id
94
94
  kleinkram.core.delete_project(client=client, project_id=project_id)
95
95
 
96
96
 
@@ -13,6 +13,7 @@ from kleinkram.api.query import MissionQuery
13
13
  from kleinkram.api.query import ProjectQuery
14
14
  from kleinkram.config import get_shared_state
15
15
  from kleinkram.errors import FileNameNotSupported
16
+ from kleinkram.errors import DatatypeNotSupported
16
17
  from kleinkram.errors import MissionNotFound
17
18
  from kleinkram.utils import load_metadata
18
19
  from kleinkram.utils import split_args
@@ -44,6 +45,9 @@ def upload(
44
45
  False,
45
46
  help="fix filenames before upload, this does not change the filenames locally",
46
47
  ),
48
+ experimental_datatypes: bool = typer.Option(
49
+ False, help="allow experimental datatypes (yaml, svo2, db3, tum)"
50
+ ),
47
51
  ignore_missing_tags: bool = typer.Option(False, help="ignore mission tags"),
48
52
  ) -> None:
49
53
  # get filepaths
@@ -61,6 +65,17 @@ def upload(
61
65
 
62
66
  if not fix_filenames:
63
67
  for file in file_paths:
68
+
69
+ # check for experimental datatypes and throw an exception if not allowed
70
+ EXPERIMENTAL_DATATYPES = {".yaml", ".svo2", ".db3", ".tum"}
71
+
72
+ if not experimental_datatypes:
73
+ if file.suffix.lower() in EXPERIMENTAL_DATATYPES:
74
+ raise DatatypeNotSupported(
75
+ f"Datatype '{file.suffix}' for file {file} is not supported without the "
76
+ f"`--experimental-datatypes` flag. "
77
+ )
78
+
64
79
  if not kleinkram.utils.check_filename_is_sanatized(file.stem):
65
80
  raise FileNameNotSupported(
66
81
  f"Only `{''.join(kleinkram.utils.INTERNAL_ALLOWED_CHARS)}` are "
@@ -107,9 +107,9 @@ def upload(
107
107
 
108
108
  if create and mission is None:
109
109
  # check if project exists and get its id at the same time
110
- project_id = kleinkram.api.routes.get_project(
111
- client, query=query.project_query
112
- ).id
110
+ project = kleinkram.api.routes.get_project(client, query=query.project_query)
111
+ project_id = project.id
112
+ project_required_tags = project.required_tags
113
113
  mission_name = check_mission_query_is_creatable(query)
114
114
  kleinkram.api.routes._create_mission(
115
115
  client,
@@ -117,6 +117,7 @@ def upload(
117
117
  mission_name,
118
118
  metadata=metadata or {},
119
119
  ignore_missing_tags=ignore_missing_metadata,
120
+ required_tags=project_required_tags,
120
121
  )
121
122
  mission = kleinkram.api.routes.get_mission(client, query)
122
123
 
@@ -281,7 +282,9 @@ def delete_mission(*, client: AuthenticatedClient, mission_id: UUID) -> None:
281
282
 
282
283
  def delete_project(*, client: AuthenticatedClient, project_id: UUID) -> None:
283
284
  pquery = ProjectQuery(ids=[project_id])
284
- _ = kleinkram.api.routes.get_project(client, pquery) # check if project exists
285
+ _ = kleinkram.api.routes.get_project(
286
+ client, pquery, exact_match=True
287
+ ) # check if project exists
285
288
 
286
289
  # delete all missions and files
287
290
  missions = list(
@@ -43,9 +43,15 @@ class FileTypeNotSupported(Exception): ...
43
43
  class FileNameNotSupported(Exception): ...
44
44
 
45
45
 
46
+ class DatatypeNotSupported(Exception): ...
47
+
48
+
46
49
  class InvalidMissionMetadata(Exception): ...
47
50
 
48
51
 
52
+ class MissionValidationError(Exception): ...
53
+
54
+
49
55
  class NotAuthenticated(Exception):
50
56
  def __init__(self) -> None:
51
57
  super().__init__(LOGIN_MESSAGE)
@@ -41,6 +41,7 @@ class Project:
41
41
  description: str
42
42
  created_at: datetime
43
43
  updated_at: datetime
44
+ required_tags: List[str]
44
45
 
45
46
 
46
47
  @dataclass(frozen=True)
@@ -233,7 +233,7 @@ def file_info_table(file: File) -> Table:
233
233
 
234
234
 
235
235
  def mission_info_table(
236
- mission: Mission, print_metadata: bool = False
236
+ mission: Mission, print_metadata: bool = True
237
237
  ) -> Tuple[Table, ...]:
238
238
  table = Table("k", "v", title=f"mission info: {mission.name}", show_header=False)
239
239
 
@@ -269,6 +269,7 @@ def project_info_table(project: Project) -> Table:
269
269
  table.add_row("description", project.description)
270
270
  table.add_row("created", str(project.created_at))
271
271
  table.add_row("updated", str(project.updated_at))
272
+ table.add_row("required tags", ", ".join(project.required_tags))
272
273
 
273
274
  return table
274
275
 
@@ -28,10 +28,7 @@ from kleinkram.types import IdLike
28
28
  from kleinkram.types import PathLike
29
29
 
30
30
  INTERNAL_ALLOWED_CHARS = string.ascii_letters + string.digits + "_" + "-"
31
- SUPPORT_FILE_TYPES = [
32
- ".bag",
33
- ".mcap",
34
- ]
31
+ SUPPORT_FILE_TYPES = [".bag", ".mcap", ".db3", ".svo2", ".tum", ".yaml"]
35
32
 
36
33
 
37
34
  def file_paths_from_files(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kleinkram
3
- Version: 0.50.2
3
+ Version: 0.51.0
4
4
  Summary: give me your bags
5
5
  Author: Cyrill Püntener, Dominique Garmier, Johann Schwabe
6
6
  Author-email: pucyril@ethz.ch, dgarmier@ethz.ch, jschwab@ethz.ch
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = kleinkram
3
- version = 0.50.2
3
+ version = 0.51.0
4
4
  description = give me your bags
5
5
  long_description = file: README.md
6
6
  long_description_content_type = text/markdown
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes