primitive 0.2.70__py3-none-any.whl → 0.2.72__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 primitive might be problematic. Click here for more details.

primitive/__about__.py CHANGED
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2025-present Dylan Stein <dylan@primitive.tech>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
- __version__ = "0.2.70"
4
+ __version__ = "0.2.72"
primitive/agent/pxe.py ADDED
@@ -0,0 +1,71 @@
1
+ from loguru import logger
2
+
3
+ from primitive.network.redfish import RedfishClient
4
+ from primitive.network.ssh import test_ssh_connection, run_command
5
+
6
+
7
+ def pxe_boot_via_redfish(target_hardware_secret: dict):
8
+ bmc_host = target_hardware_secret.get("bmcHostname", None)
9
+ bmc_username = target_hardware_secret.get("bmcUsername", None)
10
+ bmc_password = target_hardware_secret.get("bmcPassword", "")
11
+
12
+ if bmc_host is None:
13
+ logger.error(
14
+ "No BMC host found in target hardware secret for out-of-band power cycle"
15
+ )
16
+ return True
17
+ if bmc_username is None:
18
+ logger.error(
19
+ "No BMC username found in target hardware secret for out-of-band power cycle"
20
+ )
21
+ return True
22
+
23
+ redfish = RedfishClient(host=bmc_host, username=bmc_username, password=bmc_password)
24
+ redfish.update_boot_options(
25
+ system_id="1",
26
+ boot_source_override_target="Pxe",
27
+ boot_source_override_enabled="Once",
28
+ boot_source_override_mode="UEFI",
29
+ )
30
+ redfish.compute_system_reset(system_id="1", reset_type="ForceRestart")
31
+
32
+
33
+ def pxe_boot_via_efibootmgr(target_hardware_secret: dict):
34
+ run_command(
35
+ hostname=target_hardware_secret.get("hostname"),
36
+ username=target_hardware_secret.get("username"),
37
+ password=target_hardware_secret.get("password"),
38
+ command="sudo efibootmgr -n $(efibootmgr | awk '/PXE IPV4/ {print substr($1,5,4)}' | head -n1 || efibootmgr | awk '/ubuntu/ {print substr($1,5,4)}' | head -n1) && sudo reboot",
39
+ port=22,
40
+ )
41
+
42
+
43
+ def pxe_boot(target_hardware_secret: dict) -> bool:
44
+ redfish_available = False
45
+ ssh_available = False
46
+
47
+ if target_hardware_secret.get("bmcHostname", None):
48
+ redfish_available = True
49
+ else:
50
+ logger.info(
51
+ "No BMC credentials found, skipping Redfish PXE boot method. Checking efiboot method."
52
+ )
53
+
54
+ ssh_available = test_ssh_connection(
55
+ hostname=target_hardware_secret.get("hostname"),
56
+ username=target_hardware_secret.get("username"),
57
+ password=target_hardware_secret.get("password"),
58
+ port=22,
59
+ )
60
+
61
+ if redfish_available:
62
+ pxe_boot_via_redfish(target_hardware_secret=target_hardware_secret)
63
+ return True
64
+ elif ssh_available:
65
+ pxe_boot_via_efibootmgr(target_hardware_secret=target_hardware_secret)
66
+ return True
67
+ else:
68
+ logger.error(
69
+ "No available method to PXE boot target hardware. Missing BMC credentials and SSH is not available."
70
+ )
71
+ return False
primitive/agent/runner.py CHANGED
@@ -10,6 +10,7 @@ from datetime import datetime, timezone
10
10
 
11
11
  from loguru import logger
12
12
 
13
+ from primitive.agent.pxe import pxe_boot
13
14
  from primitive.network.ssh import wait_for_ssh
14
15
  from primitive.utils.cache import get_artifacts_cache, get_logs_cache, get_sources_cache
15
16
  from primitive.utils.logging import fmt, log_context
@@ -287,33 +288,9 @@ class Runner:
287
288
 
288
289
  if cmd == "pxeboot":
289
290
  logger.info("Setting next boot to PXE and rebooting")
290
- from primitive.network.redfish import RedfishClient
291
-
292
- bmc_host = self.target_hardware_secret.get("bmcHostname", None)
293
- bmc_username = self.target_hardware_secret.get("bmcUsername", None)
294
- bmc_password = self.target_hardware_secret.get("bmcPassword", "")
295
291
 
296
- if bmc_host is None:
297
- logger.error(
298
- "No BMC host found in target hardware secret for out-of-band power cycle"
299
- )
300
- return True
301
- if bmc_username is None:
302
- logger.error(
303
- "No BMC username found in target hardware secret for out-of-band power cycle"
304
- )
305
- return True
292
+ pxe_boot(target_hardware_secret=self.target_hardware_secret)
306
293
 
307
- redfish = RedfishClient(
308
- host=bmc_host, username=bmc_username, password=bmc_password
309
- )
310
- redfish.update_boot_options(
311
- system_id="1",
312
- boot_source_override_target="Pxe",
313
- boot_source_override_enabled="Once",
314
- boot_source_override_mode="UEFI",
315
- )
316
- redfish.compute_system_reset(system_id="1", reset_type="ForceRestart")
317
294
  if self.target_hardware_id:
318
295
  await self.primitive.hardware.aupdate_hardware(
319
296
  hardware_id=self.target_hardware_id,
@@ -421,7 +421,10 @@ class Hardware(BaseAction):
421
421
  def _get_ubuntu_installed_packages(self):
422
422
  try:
423
423
  lines = (
424
- subprocess.check_output(["apt", "list", "--installed"])
424
+ subprocess.check_output(
425
+ ["apt", "list", "--installed"],
426
+ stderr=subprocess.DEVNULL,
427
+ )
425
428
  .decode("utf-8")
426
429
  .strip()
427
430
  .split("\n")
@@ -793,7 +796,7 @@ class Hardware(BaseAction):
793
796
  if slug is not None:
794
797
  filters["slug"] = {"exact": slug}
795
798
  if id is not None:
796
- filters["id"] = {"exact": id}
799
+ filters["id"] = id
797
800
  # if nested_children is True:
798
801
  # filters["hasParent"] = {"exact": False}
799
802
 
@@ -1206,3 +1209,11 @@ class Hardware(BaseAction):
1206
1209
  logger.info(message)
1207
1210
 
1208
1211
  return result
1212
+
1213
+ def push_own_system_info(self):
1214
+ if self.primitive.messaging.ready:
1215
+ message = {"system_info": self.primitive.hardware.get_system_info()}
1216
+ self.primitive.messaging.send_message(
1217
+ message_type=MESSAGE_TYPES.SYSTEM_INFO,
1218
+ message=message,
1219
+ )
@@ -27,6 +27,7 @@ CELERY_TASK_NAME = "hardware.tasks.task_receive_hardware_message"
27
27
 
28
28
  class MESSAGE_TYPES(enum.Enum):
29
29
  CHECK_IN = "CHECK_IN"
30
+ SYSTEM_INFO = "SYSTEM_INFO"
30
31
  METRICS = "METRICS"
31
32
  SWITCH_AND_INTERFACES_INFO = "SWITCH_AND_INTERFACES_INFO"
32
33
  OWN_NETWORK_INTERFACES = "OWN_NETWORK_INTERFACES"
@@ -94,7 +94,7 @@ class Monitor(BaseAction):
94
94
  if hardware.get("isController", False):
95
95
  self.primitive.hardware.get_and_set_switch_info()
96
96
  self.primitive.network.push_switch_and_interfaces_info()
97
- self.primitive.network.push_own_network_interfaces()
97
+ self.primitive.hardware.push_own_system_info()
98
98
  self.primitive.hardware._sync_children(hardware=hardware)
99
99
 
100
100
  sleep_amount = 5
@@ -8,6 +8,7 @@ from paramiko import SSHClient
8
8
  from typing import TypedDict
9
9
  import re
10
10
 
11
+ from primitive.hardware.actions import does_executable_exist
11
12
  from primitive.messaging.provider import MESSAGE_TYPES
12
13
  from primitive.utils.actions import BaseAction
13
14
 
@@ -372,6 +373,9 @@ class Network(BaseAction):
372
373
  return ip_to_mac_address_info
373
374
 
374
375
  def get_ip_arp_table_via_ip_command(self):
376
+ if does_executable_exist("ip") is False:
377
+ return []
378
+
375
379
  command = "ip --json neigh show"
376
380
  ip_result = None
377
381
  with Popen(command.split(" "), stdout=PIPE) as process:
@@ -458,6 +462,7 @@ class Network(BaseAction):
458
462
  return False
459
463
 
460
464
  def push_switch_and_interfaces_info(self, interfaces_info: dict | None = None):
465
+ logger.debug("Pushing switch and interfaces info")
461
466
  if self.primitive.messaging.ready and self.switch_connection_info is not None:
462
467
  switch_info = self.get_switch_info()
463
468
  interfaces_info = interfaces_info or self.get_interfaces_info()
@@ -473,13 +478,4 @@ class Network(BaseAction):
473
478
  message_type=MESSAGE_TYPES.SWITCH_AND_INTERFACES_INFO,
474
479
  message=message,
475
480
  )
476
-
477
- def push_own_network_interfaces(self):
478
- if self.primitive.messaging.ready:
479
- message = {
480
- "network_interfaces": self.primitive.hardware._get_network_interfaces()
481
- }
482
- self.primitive.messaging.send_message(
483
- message_type=MESSAGE_TYPES.OWN_NETWORK_INTERFACES,
484
- message=message,
485
- )
481
+ logger.debug("Switch and interfaces info pushed")
primitive/network/ssh.py CHANGED
@@ -27,7 +27,13 @@ def test_ssh_connection(hostname, username, password=None, key_filename=None, po
27
27
  try:
28
28
  if password:
29
29
  ssh_client.connect(
30
- hostname=hostname, port=port, username=username, password=password
30
+ hostname=hostname,
31
+ port=port,
32
+ username=username,
33
+ password=password,
34
+ banner_timeout=60,
35
+ timeout=60,
36
+ auth_timeout=60,
31
37
  )
32
38
  elif key_filename:
33
39
  ssh_client.connect(
@@ -35,26 +41,29 @@ def test_ssh_connection(hostname, username, password=None, key_filename=None, po
35
41
  port=port,
36
42
  username=username,
37
43
  key_filename=key_filename,
44
+ banner_timeout=60,
45
+ timeout=60,
46
+ auth_timeout=60,
38
47
  )
39
48
  else:
40
- print(
49
+ logger.error(
41
50
  "Error: Either password or key_filename must be provided for authentication."
42
51
  )
43
52
  return False
44
53
 
45
- print(f"Successfully connected to {hostname} as {username}")
54
+ logger.info(f"Successfully connected to {hostname} as {username}")
46
55
  return True
47
56
  except paramiko.AuthenticationException:
48
- print(f"Authentication failed for {username} on {hostname}")
57
+ logger.debug(f"Authentication failed for {username} on {hostname}")
49
58
  return False
50
59
  except paramiko.SSHException as exception:
51
- print(f"SSH error connecting to {hostname}: {exception}")
60
+ logger.debug(f"SSH error connecting to {hostname}: {exception}")
52
61
  return False
53
62
  except socket.error as exception:
54
- print(f"Socket error connecting to {hostname}: {exception}")
63
+ logger.debug(f"Socket error connecting to {hostname}: {exception}")
55
64
  return False
56
65
  except Exception as exception:
57
- print(f"An unexpected error occurred: {exception}")
66
+ logger.debug(f"An unexpected error occurred: {exception}")
58
67
  return False
59
68
  finally:
60
69
  ssh_client.close()
@@ -17,13 +17,16 @@ import os
17
17
  from loguru import logger
18
18
 
19
19
  from primitive.utils.checksums import get_checksum_from_file, calculate_sha256
20
+ from primitive.utils.text import slugify
20
21
 
21
22
 
22
23
  class OperatingSystems(BaseAction):
23
24
  def __init__(self, primitive):
24
25
  super().__init__(primitive)
26
+ self.operating_systems_key_prefix = "operating-systems"
25
27
  self.remote_operating_systems = {
26
28
  "ubuntu-24-04-3": {
29
+ "slug": "ubuntu-24-04-3",
27
30
  "iso": "https://releases.ubuntu.com/24.04.3/ubuntu-24.04.3-desktop-amd64.iso",
28
31
  "checksum": "https://releases.ubuntu.com/24.04.3/SHA256SUMS",
29
32
  "checksum_file_type": self.OperatingSystemChecksumFileType.SHA256SUMS,
@@ -33,13 +36,17 @@ class OperatingSystems(BaseAction):
33
36
  class OperatingSystemChecksumFileType(Enum):
34
37
  SHA256SUMS = "SHA256SUMS"
35
38
 
36
- def get_remote_operating_system_names(self):
37
- return list(self.remote_operating_systems.keys())
39
+ def list_remotes(self):
40
+ return self.remote_operating_systems.values()
38
41
 
39
- def _download_remote_operating_system_iso(self, remote_operating_system_name):
40
- operating_system_dir = Path(
41
- get_operating_systems_cache() / remote_operating_system_name
42
- )
42
+ def get_remote_info(self, slug: str):
43
+ return self.remote_operating_systems[slug]
44
+
45
+ def _download_remote_operating_system_iso(
46
+ self, remote_operating_system_name: str, directory: str | None = None
47
+ ):
48
+ cache_dir = Path(directory) if directory else get_operating_systems_cache()
49
+ operating_system_dir = Path(cache_dir / remote_operating_system_name)
43
50
  iso_dir = Path(operating_system_dir / "iso")
44
51
  os.makedirs(iso_dir, exist_ok=True)
45
52
 
@@ -73,10 +80,11 @@ class OperatingSystems(BaseAction):
73
80
 
74
81
  return iso_file_path
75
82
 
76
- def _download_remote_operating_system_checksum(self, remote_operating_system_name):
77
- operating_system_dir = Path(
78
- get_operating_systems_cache() / remote_operating_system_name
79
- )
83
+ def _download_remote_operating_system_checksum(
84
+ self, remote_operating_system_name: str, directory: str | None = None
85
+ ):
86
+ cache_dir = Path(directory) if directory else get_operating_systems_cache()
87
+ operating_system_dir = Path(cache_dir / remote_operating_system_name)
80
88
  checksum_dir = Path(operating_system_dir / "checksum")
81
89
  os.makedirs(checksum_dir, exist_ok=True)
82
90
 
@@ -103,8 +111,10 @@ class OperatingSystems(BaseAction):
103
111
 
104
112
  return checksum_file_path
105
113
 
106
- def download_from_remote(self, remote_operating_system_name: str):
107
- remote_operating_system_names = self.get_remote_operating_system_names()
114
+ def download_remote(
115
+ self, remote_operating_system_name: str, directory: str | None = None
116
+ ):
117
+ remote_operating_system_names = list(self.remote_operating_systems.keys())
108
118
 
109
119
  if remote_operating_system_name not in remote_operating_system_names:
110
120
  logger.error(
@@ -115,20 +125,30 @@ class OperatingSystems(BaseAction):
115
125
  )
116
126
 
117
127
  iso_file_path = self._download_remote_operating_system_iso(
118
- remote_operating_system_name
128
+ remote_operating_system_name,
129
+ directory=directory,
119
130
  )
120
131
  checksum_file_path = self._download_remote_operating_system_checksum(
121
- remote_operating_system_name
132
+ remote_operating_system_name,
133
+ directory=directory,
122
134
  )
123
135
 
124
- return iso_file_path, checksum_file_path
136
+ logger.info("Validating iso checksum")
137
+ checksum_valid = self.primitive.operating_systems._validate_checksum(
138
+ remote_operating_system_name, str(iso_file_path), str(checksum_file_path)
139
+ )
125
140
 
126
- def get_checksum_file_type(self, operating_system_name: str):
127
- return self.remote_operating_systems[operating_system_name][
128
- "checksum_file_type"
129
- ]
141
+ if not checksum_valid:
142
+ raise Exception(
143
+ "Checksums did not match: file may have been corrupted during download."
144
+ + f"\nTry deleting the directory {get_operating_systems_cache()}/{remote_operating_system_name} and running this command again."
145
+ )
146
+
147
+ logger.info("Checksum valid")
130
148
 
131
- def validate_checksum(
149
+ return iso_file_path, checksum_file_path
150
+
151
+ def _validate_checksum(
132
152
  self,
133
153
  operating_system_name: str,
134
154
  iso_file_path: str,
@@ -138,7 +158,7 @@ class OperatingSystems(BaseAction):
138
158
  checksum_file_type = (
139
159
  checksum_file_type
140
160
  if checksum_file_type
141
- else self.get_checksum_file_type(operating_system_name)
161
+ else self.get_remote_info(operating_system_name)["checksum_file_type"]
142
162
  )
143
163
 
144
164
  match checksum_file_type:
@@ -157,8 +177,130 @@ class OperatingSystems(BaseAction):
157
177
  local_checksum = calculate_sha256(iso_file_path)
158
178
  return remote_checksum == local_checksum
159
179
 
180
+ def _upload_iso_file(
181
+ self, iso_file_path: Path, organization_id: str, operating_system_slug: str
182
+ ):
183
+ iso_upload_result = self.primitive.files.upload_file_direct(
184
+ path=iso_file_path,
185
+ organization_id=organization_id,
186
+ key_prefix=f"{self.operating_systems_key_prefix}/{operating_system_slug}",
187
+ )
188
+
189
+ if not iso_upload_result or iso_upload_result.data is None:
190
+ logger.error("Unable to upload iso file")
191
+ raise Exception("Unable to upload iso file")
192
+
193
+ iso_upload_data = iso_upload_result.data
194
+ iso_file_id = iso_upload_data.get("fileUpdate", {}).get("id")
195
+
196
+ if not iso_file_id:
197
+ logger.error("Unable to upload iso file")
198
+ raise Exception("Unable to upload iso file")
199
+
200
+ return iso_file_id
201
+
202
+ def _upload_checksum_file(
203
+ self, checksum_file_path: Path, organization_id: str, operating_system_slug: str
204
+ ):
205
+ checksum_upload_response = self.primitive.files.upload_file_via_api(
206
+ path=checksum_file_path,
207
+ organization_id=organization_id,
208
+ key_prefix=f"{self.operating_systems_key_prefix}/{operating_system_slug}",
209
+ )
210
+
211
+ if not checksum_upload_response.ok:
212
+ logger.error("Unable to upload checksum file")
213
+ raise Exception("Unable to upload checksum file")
214
+
215
+ checksum_file_id = (
216
+ checksum_upload_response.json()
217
+ .get("data", {})
218
+ .get("fileUpload", {})
219
+ .get("id", {})
220
+ )
221
+
222
+ if not checksum_file_id:
223
+ logger.error("Unable to upload checksum file")
224
+ raise Exception("Unable to upload checksum file")
225
+
226
+ return checksum_file_id
227
+
160
228
  @guard
161
- def create_operating_system(
229
+ def create(
230
+ self,
231
+ slug: str,
232
+ iso_file: str,
233
+ checksum_file: str,
234
+ checksum_file_type: str,
235
+ organization_id: str,
236
+ ):
237
+ formatted_slug = slugify(slug)
238
+ is_slug_available = self.primitive.operating_systems._is_slug_available(
239
+ slug=formatted_slug,
240
+ organization_id=organization_id,
241
+ )
242
+
243
+ if not is_slug_available:
244
+ raise Exception(
245
+ f"Operating system with slug {formatted_slug} already exists."
246
+ )
247
+
248
+ is_known_checksum_file_type = (
249
+ checksum_file_type
250
+ in self.primitive.operating_systems.OperatingSystemChecksumFileType._value2member_map_
251
+ )
252
+
253
+ if not is_known_checksum_file_type:
254
+ raise Exception(
255
+ f"Operating system checksum file type {checksum_file_type} is not supported."
256
+ + f" Supported types are: {''.join([type.value for type in self.primitive.operating_systems.OperatingSystemChecksumFileType])}"
257
+ )
258
+
259
+ iso_file_path = Path(iso_file)
260
+ checksum_file_path = Path(checksum_file)
261
+
262
+ if not iso_file_path.is_file():
263
+ raise Exception(
264
+ f"ISO file {iso_file_path} does not exist or is not a file."
265
+ )
266
+
267
+ if not checksum_file_path.is_file():
268
+ raise Exception(
269
+ f"Checksum file {checksum_file_path} does not exist or is not a file."
270
+ )
271
+
272
+ logger.info("Uploading iso file. This may take a while...")
273
+ iso_file_id = self.primitive.operating_systems._upload_iso_file(
274
+ iso_file_path=iso_file_path,
275
+ organization_id=organization_id,
276
+ operating_system_slug=formatted_slug,
277
+ )
278
+
279
+ logger.info("Uploading checksum file")
280
+ checksum_file_id = self.primitive.operating_systems._upload_checksum_file(
281
+ checksum_file_path=checksum_file_path,
282
+ organization_id=organization_id,
283
+ operating_system_slug=formatted_slug,
284
+ )
285
+
286
+ logger.info("Creating operating system in primitive.")
287
+ operating_system_create_response = (
288
+ self.primitive.operating_systems._create_query(
289
+ slug=formatted_slug,
290
+ checksum_file_id=checksum_file_id,
291
+ checksum_file_type=checksum_file_type,
292
+ organization_id=organization_id,
293
+ iso_file_id=iso_file_id,
294
+ )
295
+ )
296
+
297
+ if "id" not in operating_system_create_response:
298
+ raise Exception("Failed to create operating system")
299
+
300
+ return operating_system_create_response
301
+
302
+ @guard
303
+ def _create_query(
162
304
  self,
163
305
  slug: str,
164
306
  organization_id: str,
@@ -181,7 +323,7 @@ class OperatingSystems(BaseAction):
181
323
  return result.data.get("operatingSystemCreate")
182
324
 
183
325
  @guard
184
- def get_operating_system_list(
326
+ def list(
185
327
  self,
186
328
  organization_id: str,
187
329
  slug: str | None = None,
@@ -205,26 +347,97 @@ class OperatingSystems(BaseAction):
205
347
  query, variable_values=variables, get_execution_result=True
206
348
  )
207
349
 
208
- return result
350
+ edges = result.data.get("operatingSystemList").get("edges", [])
351
+
352
+ nodes = [edge.get("node") for edge in edges]
353
+
354
+ return nodes
209
355
 
210
356
  @guard
211
- def get_operating_system(
212
- self, organization_id: str, slug: str | None = None, id: str | None = None
357
+ def download(
358
+ self,
359
+ organization_id: str,
360
+ id: str | None = None,
361
+ slug: str | None = None,
362
+ directory: str | None = None,
213
363
  ):
364
+ operating_system = self.primitive.operating_systems.get(
365
+ organization_id=organization_id, slug=slug, id=id
366
+ )
367
+
368
+ is_cached = self.primitive.operating_systems.is_operating_system_cached(
369
+ slug=operating_system["slug"],
370
+ directory=directory,
371
+ )
372
+
373
+ if is_cached:
374
+ raise Exception(
375
+ "Operating system already exists in cache, aborting download."
376
+ )
377
+
378
+ download_directory = (
379
+ Path(directory) / operating_system["slug"]
380
+ if directory
381
+ else (get_operating_systems_cache() / operating_system["slug"])
382
+ )
383
+ checksum_directory = download_directory / "checksum"
384
+ checksum_file_path = (
385
+ checksum_directory / operating_system["checksumFile"]["fileName"]
386
+ )
387
+ iso_directory = download_directory / "iso"
388
+ iso_file_path = iso_directory / operating_system["isoFile"]["fileName"]
389
+
390
+ if not iso_directory.exists():
391
+ iso_directory.mkdir(parents=True)
392
+
393
+ if not checksum_directory.exists():
394
+ checksum_directory.mkdir(parents=True)
395
+
396
+ logger.info("Downloading operating system iso")
397
+ self.primitive.files.download_file(
398
+ file_id=operating_system["isoFile"]["id"],
399
+ output_path=iso_directory,
400
+ organization_id=organization_id,
401
+ )
402
+
403
+ logger.info("Downloading operating system checksum")
404
+ self.primitive.files.download_file(
405
+ file_id=operating_system["checksumFile"]["id"],
406
+ output_path=checksum_directory,
407
+ organization_id=organization_id,
408
+ )
409
+
410
+ logger.info("Validating iso checksum")
411
+ checksum_file_type = (
412
+ self.primitive.operating_systems.OperatingSystemChecksumFileType[
413
+ operating_system["checksumFileType"]
414
+ ]
415
+ )
416
+ checksum_valid = self.primitive.operating_systems._validate_checksum(
417
+ operating_system["slug"],
418
+ iso_file_path,
419
+ checksum_file_path,
420
+ checksum_file_type=checksum_file_type,
421
+ )
422
+
423
+ if not checksum_valid:
424
+ raise Exception(
425
+ "Checksums did not match: file may have been corrupted during download."
426
+ + f"\nTry deleting the directory {get_operating_systems_cache()}/{operating_system['slug']} and running this command again."
427
+ )
428
+
429
+ return download_directory
430
+
431
+ @guard
432
+ def get(self, organization_id: str, slug: str | None = None, id: str | None = None):
214
433
  if not (slug or id):
215
434
  raise Exception("Slug or id must be provided.")
216
435
  if slug and id:
217
436
  raise Exception("Only one of slug or id must be provided.")
218
437
 
219
- operating_system_list_result = self.get_operating_system_list(
220
- organization_id=organization_id, slug=slug, id=id
221
- )
222
-
223
- edges = operating_system_list_result.data.get("operatingSystemList").get(
224
- "edges", []
225
- )
438
+ operating_systems = self.list(organization_id=organization_id, slug=slug, id=id)
226
439
 
227
- if len(edges) == 0:
440
+ if len(operating_systems) == 0:
228
441
  if slug:
229
442
  logger.error(f"No operating system found for slug '{slug}'.")
230
443
  raise Exception(f"No operating system found for slug {slug}.")
@@ -232,10 +445,10 @@ class OperatingSystems(BaseAction):
232
445
  logger.error(f"No operating system found for ID {id}.")
233
446
  raise Exception(f"No operating system found for ID {id}.")
234
447
 
235
- return edges[0].get("node")
448
+ return operating_systems[0]
236
449
 
237
450
  @guard
238
- def is_slug_available(self, slug: str, organization_id: str):
451
+ def _is_slug_available(self, slug: str, organization_id: str):
239
452
  query = gql(operating_system_list_query)
240
453
 
241
454
  variables = {
@@ -1,11 +1,7 @@
1
- from pathlib import Path
2
-
3
1
  import click
4
2
  import typing
5
3
  from loguru import logger
6
4
 
7
- from ..utils.cache import get_operating_systems_cache
8
- from ..utils.text import slugify
9
5
 
10
6
  if typing.TYPE_CHECKING:
11
7
  from ..client import Primitive
@@ -18,22 +14,14 @@ def cli(context):
18
14
  pass
19
15
 
20
16
 
21
- @cli.command("download")
22
- @click.option("--id", help="Operating system ID", required=False)
23
- @click.option("--slug", help="Operating system slug", required=False)
24
- @click.option("--organization-slug", help="Organization slug", required=False)
17
+ @cli.command("list")
25
18
  @click.option(
26
- "--directory",
27
- help="Directory to download the operating system files to",
19
+ "--organization-slug",
20
+ help="Organization slug to list operating systems for",
28
21
  required=False,
29
22
  )
30
23
  @click.pass_context
31
- def download(context, id, slug, organization_slug, directory):
32
- if not (id or slug):
33
- raise click.UsageError("You must provide either --id or --slug.")
34
- if id and slug:
35
- raise click.UsageError("You can only specify one of --id or --slug.")
36
-
24
+ def operating_systems_list_command(context, organization_slug):
37
25
  primitive: Primitive = context.obj.get("PRIMITIVE")
38
26
 
39
27
  organization = (
@@ -44,81 +32,114 @@ def download(context, id, slug, organization_slug, directory):
44
32
 
45
33
  if not organization:
46
34
  if organization_slug:
47
- logger.error(f"No organization found with slug {slug}")
35
+ logger.error(f"No organization found with slug {organization_slug}")
48
36
  else:
49
37
  logger.error("Failed to fetch default organization")
50
38
 
51
- try:
52
- operating_system = primitive.operating_systems.get_operating_system(
53
- organization_id=organization["id"], slug=slug, id=id
54
- )
55
- except Exception:
56
- logger.error("Unable to fetch operating system")
57
- return
58
-
59
- is_cached = primitive.operating_systems.is_operating_system_cached(
60
- slug=operating_system["slug"],
61
- directory=directory,
39
+ operating_systems = primitive.operating_systems.list(
40
+ organization_id=organization["id"]
62
41
  )
42
+ operating_system_slugs = [
43
+ operating_system["slug"] for operating_system in operating_systems
44
+ ]
63
45
 
64
- if is_cached:
65
- logger.info("Operating system already exists in cache, aborting download.")
66
- return
67
-
68
- operating_system_directory = (
69
- Path(directory) / operating_system["slug"]
70
- if directory
71
- else (get_operating_systems_cache() / operating_system["slug"])
46
+ newline = "\n"
47
+ logger.info(
48
+ f"Operating systems: {newline}- {f'{newline}- '.join(operating_system_slugs)}"
72
49
  )
73
- checksum_directory = operating_system_directory / "checksum"
74
- checksum_file_path = (
75
- checksum_directory / operating_system["checksumFile"]["fileName"]
76
- )
77
- iso_directory = operating_system_directory / "iso"
78
- iso_file_path = iso_directory / operating_system["isoFile"]["fileName"]
79
50
 
80
- if not iso_directory.exists():
81
- iso_directory.mkdir(parents=True)
82
51
 
83
- if not checksum_directory.exists():
84
- checksum_directory.mkdir(parents=True)
52
+ @cli.command("create")
53
+ @click.option("--slug", help="Slug for created operating system", required=True)
54
+ @click.option(
55
+ "--iso-file", help="Path to operating system iso file to upload", required=True
56
+ )
57
+ @click.option(
58
+ "--checksum-file",
59
+ help="Path to operating system checksum file to upload",
60
+ required=True,
61
+ )
62
+ @click.option(
63
+ "--checksum-file-type", help="The type of the checksum file", required=True
64
+ )
65
+ @click.option(
66
+ "--organization-slug",
67
+ help="Organization to create the operating system in",
68
+ required=False,
69
+ )
70
+ @click.pass_context
71
+ def create_command(
72
+ context, slug, iso_file, checksum_file, checksum_file_type, organization_slug
73
+ ):
74
+ primitive: Primitive = context.obj.get("PRIMITIVE")
85
75
 
86
- logger.info("Downloading operating system iso")
87
- primitive.files.download_file(
88
- file_id=operating_system["isoFile"]["id"],
89
- output_path=iso_directory,
90
- organization_id=organization["id"],
76
+ organization = (
77
+ primitive.organizations.get_organization(slug=organization_slug)
78
+ if organization_slug
79
+ else primitive.organizations.get_default_organization()
91
80
  )
92
81
 
93
- logger.info("Downloading operating system checksum")
94
- primitive.files.download_file(
95
- file_id=operating_system["checksumFile"]["id"],
96
- output_path=checksum_directory,
97
- organization_id=organization["id"],
98
- )
82
+ if not organization:
83
+ if organization_slug:
84
+ logger.error(f"No organization found with slug {organization_slug}")
85
+ return
86
+ else:
87
+ logger.error("Failed to fetch default organization")
88
+ return
99
89
 
100
- logger.info("Validating iso checksum")
101
90
  try:
102
- checksum_file_type = (
103
- primitive.operating_systems.OperatingSystemChecksumFileType[
104
- operating_system["checksumFileType"]
105
- ]
106
- )
107
- checksum_valid = primitive.operating_systems.validate_checksum(
108
- operating_system["slug"],
109
- iso_file_path,
110
- checksum_file_path,
91
+ primitive.operating_systems.create(
92
+ slug=slug,
93
+ iso_file=iso_file,
94
+ checksum_file=checksum_file,
111
95
  checksum_file_type=checksum_file_type,
96
+ organization_id=organization["id"],
112
97
  )
113
- except Exception:
114
- logger.error("Failed to validate checksum.")
98
+ except Exception as error:
99
+ logger.error(error)
115
100
  return
116
101
 
117
- if not checksum_valid:
118
- logger.error(
119
- "Checksums did not match: file may have been corrupted during download."
120
- + f"\nTry deleting the directory {get_operating_systems_cache()}/{operating_system['slug']} and running this command again."
102
+ logger.success("Operating system created in primitive.")
103
+
104
+
105
+ @cli.command("download")
106
+ @click.option("--id", help="Operating system ID", required=False)
107
+ @click.option("--slug", help="Operating system slug", required=False)
108
+ @click.option("--organization-slug", help="Organization slug", required=False)
109
+ @click.option(
110
+ "--directory",
111
+ help="Directory to download the operating system files to",
112
+ required=False,
113
+ )
114
+ @click.pass_context
115
+ def download(context, id, slug, organization_slug, directory):
116
+ if not (id or slug):
117
+ raise click.UsageError("You must provide either --id or --slug.")
118
+ if id and slug:
119
+ raise click.UsageError("You can only specify one of --id or --slug.")
120
+
121
+ primitive: Primitive = context.obj.get("PRIMITIVE")
122
+
123
+ organization = (
124
+ primitive.organizations.get_organization(slug=organization_slug)
125
+ if organization_slug
126
+ else primitive.organizations.get_default_organization()
127
+ )
128
+
129
+ if not organization:
130
+ if organization_slug:
131
+ logger.error(f"No organization found with slug {organization_slug}")
132
+ return
133
+ else:
134
+ logger.error("Failed to fetch default organization")
135
+ return
136
+
137
+ try:
138
+ operating_system_directory = primitive.operating_systems.download(
139
+ id=id, slug=slug, organization_id=organization["id"], directory=directory
121
140
  )
141
+ except Exception as error:
142
+ logger.error(error)
122
143
  return
123
144
 
124
145
  logger.success(
@@ -133,6 +154,30 @@ def remotes(context):
133
154
  pass
134
155
 
135
156
 
157
+ @remotes.command("download")
158
+ @click.pass_context
159
+ @click.argument("operating-system")
160
+ @click.option(
161
+ "--directory",
162
+ help="Directory to download the operating system files to",
163
+ required=False,
164
+ )
165
+ def operating_system_remotes_download_command(
166
+ context, operating_system, directory=None
167
+ ):
168
+ primitive: Primitive = context.obj.get("PRIMITIVE")
169
+
170
+ try:
171
+ primitive.operating_systems.download_remote(
172
+ remote_operating_system_name=operating_system, directory=directory
173
+ )
174
+ except Exception as error:
175
+ logger.error(error)
176
+ return
177
+
178
+ logger.success(f"Successfully downloaded operating system files to {directory}")
179
+
180
+
136
181
  @remotes.command("mirror")
137
182
  @click.pass_context
138
183
  @click.argument("operating-system")
@@ -142,13 +187,16 @@ def remotes(context):
142
187
  help="Slug of the organization to upload the operating system to",
143
188
  required=False,
144
189
  )
190
+ @click.option(
191
+ "--directory",
192
+ help="Directory to download the operating system files to",
193
+ required=False,
194
+ )
145
195
  def operating_system_mirror_command(
146
- context, operating_system, slug=None, organization_slug=None
196
+ context, operating_system, slug=None, organization_slug=None, directory=None
147
197
  ):
148
198
  primitive: Primitive = context.obj.get("PRIMITIVE")
149
199
 
150
- operating_system_slug = slugify(slug) if slug else slugify(operating_system)
151
-
152
200
  organization = (
153
201
  primitive.organizations.get_organization(slug=organization_slug)
154
202
  if organization_slug
@@ -157,112 +205,42 @@ def operating_system_mirror_command(
157
205
 
158
206
  if not organization:
159
207
  if organization_slug:
160
- logger.error(f"No organization found with slug {slug}.")
208
+ logger.error(f"No organization found with slug {organization_slug}.")
209
+ return
161
210
  else:
162
211
  logger.error("Failed to fetch default organization.")
212
+ return
163
213
 
164
- is_slug_available = primitive.operating_systems.is_slug_available(
165
- slug=operating_system_slug,
166
- organization_id=organization["id"],
167
- )
168
-
169
- if not is_slug_available:
170
- logger.error(
171
- f"Operating system slug {operating_system_slug} already exists."
172
- + " Please specify the --slug parameter to mirror the operating system with a different slug."
173
- )
174
- return
175
-
176
- try:
177
- iso_file_path, checksum_file_path = (
178
- primitive.operating_systems.download_from_remote(operating_system)
179
- )
180
- except Exception:
181
- logger.error("Unable to download operating system")
182
- return
183
-
184
- logger.info("Validating iso checksum")
185
214
  try:
186
- checksum_valid = primitive.operating_systems.validate_checksum(
187
- operating_system, iso_file_path, checksum_file_path
215
+ iso_file_path, checksum_file_path = primitive.operating_systems.download_remote(
216
+ operating_system, directory=directory
188
217
  )
189
- except Exception:
190
- logger.error("Failed to validate checksum.")
191
- return
192
-
193
- if not checksum_valid:
194
- logger.error(
195
- "Checksums did not match: file may have been corrupted during download."
196
- + f"\nTry deleting the directory {get_operating_systems_cache()}/{operating_system} and running this command again."
197
- )
198
- return
199
218
 
200
- logger.info("Checksum valid")
219
+ checksum_file_type = primitive.operating_systems.get_remote_info(
220
+ operating_system
221
+ )["checksum_file_type"]
201
222
 
202
- logger.info("Uploading operating system files to primitive.")
203
- file_key_prefix = "operating-systems"
204
-
205
- iso_upload_result = primitive.files.upload_file_direct(
206
- path=iso_file_path,
207
- organization_id=organization["id"],
208
- key_prefix=file_key_prefix,
209
- )
210
-
211
- if iso_upload_result and iso_upload_result.data is not None:
212
- iso_upload_data = iso_upload_result.data
213
- iso_file_id = iso_upload_data.get("fileUpdate", {}).get("id")
214
-
215
- if not iso_file_id:
216
- logger.error("Unable to upload iso file")
217
- return
218
-
219
- checksum_upload_response = primitive.files.upload_file_via_api(
220
- path=checksum_file_path,
221
- organization_id=organization["id"],
222
- key_prefix=file_key_prefix,
223
- )
224
-
225
- if not checksum_upload_response.ok:
226
- logger.error("Unable to upload checksum file")
227
- return
228
-
229
- checksum_file_id = (
230
- checksum_upload_response.json()
231
- .get("data", {})
232
- .get("fileUpload", {})
233
- .get("id", {})
234
- )
235
-
236
- if not checksum_file_id:
237
- logger.error("Unable to upload checksum file")
238
- return
239
-
240
- logger.info("Creating operating system in primitive.")
241
- operating_system_create_response = (
242
- primitive.operating_systems.create_operating_system(
243
- slug=operating_system_slug,
244
- checksum_file_id=checksum_file_id,
245
- checksum_file_type=primitive.operating_systems.get_checksum_file_type(
246
- operating_system
247
- ).value,
223
+ primitive.operating_systems.create(
224
+ slug=slug if slug else operating_system,
225
+ iso_file=iso_file_path,
226
+ checksum_file=checksum_file_path,
227
+ checksum_file_type=checksum_file_type.value,
248
228
  organization_id=organization["id"],
249
- iso_file_id=iso_file_id,
250
229
  )
251
- )
252
-
253
- if "id" not in operating_system_create_response:
254
- logger.error("Failed to create operating system")
230
+ except Exception as error:
231
+ logger.error(error)
255
232
  return
256
233
 
257
- logger.success("Operating system created in primitive.")
234
+ logger.success("Successfully mirrored operating system")
258
235
 
259
236
 
260
237
  @remotes.command("list")
261
238
  @click.pass_context
262
- def operating_system_list_command(context):
239
+ def remote_operating_systems_list_command(context):
263
240
  primitive: Primitive = context.obj.get("PRIMITIVE")
264
- remote_operating_system_names = (
265
- primitive.operating_systems.get_remote_operating_system_names()
241
+ remotes_list = primitive.operating_systems.list_remotes()
242
+ remote_slugs = [remote["slug"] for remote in remotes_list]
243
+ newline = "\n"
244
+ logger.info(
245
+ f"Remote operating systems: {newline}- {f'{newline}- '.join(remote_slugs)}"
266
246
  )
267
- logger.info("Remote operating systems available for download:")
268
- logger.info("\n".join(remote_operating_system_names))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: primitive
3
- Version: 0.2.70
3
+ Version: 0.2.72
4
4
  Project-URL: Documentation, https://github.com//primitivecorp/primitive-cli#readme
5
5
  Project-URL: Issues, https://github.com//primitivecorp/primitive-cli/issues
6
6
  Project-URL: Source, https://github.com//primitivecorp/primitive-cli
@@ -1,11 +1,12 @@
1
- primitive/__about__.py,sha256=NOrCGYjwMjgYW_AaVMV1_PDAVaXvQrgfkCKN6ly7xDM,130
1
+ primitive/__about__.py,sha256=pYIjjw7lxGTZ015Ir3hYhbZnOjU5lRO-cLrPQE5MBpw,130
2
2
  primitive/__init__.py,sha256=bwKdgggKNVssJFVPfKSxqFMz4IxSr54WWbmiZqTMPNI,106
3
3
  primitive/cli.py,sha256=ga1TcPKyUkGNGZ76CjIQqTKWn1r9di5k_uRbLljY07w,2745
4
4
  primitive/client.py,sha256=EyXLOFwKUT_ZB3at5m4hbdtjgaKVVFoiDdE0bP0_JfI,3966
5
5
  primitive/agent/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  primitive/agent/actions.py,sha256=SnYJQKOQgzGL2-zopWT2ioy9w7OP9pqAbS3itMq-614,9528
7
7
  primitive/agent/commands.py,sha256=o847pK7v7EWQGG67tky6a33qtwoutX6LZrP2FIS_NOk,388
8
- primitive/agent/runner.py,sha256=bYDH_kjoR8XrsxNsE2RfkGOJqfqX2M8jXyaFp3zGxqk,17760
8
+ primitive/agent/pxe.py,sha256=XTu9Plj2mlmlL6YjcDqlfS_IGBK4i7aGKhTL-k_wnBU,2542
9
+ primitive/agent/runner.py,sha256=nel7NiqWoIh5HFdoor9YIsPpMBJPHsevg3XtAHrKvc0,16619
9
10
  primitive/agent/uploader.py,sha256=DT_Nzt5eOTm_uRcYKm1sjBBaQZzp5iNZ_uN5XktfQ30,3382
10
11
  primitive/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
12
  primitive/auth/actions.py,sha256=9NIEXJ1BNJutJs6AMMSjMN_ziONUAUhY_xHwojYJCLA,942
@@ -40,7 +41,7 @@ primitive/graphql/relay.py,sha256=bmij2AjdpURQ6GGVCxwWhauF-r_SxuAU2oJ4sDbLxpI,72
40
41
  primitive/graphql/sdk.py,sha256=dE4TD8KiTKw3Y0uiw5XrIcuZGqexE47eSlPaPD6jDGo,1545
41
42
  primitive/graphql/utility_fragments.py,sha256=uIjwILC4QtWNyO5vu77VjQf_p0jvP3A9q_6zRq91zqs,303
42
43
  primitive/hardware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
43
- primitive/hardware/actions.py,sha256=B0mkqNsjeMPFZsAgI23X1I8th2U1cqCg4Ugm3w6dxfk,44255
44
+ primitive/hardware/actions.py,sha256=WdI7dd7TJen6xF8h63kH8PbCXydJfHlFF56QZdAgh5w,44645
44
45
  primitive/hardware/android.py,sha256=tu7pBPxWFrIwb_mm5CEdFFf1_veNDOKjOCQg13i_Lh4,2758
45
46
  primitive/hardware/commands.py,sha256=ZAbrqGuK4c4aCYPySaTUt51qFsf_F3Zsb0KaLfeVnmA,5045
46
47
  primitive/hardware/ui.py,sha256=12rucuZ2s-w5R4bKyxON5dEbrdDnVf5sbj3K_nbdo44,2473
@@ -56,18 +57,18 @@ primitive/jobs/graphql/fragments.py,sha256=I-Ly0TGARt_TdenLQ7877pGLWBzono_IMj7NC
56
57
  primitive/jobs/graphql/mutations.py,sha256=8ASvCmwQh7cMeeiykOdYaYVryG8FRIuVF6v_J8JJZuw,219
57
58
  primitive/jobs/graphql/queries.py,sha256=57B5mSnAYjNEFFk1P69odON0fqkf7FyhsA316pcxb6g,1712
58
59
  primitive/messaging/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
59
- primitive/messaging/provider.py,sha256=NkR4hnQzEZ0SItno1TgYNvzTwgKZq03iNrnIpLK7MTg,4400
60
- primitive/monitor/actions.py,sha256=HO45Q1Cw9VTd2dITp-ftYJ4wJKt3YHHLdeJSqY-qhos,11085
60
+ primitive/messaging/provider.py,sha256=BCkhRq_F6jKqf26x-9Da3ebCqgpjwxynRHIu7I7iFeQ,4432
61
+ primitive/monitor/actions.py,sha256=Ky4KPNJc5c0LaxZkDs8-7pP2bQ6xNBFxmNf8eAmi7ag,11079
61
62
  primitive/monitor/commands.py,sha256=VDlEL_Qpm_ysHxug7VpI0cVAZ0ny6AS91Y58D7F1zkU,409
62
63
  primitive/network/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
63
- primitive/network/actions.py,sha256=8JuArc8kRF-UjatxI1SbrD-F72ywGypmwDeijiXLTzw,18994
64
+ primitive/network/actions.py,sha256=iw6X5zKdsFCQ8vAw_glEI6sdtVUeXdauW33PjsKL_QU,18874
64
65
  primitive/network/commands.py,sha256=wVszHkmmY85dwlEJR726vqx6rDcfD4XtAptgiHf5p4U,1361
65
66
  primitive/network/redfish.py,sha256=uOtAS_Dwc4I4bnWKNSTCQ_xsj5LTtRzW5v2P_fWaSJM,4248
66
- primitive/network/ssh.py,sha256=vVopUr7PoZsu2-n8BQfwr_IF39HgPbrxz9dKKoJqsdE,4095
67
+ primitive/network/ssh.py,sha256=evtZFWFsKfuN4MeFtPpvzjBM1a5Q0O06rxd6ZkSzoJo,4377
67
68
  primitive/network/ui.py,sha256=-AV6OD1O3_nsTrgS-W49R6LfDN0vmXmz0Dli_S--LxE,685
68
69
  primitive/operating_systems/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
69
- primitive/operating_systems/actions.py,sha256=68YUAowtvJ8jm5HBXHjnQ_K8q0Z83J2hS5rfPkoJ53A,9127
70
- primitive/operating_systems/commands.py,sha256=blZlHPKd1bfUImXP-AKUc7WGzMZ3Cm9wUI1--Q59JiQ,8523
70
+ primitive/operating_systems/actions.py,sha256=SNeYC4QgG0H-ybmIQaAXhDj6c9yYG6BgIQfzU2wm8hY,16993
71
+ primitive/operating_systems/commands.py,sha256=4qG3Ggf-sqLa0Cr6MErpjd6TOoc5_bE5tgJ8LigKKnw,7288
71
72
  primitive/operating_systems/graphql/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
72
73
  primitive/operating_systems/graphql/mutations.py,sha256=L2ZeWEfBK22QAv2AsfDQWHKcwH5SWvMvcmOOftiEM7w,699
73
74
  primitive/operating_systems/graphql/queries.py,sha256=TJzcYbFP4SowsFJb8Wv8Q1r5_gCZRKbobEzniu9LWFk,738
@@ -112,8 +113,8 @@ primitive/utils/psutil.py,sha256=xa7ef435UL37jyjmUPbEqCO2ayQMpCs0HCrxVEvLcuM,763
112
113
  primitive/utils/shell.py,sha256=Z4zxmOaSyGCrS0D6I436iQci-ewHLt4UxVg1CD9Serc,2171
113
114
  primitive/utils/text.py,sha256=XiESMnlhjQ534xE2hMNf08WehE1SKaYFRNih0MmnK0k,829
114
115
  primitive/utils/x509.py,sha256=HwHRPqakTHWd40ny-9O_yNknSL1Cxo50O0UCjXHFq04,3796
115
- primitive-0.2.70.dist-info/METADATA,sha256=6RjEuCX8ocvGe1cuwHtM3UN82KuKN5iLSyCtIlJavjQ,3569
116
- primitive-0.2.70.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
117
- primitive-0.2.70.dist-info/entry_points.txt,sha256=p1K8DMCWka5FqLlqP1sPek5Uovy9jq8u51gUsP-z334,48
118
- primitive-0.2.70.dist-info/licenses/LICENSE.txt,sha256=B8kmQMJ2sxYygjCLBk770uacaMci4mPSoJJ8WoDBY_c,1098
119
- primitive-0.2.70.dist-info/RECORD,,
116
+ primitive-0.2.72.dist-info/METADATA,sha256=L8m-fkO2D2xT8U2iUqr62x_NCoIvG8Rv3Em-RSlFAlY,3569
117
+ primitive-0.2.72.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
118
+ primitive-0.2.72.dist-info/entry_points.txt,sha256=p1K8DMCWka5FqLlqP1sPek5Uovy9jq8u51gUsP-z334,48
119
+ primitive-0.2.72.dist-info/licenses/LICENSE.txt,sha256=B8kmQMJ2sxYygjCLBk770uacaMci4mPSoJJ8WoDBY_c,1098
120
+ primitive-0.2.72.dist-info/RECORD,,