From mboxrd@z Thu Jan  1 00:00:00 1970
Return-Path: <m.carrara@proxmox.com>
Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68])
 (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits)
 key-exchange X25519 server-signature RSA-PSS (2048 bits))
 (No client certificate requested)
 by lists.proxmox.com (Postfix) with ESMTPS id C2F89BB283
 for <pve-devel@lists.proxmox.com>; Fri, 22 Mar 2024 19:07:26 +0100 (CET)
Received: from firstgate.proxmox.com (localhost [127.0.0.1])
 by firstgate.proxmox.com (Proxmox) with ESMTP id A5242A396
 for <pve-devel@lists.proxmox.com>; Fri, 22 Mar 2024 19:06:56 +0100 (CET)
Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com
 [94.136.29.106])
 (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits)
 key-exchange X25519 server-signature RSA-PSS (2048 bits))
 (No client certificate requested)
 by firstgate.proxmox.com (Proxmox) with ESMTPS
 for <pve-devel@lists.proxmox.com>; Fri, 22 Mar 2024 19:06:55 +0100 (CET)
Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1])
 by proxmox-new.maurer-it.com (Proxmox) with ESMTP id BE6D44189C
 for <pve-devel@lists.proxmox.com>; Fri, 22 Mar 2024 19:06:55 +0100 (CET)
From: Max Carrara <m.carrara@proxmox.com>
To: pve-devel@lists.proxmox.com
Date: Fri, 22 Mar 2024 19:06:20 +0100
Message-Id: <20240322180624.441185-4-m.carrara@proxmox.com>
X-Mailer: git-send-email 2.39.2
In-Reply-To: <20240322180624.441185-1-m.carrara@proxmox.com>
References: <20240322180624.441185-1-m.carrara@proxmox.com>
MIME-Version: 1.0
Content-Transfer-Encoding: 8bit
X-SPAM-LEVEL: Spam detection results:  0
 AWL 0.022 Adjusted score from AWL reputation of From: address
 BAYES_00                 -1.9 Bayes spam probability is 0 to 1%
 DMARC_MISSING             0.1 Missing DMARC policy
 KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment
 SPF_HELO_NONE           0.001 SPF: HELO does not publish an SPF Record
 SPF_PASS               -0.001 SPF: sender matches SPF record
Subject: [pve-devel] [PATCH v2 pve-esxi-import-tools 3/7] listvms: improve
 typing and add dataclasses to represent dicts
X-BeenThere: pve-devel@lists.proxmox.com
X-Mailman-Version: 2.1.29
Precedence: list
List-Id: Proxmox VE development discussion <pve-devel.lists.proxmox.com>
List-Unsubscribe: <https://lists.proxmox.com/cgi-bin/mailman/options/pve-devel>, 
 <mailto:pve-devel-request@lists.proxmox.com?subject=unsubscribe>
List-Archive: <http://lists.proxmox.com/pipermail/pve-devel/>
List-Post: <mailto:pve-devel@lists.proxmox.com>
List-Help: <mailto:pve-devel-request@lists.proxmox.com?subject=help>
List-Subscribe: <https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel>, 
 <mailto:pve-devel-request@lists.proxmox.com?subject=subscribe>
X-List-Received-Date: Fri, 22 Mar 2024 18:07:26 -0000

This commit replaces some of the explicitly imported types from the
`typing` module with their inbuilt counterparts, e.g. `typing.List`
becomes `list`. This is supported since Python 3.9 [0].

Additionally, file paths are now represented as `pathlib.Path` [1],
which also checks whether the given string is actually a valid path
when constructed.

Furthermore, the `dict`s with values of mixed types are now
represented as dataclasses [2] instead, in order to make them more
type-safe (--> allow for better linting).

Because dataclasses and `pathlib.Path`s are not JSON-serializable by
default however, a helper function is added, which allows for more
fine-grained control regarding how those objects are serialized.

[0]: https://docs.python.org/3.9/whatsnew/3.9.html#type-hinting-generics-in-standard-collections
[1]: https://docs.python.org/3.11/library/pathlib.html
[2]: https://docs.python.org/3.11/library/dataclasses.html

Signed-off-by: Max Carrara <m.carrara@proxmox.com>
---
Changes v1 --> v2:
  * rebase so patch applies onto previous patch
    (the `Tuple` import removal)

 listvms.py | 99 ++++++++++++++++++++++++++++++++++++++++--------------
 1 file changed, 73 insertions(+), 26 deletions(-)

diff --git a/listvms.py b/listvms.py
index 0b64b0b..fe257a4 100755
--- a/listvms.py
+++ b/listvms.py
@@ -1,16 +1,59 @@
 #!/usr/bin/python3
 
+import dataclasses
 import json
 import ssl
 import sys
 
-from typing import List, Dict, Optional
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
 
 from pyVim.connect import SmartConnect, Disconnect
 from pyVmomi import vim
 
 
-def get_datacenter_of_vm(vm: vim.VirtualMachine) -> Optional[vim.Datacenter]:
+@dataclass
+class VmVmxInfo:
+    datastore: str
+    path: Path
+    checksum: str
+
+
+@dataclass
+class VmDiskInfo:
+    datastore: str
+    path: Path
+    capacity: int
+
+
+@dataclass
+class VmInfo:
+    config: VmVmxInfo
+    disks: list[VmDiskInfo]
+    power: str
+
+
+def json_dump_helper(obj: Any) -> Any:
+    """Converts otherwise unserializable objects to types that can be
+    serialized as JSON.
+
+    Raises:
+        TypeError: If the conversion of the object is not supported.
+    """
+    if dataclasses.is_dataclass(obj):
+        return dataclasses.asdict(obj)
+
+    match obj:
+        case Path():
+            return str(obj)
+
+    raise TypeError(
+        f"Can't make object of type {type(obj)} JSON-serializable: {repr(obj)}"
+    )
+
+
+def get_datacenter_of_vm(vm: vim.VirtualMachine) -> vim.Datacenter | None:
     """Find the Datacenter object a VM belongs to."""
     current = vm.parent
     while current:
@@ -20,10 +63,10 @@ def get_datacenter_of_vm(vm: vim.VirtualMachine) -> Optional[vim.Datacenter]:
     return None
 
 
-def list_vms(service_instance: vim.ServiceInstance) -> List[vim.VirtualMachine]:
+def list_vms(service_instance: vim.ServiceInstance) -> list[vim.VirtualMachine]:
     """List all VMs on the ESXi/vCenter server."""
     content = service_instance.content
-    vm_view = content.viewManager.CreateContainerView(
+    vm_view: Any = content.viewManager.CreateContainerView(
         content.rootFolder,
         [vim.VirtualMachine],
         True,
@@ -32,39 +75,36 @@ def list_vms(service_instance: vim.ServiceInstance) -> List[vim.VirtualMachine]:
     vm_view.Destroy()
     return vms
 
-def parse_file_path(path):
+def parse_file_path(path) -> tuple[str, Path]:
     """Parse a path of the form '[datastore] file/path'"""
     datastore_name, relative_path = path.split('] ', 1)
     datastore_name = datastore_name.strip('[')
-    return (datastore_name, relative_path)
+    return (datastore_name, Path(relative_path))
 
-def get_vm_vmx_info(vm: vim.VirtualMachine) -> Dict[str, str]:
+def get_vm_vmx_info(vm: vim.VirtualMachine) -> VmVmxInfo:
     """Extract VMX file path and checksum from a VM object."""
     datastore_name, relative_vmx_path = parse_file_path(vm.config.files.vmPathName)
-    return {
-        'datastore': datastore_name,
-        'path': relative_vmx_path,
-        'checksum': vm.config.vmxConfigChecksum.hex() if vm.config.vmxConfigChecksum else 'N/A'
-    }
 
-def get_vm_disk_info(vm: vim.VirtualMachine) -> Dict[str, int]:
+    return VmVmxInfo(
+        datastore=datastore_name,
+        path=relative_vmx_path,
+        checksum=vm.config.vmxConfigChecksum.hex() if vm.config.vmxConfigChecksum else 'N/A'
+    )
+
+def get_vm_disk_info(vm: vim.VirtualMachine) -> list[VmDiskInfo]:
     disks = []
     for device in vm.config.hardware.device:
-        if type(device).__name__ == 'vim.vm.device.VirtualDisk':
+        if isinstance(device, vim.vm.device.VirtualDisk):
             try:
                 (datastore, path) = parse_file_path(device.backing.fileName)
                 capacity = device.capacityInBytes
-                disks.append({
-                    'datastore': datastore,
-                    'path': path,
-                    'capacity': capacity,
-                })
+                disks.append(VmDiskInfo(datastore, path, capacity))
             except Exception as err:
                 # if we can't figure out the disk stuff that's fine...
                 print("failed to get disk information for esxi vm: ", err, file=sys.stderr)
     return disks
 
-def get_all_datacenters(service_instance: vim.ServiceInstance) -> List[vim.Datacenter]:
+def get_all_datacenters(service_instance: vim.ServiceInstance) -> list[vim.Datacenter]:
     """Retrieve all datacenters from the ESXi/vCenter server."""
     content = service_instance.content
     dc_view = content.viewManager.CreateContainerView(content.rootFolder, [vim.Datacenter], True)
@@ -110,18 +150,25 @@ def main():
             name = 'vm ' + vm.name
             try:
                 dc = get_datacenter_of_vm(vm)
-                vm_info = {
-                    'config': get_vm_vmx_info(vm),
-                    'disks': get_vm_disk_info(vm),
-                    'power': vm.runtime.powerState,
-                }
+                if dc is None:
+                    print(
+                        f"Failed to get datacenter for {name}",
+                        file=sys.stderr
+                    )
+
+                vm_info = VmInfo(
+                    config=get_vm_vmx_info(vm),
+                    disks=get_vm_disk_info(vm),
+                    power=vm.runtime.powerState,
+                )
+
                 datastore_info = {ds.name: ds.url for ds in vm.config.datastoreUrl}
                 data.setdefault(dc.name, {}).setdefault('vms', {})[vm.name] = vm_info
                 data.setdefault(dc.name, {}).setdefault('datastores', {}).update(datastore_info)
             except Exception as err:
                 print("failed to get info for", name, ':', err, file=sys.stderr)
 
-        print(json.dumps(data, indent=2))
+        print(json.dumps(data, indent=2, default=json_dump_helper))
     finally:
         Disconnect(si)
 
-- 
2.39.2