kleinkram 0.36.2__py3-none-any.whl → 0.36.2.dev20241118065826__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 +328 -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 -484
  24. kleinkram/models.py +186 -0
  25. kleinkram/utils.py +179 -0
  26. {kleinkram-0.36.2.dist-info/licenses → kleinkram-0.36.2.dev20241118065826.dist-info}/LICENSE +1 -1
  27. kleinkram-0.36.2.dev20241118065826.dist-info/METADATA +113 -0
  28. kleinkram-0.36.2.dev20241118065826.dist-info/RECORD +33 -0
  29. {kleinkram-0.36.2.dist-info → kleinkram-0.36.2.dev20241118065826.dist-info}/WHEEL +2 -1
  30. kleinkram-0.36.2.dev20241118065826.dist-info/entry_points.txt +2 -0
  31. kleinkram-0.36.2.dev20241118065826.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.36.2.dist-info/METADATA +0 -25
  47. kleinkram-0.36.2.dist-info/RECORD +0 -20
  48. kleinkram-0.36.2.dist-info/entry_points.txt +0 -2
kleinkram/helper.py DELETED
@@ -1,272 +0,0 @@
1
- import glob
2
- import os
3
- import queue
4
- import re
5
- import sys
6
- import threading
7
- from datetime import datetime
8
- from functools import partial
9
-
10
- import boto3
11
- import tqdm
12
- import typer
13
- from boto3.s3.transfer import TransferConfig
14
- from botocore.config import Config
15
- from botocore.utils import calculate_md5
16
- from rich import print
17
- from rich.console import Console
18
- from typing_extensions import Dict
19
-
20
- from kleinkram.api_client import AuthenticatedClient
21
-
22
-
23
- class TransferCallback:
24
- """
25
- Handle callbacks from the transfer manager.
26
-
27
- The transfer manager periodically calls the __call__ method throughout
28
- the upload process so that it can take action, such as displaying progress
29
- to the user and collecting data about the transfer.
30
- """
31
-
32
- def __init__(self):
33
- """
34
- Initialize the TransferCallback.
35
-
36
- This initializes an empty dictionary to hold progress bars for each file.
37
- """
38
- self._lock = threading.Lock()
39
- self.file_progress = {}
40
-
41
- def add_file(self, file_id, target_size):
42
- """
43
- Add a new file to track.
44
-
45
- :param file_id: A unique identifier for the file (e.g., file name or ID).
46
- :param target_size: The total size of the file being transferred.
47
- """
48
- with self._lock:
49
- tqdm_instance = tqdm.tqdm(
50
- total=target_size,
51
- unit="B",
52
- unit_scale=True,
53
- desc=f"Uploading {file_id}",
54
- )
55
- self.file_progress[file_id] = {
56
- "tqdm": tqdm_instance,
57
- "total_transferred": 0,
58
- }
59
-
60
- def __call__(self, file_id, bytes_transferred):
61
- """
62
- The callback method that is called by the transfer manager.
63
-
64
- Display progress during file transfer and collect per-thread transfer
65
- data. This method can be called by multiple threads, so shared instance
66
- data is protected by a thread lock.
67
-
68
- :param file_id: The identifier of the file being transferred.
69
- :param bytes_transferred: The number of bytes transferred in this call.
70
- """
71
- with self._lock:
72
- if file_id in self.file_progress:
73
- progress = self.file_progress[file_id]
74
- progress["total_transferred"] += bytes_transferred
75
-
76
- # Update tqdm progress bar
77
- progress["tqdm"].update(bytes_transferred)
78
-
79
- def close(self):
80
- """Close all tqdm progress bars."""
81
- with self._lock:
82
- for progress in self.file_progress.values():
83
- progress["tqdm"].close()
84
-
85
-
86
- def create_transfer_callback(callback_instance, file_id):
87
- """
88
- Factory function to create a partial function for TransferCallback.
89
- :param callback_instance: Instance of TransferCallback.
90
- :param file_id: The unique identifier for the file.
91
- :return: A callable that can be passed as a callback to boto3's upload_file method.
92
- """
93
- return partial(callback_instance.__call__, file_id)
94
-
95
-
96
- def expand_and_match(path_pattern):
97
- expanded_path = os.path.expanduser(path_pattern)
98
- expanded_path = os.path.expandvars(expanded_path)
99
-
100
- normalized_path = os.path.normpath(expanded_path)
101
-
102
- if "**" in normalized_path:
103
- file_list = glob.glob(normalized_path, recursive=True)
104
- else:
105
- file_list = glob.glob(normalized_path)
106
-
107
- return file_list
108
-
109
-
110
- def uploadFiles(
111
- files_with_access: Dict[str, object], paths: Dict[str, str], nrThreads: int
112
- ):
113
- client = AuthenticatedClient()
114
-
115
- api_endpoint = client.tokenfile.endpoint
116
- if api_endpoint == "http://localhost:3000":
117
- minio_endpoint = "http://localhost:9000"
118
- else:
119
- minio_endpoint = api_endpoint.replace("api", "minio")
120
-
121
- _queue = queue.Queue()
122
- for file_with_access in files_with_access:
123
- _queue.put((file_with_access, str(paths[file_with_access["fileName"]])))
124
-
125
- threads = []
126
- transfer_callback = TransferCallback()
127
-
128
- for i in range(nrThreads):
129
- thread = threading.Thread(
130
- target=uploadFile,
131
- args=(_queue, minio_endpoint, transfer_callback),
132
- )
133
- thread.start()
134
- threads.append(thread)
135
- for thread in threads:
136
- thread.join()
137
-
138
-
139
- def uploadFile(
140
- _queue: queue.Queue,
141
- minio_endpoint: str,
142
- transfer_callback: TransferCallback,
143
- ):
144
- config = Config(retries={"max_attempts": 10, "mode": "standard"})
145
-
146
- while True:
147
- try:
148
- file_with_access, filepath = _queue.get(timeout=3)
149
-
150
- if "error" in file_with_access and (
151
- file_with_access["error"] is not None or file_with_access["error"] != ""
152
- ):
153
- console = Console(file=sys.stderr, style="red", highlight=False)
154
- console.print(
155
- f"Error uploading file: {file_with_access['fileName']} ({filepath}): {file_with_access['error']}"
156
- )
157
- _queue.task_done()
158
- continue
159
-
160
- access_key = file_with_access["accessCredentials"]["accessKey"]
161
- secret_key = file_with_access["accessCredentials"]["secretKey"]
162
- session_token = file_with_access["accessCredentials"]["sessionToken"]
163
-
164
- session = boto3.Session(
165
- aws_access_key_id=access_key,
166
- aws_secret_access_key=secret_key,
167
- aws_session_token=session_token,
168
- )
169
-
170
- s3 = session.resource("s3", endpoint_url=minio_endpoint, config=config)
171
-
172
- fileu_uid = file_with_access["fileUUID"]
173
- bucket = file_with_access["bucket"]
174
-
175
- transfer_config = TransferConfig(
176
- multipart_chunksize=10 * 1024 * 1024,
177
- max_concurrency=5,
178
- )
179
- with open(filepath, "rb") as f:
180
- md5_checksum = calculate_md5(f)
181
- file_size = os.path.getsize(filepath)
182
- transfer_callback.add_file(filepath, file_size)
183
- callback_function = create_transfer_callback(
184
- transfer_callback, filepath
185
- )
186
- s3.Bucket(bucket).upload_file(
187
- filepath,
188
- fileu_uid,
189
- Config=transfer_config,
190
- Callback=callback_function,
191
- )
192
-
193
- client = AuthenticatedClient()
194
- res = client.post(
195
- "/queue/confirmUpload",
196
- json={"uuid": fileu_uid, "md5": md5_checksum},
197
- )
198
- res.raise_for_status()
199
- _queue.task_done()
200
- except queue.Empty:
201
- break
202
- except Exception as e:
203
- print("Error uploading file: " + filepath)
204
- print(e)
205
- _queue.task_done()
206
-
207
-
208
- def canUploadMission(client: AuthenticatedClient, project_uuid: str):
209
- permissions = client.get("/user/permissions")
210
- permissions.raise_for_status()
211
- permissions_json = permissions.json()
212
- for_project = filter(
213
- lambda x: x["uuid"] == project_uuid, permissions_json["projects"]
214
- )
215
- max_for_project = max(map(lambda x: x["access"], for_project))
216
- return max_for_project >= 10
217
-
218
-
219
- def promptForTags(setTags: Dict[str, str], requiredTags: Dict[str, str]):
220
- for required_tag in requiredTags:
221
- if required_tag["name"] not in setTags:
222
- while True:
223
- if required_tag["datatype"] in ["LOCATION", "STRING", "LINK"]:
224
- tag_value = typer.prompt(
225
- "Provide value for required tag " + required_tag["name"]
226
- )
227
- if tag_value != "":
228
- break
229
- elif required_tag["datatype"] == "BOOLEAN":
230
- tag_value = typer.confirm(
231
- "Provide (y/N) for required tag " + required_tag["name"]
232
- )
233
- break
234
- elif required_tag["datatype"] == "NUMBER":
235
- tag_value = typer.prompt(
236
- "Provide number for required tag " + required_tag["name"]
237
- )
238
- try:
239
- tag_value = float(tag_value)
240
- break
241
- except ValueError:
242
- typer.echo("Invalid number format. Please provide a number.")
243
- elif required_tag["datatype"] == "DATE":
244
- tag_value = typer.prompt(
245
- "Provide date for required tag " + required_tag["name"]
246
- )
247
- try:
248
- tag_value = datetime.strptime(tag_value, "%Y-%m-%d %H:%M:%S")
249
- break
250
- except ValueError:
251
- print("Invalid date format. Please use 'YYYY-MM-DD HH:MM:SS'")
252
-
253
- setTags[required_tag["uuid"]] = tag_value
254
-
255
-
256
- def is_valid_UUIDv4(uuid: str) -> bool:
257
- has_correct_length = len(uuid) == 36
258
-
259
- # is UUID4
260
- uuid_regex = (
261
- "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
262
- )
263
- is_valid_uuid = re.match(uuid_regex, uuid)
264
-
265
- return has_correct_length and is_valid_uuid
266
-
267
-
268
- if __name__ == "__main__":
269
- res = expand_and_match(
270
- "~/Downloads/dodo_mission_2024_02_08-20240408T074313Z-003/**.bag"
271
- )
272
- print(res)
@@ -1,310 +0,0 @@
1
- import os
2
- import re
3
-
4
- import httpx
5
- import requests
6
- import typer
7
- from rich.console import Console
8
- from rich.table import Table
9
- from tqdm import tqdm
10
- from typing_extensions import Annotated, Optional, List
11
-
12
- from kleinkram.api_client import AuthenticatedClient
13
- from kleinkram.error_handling import AccessDeniedException
14
- from kleinkram.helper import expand_and_match, uploadFiles
15
-
16
- missionCommands = typer.Typer(
17
- name="mission",
18
- help="Mission operations",
19
- no_args_is_help=True,
20
- context_settings={"help_option_names": ["-h", "--help"]},
21
- )
22
-
23
-
24
- @missionCommands.command("tag")
25
- def addTag(
26
- mission_uuid: Annotated[str, typer.Argument()],
27
- tagtype_uuid: Annotated[str, typer.Argument()],
28
- value: Annotated[str, typer.Argument()],
29
- ):
30
- """Tag a mission"""
31
- try:
32
- client = AuthenticatedClient()
33
- response = client.post(
34
- "/tag/addTag",
35
- json={"mission": mission_uuid, "tagType": tagtype_uuid, "value": value},
36
- )
37
- if response.status_code < 400:
38
- print("Tagged mission")
39
- else:
40
- print(response.json())
41
- print("Failed to tag mission")
42
- raise Exception("Failed to tag mission")
43
- except httpx.HTTPError as e:
44
- print(e)
45
- print("Failed to tag mission")
46
- raise e
47
-
48
-
49
- @missionCommands.command("list")
50
- def list_missions(
51
- project: Optional[str] = typer.Option(None, help="Name of Project"),
52
- table: Optional[bool] = typer.Option(
53
- True, help="Outputs a table with more information"
54
- ),
55
- ):
56
- """
57
- List all missions with optional filter for project.
58
- """
59
-
60
- url = "/mission"
61
- params = {}
62
- if project:
63
- url += f"/filteredByProjectName"
64
- params["projectName"] = project
65
- else:
66
- url += "/all"
67
-
68
- client = AuthenticatedClient()
69
-
70
- try:
71
-
72
- response = client.get(url, params=params)
73
- response.raise_for_status()
74
-
75
- except httpx.HTTPError:
76
-
77
- raise AccessDeniedException(
78
- f"Failed to fetch mission."
79
- f"Consider using the following command to list all missions: 'klein mission list --verbose'\n",
80
- f"{response.json()['message']} ({response.status_code})",
81
- )
82
-
83
- data = response.json()
84
- missions_by_project_uuid = {}
85
- for mission in data:
86
- project_uuid = mission["project"]["uuid"]
87
- if project_uuid not in missions_by_project_uuid:
88
- missions_by_project_uuid[project_uuid] = []
89
- missions_by_project_uuid[project_uuid].append(mission)
90
-
91
- if len(missions_by_project_uuid.items()) == 0:
92
- print(f"No missions found for project '{project}'. Does it exist?")
93
- return
94
-
95
- print("missions by Project:")
96
- if not table:
97
- for project_uuid, missions in missions_by_project_uuid.items():
98
- print(f"* {missions_by_project_uuid[project_uuid][0]['project']['name']}")
99
- for mission in missions:
100
- print(f" - {mission['name']}")
101
-
102
- else:
103
- table = Table(
104
- "project",
105
- "name",
106
- "UUID",
107
- "creator",
108
- "createdAt",
109
- title="Missions",
110
- expand=True,
111
- )
112
- for project_uuid, missions in missions_by_project_uuid.items():
113
- for mission in missions:
114
- table.add_row(
115
- mission["project"]["name"],
116
- mission["name"],
117
- mission["uuid"],
118
- mission["creator"]["name"],
119
- mission["createdAt"],
120
- )
121
- console = Console()
122
- console.print(table)
123
-
124
-
125
- @missionCommands.command("byUUID")
126
- def mission_by_uuid(
127
- uuid: Annotated[str, typer.Argument()],
128
- json: Optional[bool] = typer.Option(False, help="Output as JSON"),
129
- ):
130
- """
131
- Get mission name, project name, creator and table of its files given a Mission UUID
132
-
133
- Use the JSON flag to output the full JSON response instead.
134
-
135
- Can be run with API Key or with login.
136
- """
137
- url = "/mission/one"
138
- client = AuthenticatedClient()
139
- response = client.get(url, params={"uuid": uuid})
140
-
141
- try:
142
- response.raise_for_status()
143
- except httpx.HTTPError:
144
- raise AccessDeniedException(
145
- f"Failed to fetch mission. "
146
- f"Consider using the following command to list all missions: 'klein mission list --verbose'\n",
147
- f"{response.json()['message']} ({response.status_code})",
148
- )
149
-
150
- data = response.json()
151
-
152
- if json:
153
- print(data)
154
- return
155
- print(f"mission: {data['name']}")
156
- print(f"Creator: {data['creator']['name']}")
157
- print("Project: " + data["project"]["name"])
158
- table = Table("Filename", "Size", "date")
159
-
160
- if "files" not in data:
161
- print("No files found for mission.")
162
- return
163
-
164
- for file in data["files"]:
165
- table.add_row(file["filename"], f"{file['size']}", file["date"])
166
- console = Console()
167
- console.print(table)
168
-
169
-
170
- @missionCommands.command("download")
171
- def download(
172
- mission_uuid: Annotated[
173
- List[str], typer.Option(help="UUIDs of Mission to download")
174
- ],
175
- local_path: Annotated[str, typer.Option()],
176
- pattern: Optional[str] = typer.Option(
177
- None,
178
- help="Simple pattern to match the filename against. Allowed are alphanumeric characters,"
179
- " '_', '-', '.' and '*' as wildcard.",
180
- ),
181
- ):
182
- """
183
-
184
- Downloads all files of a mission to a local path.
185
- The local path must be an empty directory.
186
-
187
- """
188
-
189
- if not os.path.isdir(local_path):
190
- raise ValueError(f"Local path '{local_path}' is not a directory.")
191
- if not os.listdir(local_path) == []:
192
-
193
- full_local_path = os.path.abspath(local_path)
194
-
195
- raise ValueError(
196
- f"Local path '{full_local_path}' is not empty, it contains {len(os.listdir(local_path))} files. "
197
- f"The local target directory must be empty."
198
- )
199
-
200
- client = AuthenticatedClient()
201
- for single_mission_uuid in mission_uuid:
202
- response = client.get("/mission/download", params={"uuid": single_mission_uuid})
203
- try:
204
- response.raise_for_status()
205
- except httpx.HTTPError as e:
206
- raise AccessDeniedException(
207
- f"Failed to download file."
208
- f"Consider using the following command to list all missions: 'klein mission list --verbose'\n",
209
- f"{response.json()['message']} ({response.status_code})",
210
- )
211
-
212
- paths = response.json()
213
- if len(paths) == 0:
214
- continue
215
-
216
- # validate search pattern
217
- if pattern:
218
- if not re.match(r"^[a-zA-Z0-9_\-.*]+$", pattern):
219
- raise ValueError(
220
- "Invalid pattern. Allowed are alphanumeric characters, '_', '-', '.' and '*' as wildcard."
221
- )
222
-
223
- regex = pattern.replace("*", ".*")
224
- pattern = re.compile(regex)
225
-
226
- print(f"Found {len(paths)} files in mission:")
227
- paths = [
228
- path for path in paths if not pattern or pattern.match(path["filename"])
229
- ]
230
-
231
- if pattern:
232
- print(
233
- f" » filtered to {len(paths)} files matching pattern '{pattern.pattern}'."
234
- )
235
-
236
- print(f"Start downloading {len(paths)} files to '{local_path}':\n")
237
- for path in paths:
238
-
239
- filename = path["filename"]
240
-
241
- response = requests.get(path["link"], stream=True) # Enable streaming mode
242
- chunk_size = 1024 * 1024 * 10 # 10 MB chunks, adjust size if needed
243
-
244
- # Open the file for writing in binary mode
245
- with open(os.path.join(local_path, filename), "wb") as f:
246
- for chunk in tqdm(
247
- response.iter_content(chunk_size=chunk_size),
248
- unit="MB",
249
- desc=filename,
250
- ):
251
- if chunk: # Filter out keep-alive new chunks
252
- f.write(chunk)
253
-
254
-
255
- @missionCommands.command("upload")
256
- def upload(
257
- path: Annotated[
258
- List[str],
259
- typer.Option(prompt=True, help="Path to files to upload, Regex supported"),
260
- ],
261
- mission_uuid: Annotated[
262
- str, typer.Option(prompt=True, help="UUID of Mission to create")
263
- ],
264
- ):
265
- """
266
- Upload files matching the path to a mission in a project.
267
-
268
- The mission name must be unique within the project and already created.
269
- Multiple paths can be given by using the option multiple times.\n
270
- \n
271
- Examples:\n
272
- - 'klein upload --path "~/data/**/*.bag" --mission-uuid "2518cfc2-07f2-41a5-b74c-fdedb1b97f88" '\n
273
-
274
- """
275
- files = []
276
- for p in path:
277
- files.extend(expand_and_match(p))
278
- filenames = list(
279
- map(lambda x: x.split("/")[-1], filter(lambda x: not os.path.isdir(x), files))
280
- )
281
- if not filenames:
282
- raise ValueError("No files found matching the given path.")
283
-
284
- print(f"Uploading the following files to mission '{mission_uuid}':")
285
- filepaths = {}
286
- for path in files:
287
- if not os.path.isdir(path):
288
- filepaths[path.split("/")[-1]] = path
289
- typer.secho(f" - {path}", fg=typer.colors.RESET)
290
-
291
- try:
292
- client = AuthenticatedClient()
293
- get_temporary_credentials = "/file/temporaryAccess"
294
- response = client.post(
295
- get_temporary_credentials,
296
- json={"filenames": filenames, "missionUUID": mission_uuid},
297
- )
298
- if response.status_code >= 400:
299
- raise ValueError(
300
- "Failed to upload data. Status Code: "
301
- + str(response.status_code)
302
- + "\n"
303
- + response.json()["message"][0]
304
- )
305
-
306
- uploadFiles(response.json(), filepaths, 4)
307
- except Exception as e:
308
- print(e)
309
- print("Failed to upload files")
310
- raise e