portacode 1.4.16.dev10__py3-none-any.whl → 1.4.16.dev11__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.
portacode/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '1.4.16.dev10'
32
- __version_tuple__ = version_tuple = (1, 4, 16, 'dev10')
31
+ __version__ = version = '1.4.16.dev11'
32
+ __version_tuple__ = version_tuple = (1, 4, 16, 'dev11')
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -19,7 +19,7 @@ import time
19
19
  import threading
20
20
  from datetime import datetime, timezone
21
21
  from pathlib import Path
22
- from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Set, Tuple
22
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
23
23
 
24
24
  import platformdirs
25
25
 
@@ -202,67 +202,66 @@ def _current_time_iso() -> str:
202
202
  return datetime.now(timezone.utc).isoformat()
203
203
 
204
204
 
205
- def _to_int(value: Any, default: int = 0) -> int:
205
+ def _parse_iso_timestamp(value: str) -> Optional[datetime]:
206
+ if not value:
207
+ return None
208
+ text = value
209
+ if text.endswith("Z"):
210
+ text = text[:-1] + "+00:00"
206
211
  try:
207
- return int(value)
208
- except (TypeError, ValueError):
209
- return default
212
+ return datetime.fromisoformat(text)
213
+ except ValueError:
214
+ return None
215
+
216
+
217
+ def _templates_need_refresh(config: Dict[str, Any]) -> bool:
218
+ if not config or not config.get("token_value"):
219
+ return False
220
+ last = _parse_iso_timestamp(config.get("templates_last_refreshed") or "")
221
+ if not last:
222
+ return True
223
+ return (datetime.now(timezone.utc) - last).total_seconds() >= TEMPLATES_REFRESH_INTERVAL_S
210
224
 
211
225
 
212
- def _to_float(value: Any, default: float = 0.0) -> float:
226
+ def _ensure_templates_refreshed_on_startup(config: Dict[str, Any]) -> None:
227
+ if not _templates_need_refresh(config):
228
+ return
213
229
  try:
214
- return float(value)
215
- except (TypeError, ValueError):
216
- return default
230
+ client = _build_proxmox_client_from_config(config)
231
+ node = config.get("node") or _pick_node(client)
232
+ storages = client.nodes(node).storage.get()
233
+ templates = _list_templates(client, node, storages)
234
+ if templates:
235
+ config["templates"] = templates
236
+ config["templates_last_refreshed"] = _current_time_iso()
237
+ _save_config(config)
238
+ except Exception as exc:
239
+ logger.warning("Unable to refresh Proxmox templates on startup: %s", exc)
217
240
 
218
241
 
219
- def _calculate_available(total: Optional[float], used: float) -> Optional[float]:
220
- if total is None:
221
- return None
222
- delta = total - used
223
- return delta if delta >= 0 else 0.0
242
+ def _pick_storage(storages: Iterable[Dict[str, Any]]) -> str:
243
+ candidates = [s for s in storages if "rootdir" in s.get("content", "") and s.get("avail", 0) > 0]
244
+ if not candidates:
245
+ candidates = [s for s in storages if "rootdir" in s.get("content", "")]
246
+ if not candidates:
247
+ return ""
248
+ candidates.sort(key=lambda entry: entry.get("avail", 0), reverse=True)
249
+ return candidates[0].get("storage", "")
224
250
 
225
251
 
226
- def _normalize_bytes(value: Any) -> float:
227
- if isinstance(value, (int, float)):
228
- return float(value)
229
- text = str(value or "").strip()
230
- if not text:
231
- return 0.0
232
- match = re.match(r"(?i)^\s*([0-9]*\.?[0-9]+)\s*([kmgtp]?i?b?)?\s*$", text)
233
- if not match:
234
- return 0.0
235
- number = match.group(1)
236
- unit = (match.group(2) or "").lower()
252
+ def _bytes_to_gib(value: Any) -> float:
237
253
  try:
238
- value = float(number)
239
- except ValueError:
254
+ return float(value) / 1024**3
255
+ except (TypeError, ValueError):
240
256
  return 0.0
241
- if unit.startswith("k"):
242
- return value * 1024
243
- if unit.startswith("m"):
244
- return value * 1024**2
245
- if unit.startswith("g"):
246
- return value * 1024**3
247
- if unit.startswith("t"):
248
- return value * 1024**4
249
- if unit.startswith("p"):
250
- return value * 1024**5
251
- return value
252
-
253
257
 
254
- def _bytes_to_mib(value: Any) -> int:
255
- return int(round(_normalize_bytes(value) / (1024**2)))
256
-
257
-
258
- def _bytes_to_gib(value: Any) -> int:
259
- return int(round(_normalize_bytes(value) / (1024**3)))
260
258
 
259
+ def _bytes_to_mib(value: Any) -> float:
260
+ try:
261
+ return float(value) / 1024**2
262
+ except (TypeError, ValueError):
263
+ return 0.0
261
264
 
262
- def _normalize_storage_name(name: Any) -> str:
263
- if not name:
264
- return ""
265
- return str(name).strip().lower()
266
265
 
267
266
  def _size_token_to_gib(token: str) -> float:
268
267
  match = re.match(r"^\s*([0-9]+(?:\.[0-9]+)?)\s*([KMGTP])?([iI]?[bB])?\s*$", token)
@@ -272,11 +271,11 @@ def _size_token_to_gib(token: str) -> float:
272
271
  unit = (match.group(2) or "").upper()
273
272
  scale = {
274
273
  "": 1,
275
- "K": 1024 ** 1,
276
- "M": 1024 ** 2,
277
- "G": 1024 ** 3,
278
- "T": 1024 ** 4,
279
- "P": 1024 ** 5,
274
+ "K": 1024**1,
275
+ "M": 1024**2,
276
+ "G": 1024**3,
277
+ "T": 1024**4,
278
+ "P": 1024**5,
280
279
  }.get(unit, 1)
281
280
  return (number * scale) / 1024**3
282
281
 
@@ -292,7 +291,7 @@ def _extract_size_gib(value: Any) -> float:
292
291
  return _size_token_to_gib(text)
293
292
 
294
293
 
295
- def _extract_storage(value: Any) -> str:
294
+ def _extract_storage_token(value: Any) -> str:
296
295
  if not value:
297
296
  return "unknown"
298
297
  text = str(value)
@@ -303,12 +302,12 @@ def _extract_storage(value: Any) -> str:
303
302
 
304
303
  def _storage_from_lxc(cfg: Dict[str, Any], entry: Dict[str, Any]) -> str:
305
304
  rootfs = cfg.get("rootfs") or entry.get("rootfs")
306
- storage = _extract_storage(rootfs)
305
+ storage = _extract_storage_token(rootfs)
307
306
  if storage != "unknown":
308
307
  return storage
309
308
  for idx in range(0, 10):
310
309
  mp_value = cfg.get(f"mp{idx}")
311
- storage = _extract_storage(mp_value)
310
+ storage = _extract_storage_token(mp_value)
312
311
  if storage != "unknown":
313
312
  return storage
314
313
  return "unknown"
@@ -318,7 +317,7 @@ def _storage_from_qemu(cfg: Dict[str, Any]) -> str:
318
317
  preferred_keys: List[str] = []
319
318
  for prefix in ("scsi", "virtio", "sata", "ide"):
320
319
  preferred_keys.extend(f"{prefix}{idx}" for idx in range(0, 6))
321
- seen: Set[str] = set()
320
+ seen = set()
322
321
  for key in preferred_keys:
323
322
  value = cfg.get(key)
324
323
  if value is None:
@@ -327,7 +326,7 @@ def _storage_from_qemu(cfg: Dict[str, Any]) -> str:
327
326
  text = str(value)
328
327
  if "media=cdrom" in text or "cloudinit" in text:
329
328
  continue
330
- storage = _extract_storage(text)
329
+ storage = _extract_storage_token(text)
331
330
  if storage != "unknown":
332
331
  return storage
333
332
  for key in sorted(cfg.keys()):
@@ -341,11 +340,11 @@ def _storage_from_qemu(cfg: Dict[str, Any]) -> str:
341
340
  text = str(value)
342
341
  if "media=cdrom" in text or "cloudinit" in text:
343
342
  continue
344
- storage = _extract_storage(text)
343
+ storage = _extract_storage_token(text)
345
344
  if storage != "unknown":
346
345
  return storage
347
346
  for key in ("efidisk0", "tpmstate0"):
348
- storage = _extract_storage(cfg.get(key))
347
+ storage = _extract_storage_token(cfg.get(key))
349
348
  if storage != "unknown":
350
349
  return storage
351
350
  return "unknown"
@@ -359,7 +358,7 @@ def _primary_qemu_disk(cfg: Dict[str, Any]) -> str:
359
358
  preferred_keys: List[str] = []
360
359
  for prefix in ("scsi", "virtio", "sata", "ide"):
361
360
  preferred_keys.extend(f"{prefix}{idx}" for idx in range(0, 6))
362
- seen: Set[str] = set()
361
+ seen = set()
363
362
  for key in preferred_keys:
364
363
  value = cfg.get(key)
365
364
  if value is None:
@@ -384,8 +383,8 @@ def _primary_qemu_disk(cfg: Dict[str, Any]) -> str:
384
383
  return ""
385
384
 
386
385
 
387
- def _pick_storage(kind: str, cfg: Dict[str, Any], entry: Dict[str, Any]) -> str:
388
- storage = _extract_storage(cfg.get("storage") or entry.get("storage"))
386
+ def _pick_container_storage(kind: str, cfg: Dict[str, Any], entry: Dict[str, Any]) -> str:
387
+ storage = _extract_storage_token(cfg.get("storage") or entry.get("storage"))
389
388
  if storage != "unknown":
390
389
  return storage
391
390
  if kind == "lxc":
@@ -393,7 +392,7 @@ def _pick_storage(kind: str, cfg: Dict[str, Any], entry: Dict[str, Any]) -> str:
393
392
  return _storage_from_qemu(cfg)
394
393
 
395
394
 
396
- def _pick_disk_gib(kind: str, cfg: Dict[str, Any], entry: Dict[str, Any]) -> float:
395
+ def _pick_container_disk_gib(kind: str, cfg: Dict[str, Any], entry: Dict[str, Any]) -> float:
397
396
  if kind == "lxc":
398
397
  size = _extract_size_gib(_primary_lxc_disk(cfg, entry))
399
398
  if size:
@@ -402,68 +401,63 @@ def _pick_disk_gib(kind: str, cfg: Dict[str, Any], entry: Dict[str, Any]) -> flo
402
401
  size = _extract_size_gib(_primary_qemu_disk(cfg))
403
402
  if size:
404
403
  return size
405
- for candidate in (entry.get("maxdisk"), entry.get("disk")):
406
- if candidate in {None, 0}:
404
+ for candidate in (entry.get("maxdisk"), entry.get("disk"), cfg.get("disk")):
405
+ if candidate is None or candidate == 0:
407
406
  continue
408
- return _normalize_bytes(candidate) / 1024**3
409
- cfg_disk = cfg.get("disk")
410
- if cfg_disk not in (None, 0):
411
- return _normalize_bytes(cfg_disk) / 1024**3
407
+ return _bytes_to_gib(candidate)
412
408
  return 0.0
413
409
 
414
410
 
415
- def _parse_bool_flag(value: Any) -> bool:
416
- if isinstance(value, bool):
417
- return value
418
- text = str(value or "").strip().lower()
419
- return text in {"1", "true", "yes", "on"}
420
-
421
-
422
- def _parse_iso_timestamp(value: str) -> Optional[datetime]:
423
- if not value:
424
- return None
425
- text = value
426
- if text.endswith("Z"):
427
- text = text[:-1] + "+00:00"
411
+ def _to_mib(value: Any) -> float:
428
412
  try:
429
- return datetime.fromisoformat(text)
430
- except ValueError:
431
- return None
413
+ val = float(value)
414
+ except (TypeError, ValueError):
415
+ return 0.0
416
+ if val <= 0:
417
+ return 0.0
418
+ # Heuristic: large values are bytes, smaller ones are already MiB.
419
+ return _bytes_to_mib(val) if val > 10000 else val
432
420
 
433
421
 
434
- def _templates_need_refresh(config: Dict[str, Any]) -> bool:
435
- if not config or not config.get("token_value"):
436
- return False
437
- last = _parse_iso_timestamp(config.get("templates_last_refreshed") or "")
438
- if not last:
439
- return True
440
- return (datetime.now(timezone.utc) - last).total_seconds() >= TEMPLATES_REFRESH_INTERVAL_S
422
+ def _pick_container_ram_mib(kind: str, cfg: Dict[str, Any], entry: Dict[str, Any]) -> float:
423
+ for candidate in (cfg.get("memory"), entry.get("maxmem"), entry.get("mem")):
424
+ ram = _to_mib(candidate)
425
+ if ram:
426
+ return ram
427
+ return 0.0
441
428
 
442
429
 
443
- def _ensure_templates_refreshed_on_startup(config: Dict[str, Any]) -> None:
444
- if not _templates_need_refresh(config):
445
- return
430
+ def _safe_float(value: Any) -> float:
446
431
  try:
447
- client = _build_proxmox_client_from_config(config)
448
- node = config.get("node") or _pick_node(client)
449
- storages = client.nodes(node).storage.get()
450
- templates = _list_templates(client, node, storages)
451
- if templates:
452
- config["templates"] = templates
453
- config["templates_last_refreshed"] = _current_time_iso()
454
- _save_config(config)
455
- except Exception as exc:
456
- logger.warning("Unable to refresh Proxmox templates on startup: %s", exc)
432
+ return float(value)
433
+ except (TypeError, ValueError):
434
+ return 0.0
457
435
 
458
436
 
459
- def _pick_storage(storages: Iterable[Dict[str, Any]]) -> str:
460
- candidates = [s for s in storages if "rootdir" in s.get("content", "") and s.get("avail", 0) > 0]
461
- if not candidates:
462
- candidates = [s for s in storages if "rootdir" in s.get("content", "")]
463
- if not candidates:
464
- return ""
465
- candidates.sort(key=lambda entry: entry.get("avail", 0), reverse=True)
466
- return candidates[0].get("storage", "")
437
+ def _pick_container_cpu_share(kind: str, cfg: Dict[str, Any], entry: Dict[str, Any]) -> float:
438
+ if kind == "lxc":
439
+ for key in ("cpulimit", "cores", "cpus"):
440
+ val = _safe_float(cfg.get(key))
441
+ if val:
442
+ return val
443
+ return _safe_float(entry.get("cpus"))
444
+
445
+ cores = _safe_float(cfg.get("cores"))
446
+ sockets = _safe_float(cfg.get("sockets")) or 1.0
447
+ if cores:
448
+ return cores * sockets
449
+ val = _safe_float(cfg.get("vcpus"))
450
+ if val:
451
+ return val
452
+ val = _safe_float(entry.get("cpus") or entry.get("maxcpu"))
453
+ if val:
454
+ return val
455
+ return 0.0
456
+
457
+
458
+ def _parse_onboot_flag(value: Any) -> bool:
459
+ text = str(value).strip().lower()
460
+ return text in {"1", "true", "yes", "on"}
467
461
 
468
462
 
469
463
  def _write_bridge_config(bridge: str) -> None:
@@ -599,104 +593,35 @@ def _load_managed_container_records() -> List[Dict[str, Any]]:
599
593
  return records
600
594
 
601
595
 
602
- def _extract_host_totals(node_status: Dict[str, Any] | None) -> Tuple[Optional[int], Optional[int], Optional[int]]:
603
- if not node_status:
604
- return None, None, None
605
- memory_total = node_status.get("memory", {}).get("total")
606
- disk_total = node_status.get("disk", {}).get("total")
607
- cpu_cores = node_status.get("cpuinfo", {}).get("cores")
608
- host_ram = _bytes_to_mib(memory_total) if memory_total is not None else None
609
- host_disk = _bytes_to_gib(disk_total) if disk_total is not None else None
610
- host_cpu = _to_int(cpu_cores) if cpu_cores is not None else None
611
- return host_ram, host_disk, host_cpu
612
-
613
-
614
- def _build_unmanaged_container_entry(
615
- ct: Dict[str, Any],
616
- cfg: Dict[str, Any],
617
- vmid: str,
618
- default_storage: str | None,
619
- *,
620
- entry_type: str = "lxc",
621
- ) -> Dict[str, Any]:
622
- ram_mib = _to_int(cfg.get("memory")) or _bytes_to_mib(ct.get("maxmem"))
623
- disk_gib = int(round(_pick_disk_gib(entry_type, cfg, ct)))
624
- cpu_share = _to_float(
625
- cfg.get("cpulimit")
626
- or cfg.get("cpus")
627
- or cfg.get("cores")
628
- or ct.get("cpus")
629
- or ct.get("cpu")
630
- )
631
- hostname = ct.get("name") or cfg.get("hostname") or f"ct{vmid}"
632
- storage = _pick_storage(entry_type, cfg, ct)
633
- status = (ct.get("status") or "unknown").lower()
634
- reserved = _parse_bool_flag(cfg.get("onboot"))
635
- storage_matches_default = _storage_matches_default(storage, default_storage)
636
- return {
637
- "vmid": vmid,
638
- "hostname": hostname,
639
- "template": cfg.get("ostemplate"),
640
- "storage": storage,
641
- "disk_gib": disk_gib,
642
- "ram_mib": ram_mib,
643
- "cpu_share": cpu_share,
644
- "reserve_on_boot": reserved,
645
- "matches_default_storage": storage_matches_default,
646
- "type": entry_type,
647
- "status": status,
648
- "managed": False,
649
- }
650
-
651
-
652
- def _get_storage_snapshot(proxmox: Any, node: str, storage_name: str | None) -> Dict[str, Any] | None:
653
- if not storage_name:
654
- return None
655
- try:
656
- storage = proxmox.nodes(node).storage(storage_name).status.get()
657
- except Exception as exc:
658
- logger.debug("Unable to read storage status %s:%s: %s", node, storage_name, exc)
659
- return None
660
- total_bytes = storage.get("total")
661
- avail_bytes = storage.get("avail")
662
- used_bytes = storage.get("used")
663
- return {
664
- "storage": storage_name,
665
- "total_gib": _bytes_to_gib(total_bytes) if total_bytes is not None else None,
666
- "avail_gib": _bytes_to_gib(avail_bytes) if avail_bytes is not None else None,
667
- "used_gib": _bytes_to_gib(used_bytes) if used_bytes is not None else None,
668
- }
669
-
670
-
671
- def _storage_matches_default(storage_name: Any, default_storage: str | None) -> bool:
672
- if not default_storage:
673
- return True
674
- return _normalize_storage_name(storage_name) == _normalize_storage_name(default_storage)
675
-
676
-
677
- def _build_managed_containers_summary(
678
- records: List[Dict[str, Any]],
679
- unmanaged_records: List[Dict[str, Any]],
680
- node_status: Dict[str, Any] | None,
681
- storage_snapshot: Dict[str, Any] | None,
682
- default_storage: str | None,
683
- ) -> Dict[str, Any]:
684
- managed_containers: List[Dict[str, Any]] = []
596
+ def _build_managed_containers_summary(records: List[Dict[str, Any]]) -> Dict[str, Any]:
685
597
  total_ram = 0
686
598
  total_disk = 0
687
599
  total_cpu_share = 0.0
600
+ containers: List[Dict[str, Any]] = []
688
601
 
689
- for record in sorted(records, key=lambda entry: _to_int(entry.get("vmid"))):
690
- ram_mib = _to_int(record.get("ram_mib"))
691
- disk_gib = _to_int(record.get("disk_gib"))
692
- cpu_share = _to_float(record.get("cpus"))
602
+ def _as_int(value: Any) -> int:
603
+ try:
604
+ return int(value)
605
+ except (TypeError, ValueError):
606
+ return 0
607
+
608
+ def _as_float(value: Any) -> float:
609
+ try:
610
+ return float(value)
611
+ except (TypeError, ValueError):
612
+ return 0.0
613
+
614
+ for record in sorted(records, key=lambda entry: _as_int(entry.get("vmid"))):
615
+ ram_mib = _as_int(record.get("ram_mib"))
616
+ disk_gib = _as_int(record.get("disk_gib"))
617
+ cpu_share = _as_float(record.get("cpus"))
693
618
  total_ram += ram_mib
694
619
  total_disk += disk_gib
695
620
  total_cpu_share += cpu_share
696
621
  status = (record.get("status") or "unknown").lower()
697
- managed_containers.append(
622
+ containers.append(
698
623
  {
699
- "vmid": str(_to_int(record.get("vmid"))) if record.get("vmid") is not None else None,
624
+ "vmid": str(_as_int(record.get("vmid"))) if record.get("vmid") is not None else None,
700
625
  "device_id": record.get("device_id"),
701
626
  "hostname": record.get("hostname"),
702
627
  "template": record.get("template"),
@@ -706,131 +631,177 @@ def _build_managed_containers_summary(
706
631
  "cpu_share": cpu_share,
707
632
  "created_at": record.get("created_at"),
708
633
  "status": status,
709
- "managed": True,
710
- "matches_default_storage": _storage_matches_default(record.get("storage"), default_storage),
711
634
  }
712
635
  )
713
636
 
714
- unmanaged_total_ram = sum(
715
- _to_int(entry.get("ram_mib"))
716
- for entry in unmanaged_records
717
- if entry.get("reserve_on_boot")
718
- )
719
- unmanaged_total_disk = sum(
720
- _to_int(entry.get("disk_gib"))
721
- for entry in unmanaged_records
722
- if entry.get("matches_default_storage")
723
- )
724
- unmanaged_total_cpu = sum(
725
- _to_float(entry.get("cpu_share"))
726
- for entry in unmanaged_records
727
- if entry.get("reserve_on_boot")
728
- )
729
-
730
- allocated_ram = total_ram + unmanaged_total_ram
731
- allocated_disk = total_disk + unmanaged_total_disk
732
- allocated_cpu = total_cpu_share + unmanaged_total_cpu
733
-
734
- host_ram_node, host_disk_node, host_cpu_node = _extract_host_totals(node_status)
735
- storage_host_disk = storage_snapshot.get("total_gib") if storage_snapshot else None
736
- host_ram = host_ram_node
737
- host_disk = storage_host_disk if storage_host_disk is not None else host_disk_node
738
- host_cpu = host_cpu_node
739
-
740
-
741
637
  return {
742
638
  "updated_at": datetime.utcnow().isoformat() + "Z",
743
- "count": len(managed_containers),
639
+ "count": len(containers),
744
640
  "total_ram_mib": total_ram,
745
641
  "total_disk_gib": total_disk,
746
642
  "total_cpu_share": round(total_cpu_share, 2),
747
- "containers": managed_containers,
748
- "unmanaged_containers": unmanaged_records,
749
- "unmanaged_count": len(unmanaged_records),
750
- "allocated_ram_mib": allocated_ram,
751
- "allocated_disk_gib": allocated_disk,
752
- "allocated_cpu_share": round(allocated_cpu, 2),
753
- "available_ram_mib": _calculate_available(host_ram, allocated_ram) if host_ram is not None else None,
754
- "available_disk_gib": _calculate_available(host_disk, allocated_disk) if host_disk is not None else None,
755
- "available_cpu_share": _calculate_available(host_cpu, allocated_cpu) if host_cpu is not None else None,
756
- "host_total_ram_mib": host_ram,
757
- "host_total_disk_gib": host_disk,
758
- "host_total_cpu_cores": host_cpu,
759
- "default_storage": default_storage,
760
- "default_storage_snapshot": storage_snapshot,
643
+ "containers": containers,
761
644
  }
762
645
 
763
646
 
647
+ def _build_full_container_summary(records: List[Dict[str, Any]], config: Dict[str, Any]) -> Dict[str, Any]:
648
+ base_summary = _build_managed_containers_summary(records)
649
+ if not config or not config.get("token_value"):
650
+ return base_summary
651
+
652
+ try:
653
+ proxmox = _connect_proxmox(config)
654
+ node = _get_node_from_config(config)
655
+ except Exception as exc: # pragma: no cover - best effort
656
+ logger.debug("Unable to extend container summary with Proxmox data: %s", exc)
657
+ return base_summary
658
+
659
+ default_storage = (config.get("default_storage") or "").strip()
660
+ record_map: Dict[str, Dict[str, Any]] = {}
661
+ for record in records:
662
+ vmid = record.get("vmid")
663
+ if vmid is None:
664
+ continue
665
+ try:
666
+ vmid_key = str(int(vmid))
667
+ except (ValueError, TypeError):
668
+ continue
669
+ record_map[vmid_key] = record
670
+
671
+ managed_entries: List[Dict[str, Any]] = []
672
+ unmanaged_entries: List[Dict[str, Any]] = []
673
+ allocated_ram = 0.0
674
+ allocated_disk = 0.0
675
+ allocated_cpu = 0.0
676
+
677
+ def _process_entries(kind: str, getter: str) -> None:
678
+ nonlocal allocated_ram, allocated_disk, allocated_cpu
679
+ entries = getattr(proxmox.nodes(node), getter).get()
680
+ for entry in entries:
681
+ vmid = entry.get("vmid")
682
+ if vmid is None:
683
+ continue
684
+ vmid_str = str(vmid)
685
+ cfg: Dict[str, Any] = {}
686
+ try:
687
+ cfg = getattr(proxmox.nodes(node), getter)(vmid_str).config.get() or {}
688
+ except Exception as exc: # pragma: no cover - best effort
689
+ logger.debug("Failed to load %s config for %s: %s", kind, vmid_str, exc)
690
+ cfg = {}
691
+
692
+ record = record_map.get(vmid_str)
693
+ description = cfg.get("description") or ""
694
+ managed = bool(record) or MANAGED_MARKER in description
695
+ hostname = entry.get("name") or cfg.get("hostname") or (record.get("hostname") if record else None)
696
+ storage = _pick_container_storage(kind, cfg, entry)
697
+ disk_gib = _pick_container_disk_gib(kind, cfg, entry)
698
+ ram_mib = _pick_container_ram_mib(kind, cfg, entry)
699
+ cpu_share = _pick_container_cpu_share(kind, cfg, entry)
700
+ reserve_on_boot = _parse_onboot_flag(cfg.get("onboot"))
701
+ matches_default_storage = bool(default_storage and storage and storage.lower() == default_storage.lower())
702
+
703
+ base_entry = {
704
+ "type": kind,
705
+ "vmid": vmid_str,
706
+ "hostname": hostname,
707
+ "status": (entry.get("status") or "unknown").lower(),
708
+ "storage": storage,
709
+ "disk_gib": disk_gib,
710
+ "ram_mib": ram_mib,
711
+ "cpu_share": cpu_share,
712
+ "reserve_on_boot": reserve_on_boot,
713
+ "matches_default_storage": matches_default_storage,
714
+ "managed": managed,
715
+ }
716
+
717
+ if managed:
718
+ merged = base_entry | {
719
+ "device_id": record.get("device_id") if record else None,
720
+ "template": record.get("template") if record else None,
721
+ "created_at": record.get("created_at") if record else None,
722
+ }
723
+ managed_entries.append(merged)
724
+ else:
725
+ unmanaged_entries.append(base_entry)
726
+
727
+ if managed or reserve_on_boot:
728
+ allocated_ram += ram_mib
729
+ allocated_cpu += cpu_share
730
+ if managed or matches_default_storage:
731
+ allocated_disk += disk_gib
732
+
733
+ _process_entries("lxc", "lxc")
734
+ _process_entries("qemu", "qemu")
735
+
736
+ memory_info = {}
737
+ cpu_info = {}
738
+ try:
739
+ node_status = proxmox.nodes(node).status.get()
740
+ memory_info = node_status.get("memory") or {}
741
+ cpu_info = node_status.get("cpuinfo") or {}
742
+ except Exception as exc: # pragma: no cover - best effort
743
+ logger.debug("Unable to read node status for resource totals: %s", exc)
744
+
745
+ host_total_ram_mib = _bytes_to_mib(memory_info.get("total"))
746
+ used_ram_mib = _bytes_to_mib(memory_info.get("used"))
747
+ available_ram_mib = max(host_total_ram_mib - used_ram_mib, 0.0) if host_total_ram_mib else None
748
+ host_total_cpu_cores = _safe_float(cpu_info.get("cores"))
749
+ available_cpu_share = max(host_total_cpu_cores - allocated_cpu, 0.0) if host_total_cpu_cores else None
750
+
751
+ host_total_disk_gib = None
752
+ available_disk_gib = None
753
+ if default_storage:
754
+ try:
755
+ storage_status = proxmox.nodes(node).storage(default_storage).status.get()
756
+ host_total_disk_gib = _bytes_to_gib(storage_status.get("total"))
757
+ available_disk_gib = _bytes_to_gib(storage_status.get("avail"))
758
+ except Exception as exc: # pragma: no cover - best effort
759
+ logger.debug("Unable to read storage status for %s: %s", default_storage, exc)
760
+
761
+ summary = base_summary.copy()
762
+ summary["containers"] = managed_entries
763
+ summary["count"] = len(managed_entries)
764
+ summary["total_ram_mib"] = int(sum(entry.get("ram_mib") or 0 for entry in managed_entries))
765
+ summary["total_disk_gib"] = int(sum(entry.get("disk_gib") or 0 for entry in managed_entries))
766
+ summary["total_cpu_share"] = round(sum(entry.get("cpu_share") or 0 for entry in managed_entries), 2)
767
+ summary["unmanaged_containers"] = unmanaged_entries
768
+ summary["allocated_ram_mib"] = round(allocated_ram, 2)
769
+ summary["allocated_disk_gib"] = round(allocated_disk, 2)
770
+ summary["allocated_cpu_share"] = round(allocated_cpu, 2)
771
+ summary["host_total_ram_mib"] = int(host_total_ram_mib) if host_total_ram_mib else None
772
+ summary["host_total_disk_gib"] = host_total_disk_gib
773
+ summary["host_total_cpu_cores"] = host_total_cpu_cores if host_total_cpu_cores else None
774
+ summary["available_ram_mib"] = int(available_ram_mib) if available_ram_mib is not None else None
775
+ summary["available_disk_gib"] = available_disk_gib
776
+ summary["available_cpu_share"] = available_cpu_share if available_cpu_share is not None else None
777
+ return summary
778
+
779
+
764
780
  def _get_managed_containers_summary(force: bool = False) -> Dict[str, Any]:
765
- def _refresh_container_statuses(
766
- records: List[Dict[str, Any]],
767
- config: Dict[str, Any] | None,
768
- managed_vmids: Set[str],
769
- default_storage: str | None,
770
- ) -> Tuple[Dict[str, str], List[Dict[str, Any]], Dict[str, Any] | None, Dict[str, Any] | None]:
771
- statuses: Dict[str, str] = {}
772
- unmanaged: List[Dict[str, Any]] = []
773
- node_status: Dict[str, Any] | None = None
774
- if not config:
775
- return statuses, unmanaged, node_status, None
776
- proxmox = None
777
- node = None
781
+ def _refresh_container_statuses(records: List[Dict[str, Any]], config: Dict[str, Any] | None) -> None:
782
+ if not records or not config:
783
+ return
778
784
  try:
779
785
  proxmox = _connect_proxmox(config)
780
786
  node = _get_node_from_config(config)
781
- node_status = proxmox.nodes(node).status.get()
782
- for ct in proxmox.nodes(node).lxc.get():
783
- vmid_val = ct.get("vmid")
784
- if vmid_val is None:
785
- continue
786
- vmid_key = str(_to_int(vmid_val))
787
- statuses[vmid_key] = (ct.get("status") or "unknown").lower()
788
- if vmid_key in managed_vmids:
789
- continue
790
- cfg: Dict[str, Any] = {}
791
- try:
792
- cfg = proxmox.nodes(node).lxc(vmid_key).config.get() or {}
793
- except Exception as exc: # pragma: no cover - best effort
794
- logger.debug("Failed to read config for container %s: %s", vmid_key, exc)
795
- description = (cfg.get("description") or "")
796
- if MANAGED_MARKER in description:
797
- continue
798
- unmanaged.append(
799
- _build_unmanaged_container_entry(ct, cfg, vmid_key, default_storage, entry_type="lxc")
800
- )
801
- for vm in proxmox.nodes(node).qemu.get():
802
- vmid_val = vm.get("vmid")
803
- if vmid_val is None:
804
- continue
805
- vmid_key = str(_to_int(vmid_val))
806
- statuses[vmid_key] = (vm.get("status") or "unknown").lower()
807
- if vmid_key in managed_vmids:
808
- continue
809
- cfg: Dict[str, Any] = {}
810
- try:
811
- cfg = proxmox.nodes(node).qemu(vmid_key).config.get() or {}
812
- except Exception as exc:
813
- logger.debug("Failed to read config for VM %s: %s", vmid_key, exc)
814
- description = (cfg.get("description") or "")
815
- if MANAGED_MARKER in description:
816
- continue
817
- unmanaged.append(
818
- _build_unmanaged_container_entry(
819
- vm,
820
- cfg,
821
- vmid_key,
822
- default_storage,
823
- entry_type="qemu",
824
- )
825
- )
787
+ statuses = {
788
+ str(ct.get("vmid")): (ct.get("status") or "unknown").lower()
789
+ for ct in proxmox.nodes(node).lxc.get()
790
+ }
826
791
  except Exception as exc: # pragma: no cover - best effort
827
792
  logger.debug("Failed to refresh container statuses: %s", exc)
828
- storage_snapshot = (
829
- _get_storage_snapshot(proxmox, node, default_storage)
830
- if proxmox and node
831
- else None
832
- )
833
- return statuses, unmanaged, node_status, storage_snapshot
793
+ return
794
+ for record in records:
795
+ vmid = record.get("vmid")
796
+ if vmid is None:
797
+ continue
798
+ try:
799
+ vmid_key = str(int(vmid))
800
+ except (ValueError, TypeError):
801
+ continue
802
+ status = statuses.get(vmid_key)
803
+ if status:
804
+ record["status"] = status
834
805
 
835
806
  now = time.monotonic()
836
807
  with _MANAGED_CONTAINERS_CACHE_LOCK:
@@ -840,22 +811,8 @@ def _get_managed_containers_summary(force: bool = False) -> Dict[str, Any]:
840
811
  return cached
841
812
  config = _load_config()
842
813
  records = _load_managed_container_records()
843
- managed_vmids: Set[str] = {
844
- str(_to_int(record.get("vmid"))) for record in records if record.get("vmid") is not None
845
- }
846
- default_storage = config.get("default_storage") if config else None
847
- statuses, unmanaged, node_status, storage_snapshot = _refresh_container_statuses(
848
- records, config, managed_vmids, default_storage
849
- )
850
- for record in records:
851
- vmid = record.get("vmid")
852
- if vmid is None:
853
- continue
854
- vmid_key = str(_to_int(vmid))
855
- status = statuses.get(vmid_key)
856
- if status:
857
- record["status"] = status
858
- summary = _build_managed_containers_summary(records, unmanaged, node_status, storage_snapshot, default_storage)
814
+ _refresh_container_statuses(records, config)
815
+ summary = _build_full_container_summary(records, config)
859
816
  with _MANAGED_CONTAINERS_CACHE_LOCK:
860
817
  _MANAGED_CONTAINERS_CACHE["timestamp"] = now
861
818
  _MANAGED_CONTAINERS_CACHE["summary"] = summary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: portacode
3
- Version: 1.4.16.dev10
3
+ Version: 1.4.16.dev11
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
@@ -1,7 +1,7 @@
1
1
  portacode/README.md,sha256=4dKtpvR8LNgZPVz37GmkQCMWIr_u25Ao63iW56s7Ke4,775
2
2
  portacode/__init__.py,sha256=oB3sV1wXr-um-RXio73UG8E5Xx6cF2ZVJveqjNmC-vQ,1086
3
3
  portacode/__main__.py,sha256=jmHTGC1hzmo9iKJLv-SSYe9BSIbPPZ2IOpecI03PlTs,296
4
- portacode/_version.py,sha256=xQIvOHyr77yXKzNXQJL1rE01nrJdvUnM7LX3jaSU684,721
4
+ portacode/_version.py,sha256=ZsUlJUljVebwmjOdkxbUPcwi8iyVjIuyBU9g68awYpA,721
5
5
  portacode/cli.py,sha256=mGLKoZ-T2FBF7IA9wUq0zyG0X9__-A1ao7gajjcVRH8,21828
6
6
  portacode/data.py,sha256=5-s291bv8J354myaHm1Y7CQZTZyRzMU3TGe5U4hb-FA,1591
7
7
  portacode/keypair.py,sha256=0OO4vHDcF1XMxCDqce61xFTlFwlTcmqe5HyGsXFEt7s,5838
@@ -22,7 +22,7 @@ portacode/connection/handlers/diff_handlers.py,sha256=iYTIRCcpEQ03vIPKZCsMTE5aZb
22
22
  portacode/connection/handlers/file_handlers.py,sha256=nAJH8nXnX07xxD28ngLpgIUzcTuRwZBNpEGEKdRqohw,39507
23
23
  portacode/connection/handlers/project_aware_file_handlers.py,sha256=AqgMnDqX2893T2NsrvUSCwjN5VKj4Pb2TN0S_SuboOE,9803
24
24
  portacode/connection/handlers/project_state_handlers.py,sha256=v6ZefGW9i7n1aZLq2jOGumJIjYb6aHlPI4m1jkYewm8,1686
25
- portacode/connection/handlers/proxmox_infra.py,sha256=5XUUzr24bs-QOMpuVPq071ZDAmhadKiMs2E-wO62kMw,86709
25
+ portacode/connection/handlers/proxmox_infra.py,sha256=-zAmaOTCTJaic8sclb5Z0DVCl_pU7XePo86NKohi3gc,85295
26
26
  portacode/connection/handlers/registry.py,sha256=qXGE60sYEWg6ZtVQzFcZ5YI2XWR6lMgw4hAL9x5qR1I,6181
27
27
  portacode/connection/handlers/session.py,sha256=uNGfiO_1B9-_yjJKkpvmbiJhIl6b-UXlT86UTfd6WYE,42219
28
28
  portacode/connection/handlers/system_handlers.py,sha256=fr12QpOr_Z8KYGUU-AYrTQwRPAcrLK85hvj3SEq1Kw8,14757
@@ -65,7 +65,7 @@ portacode/utils/__init__.py,sha256=NgBlWTuNJESfIYJzP_3adI1yJQJR0XJLRpSdVNaBAN0,3
65
65
  portacode/utils/diff_apply.py,sha256=4Oi7ft3VUCKmiUE4VM-OeqO7Gk6H7PF3WnN4WHXtjxI,15157
66
66
  portacode/utils/diff_renderer.py,sha256=S76StnQ2DLfsz4Gg0m07UwPfRp8270PuzbNaQq-rmYk,13850
67
67
  portacode/utils/ntp_clock.py,sha256=VqCnWCTehCufE43W23oB-WUdAZGeCcLxkmIOPwInYHc,2499
68
- portacode-1.4.16.dev10.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
68
+ portacode-1.4.16.dev11.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
69
69
  test_modules/README.md,sha256=Do_agkm9WhSzueXjRAkV_xEj6Emy5zB3N3VKY5Roce8,9274
70
70
  test_modules/__init__.py,sha256=1LcbHodIHsB0g-g4NGjSn6AMuCoGbymvXPYLOb6Z7F0,53
71
71
  test_modules/test_device_online.py,sha256=QtYq0Dq9vME8Gp2O4fGSheqVf8LUtpsSKosXXk56gGM,1654
@@ -91,8 +91,8 @@ testing_framework/core/playwright_manager.py,sha256=Tw46qwxIhOFkS48C2IWIQHHNpEe-
91
91
  testing_framework/core/runner.py,sha256=j2QwNJmAxVBmJvcbVS7DgPJUKPNzqfLmt_4NNdaKmZU,19297
92
92
  testing_framework/core/shared_cli_manager.py,sha256=BESSNtyQb7BOlaOvZmm04T8Uezjms4KCBs2MzTxvzYQ,8790
93
93
  testing_framework/core/test_discovery.py,sha256=2FZ9fJ8Dp5dloA-fkgXoJ_gCMC_nYPBnA3Hs2xlagzM,4928
94
- portacode-1.4.16.dev10.dist-info/METADATA,sha256=CYVCSXxYLiRseul0OVvOLRWWp2-Lv1u737bm-qT1hVk,13052
95
- portacode-1.4.16.dev10.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
96
- portacode-1.4.16.dev10.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
- portacode-1.4.16.dev10.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
- portacode-1.4.16.dev10.dist-info/RECORD,,
94
+ portacode-1.4.16.dev11.dist-info/METADATA,sha256=nEvtzLLbq9J8jG-kubMsNMRQ07YdtEczInIarfWpGhY,13052
95
+ portacode-1.4.16.dev11.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
96
+ portacode-1.4.16.dev11.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
+ portacode-1.4.16.dev11.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
+ portacode-1.4.16.dev11.dist-info/RECORD,,