Repository: CiscoDevNet/virl2-client (language: python)

Issue:
SIMPLE-5289 pcl small ldap improvements fixes



Discussion / hints:

Fix patch:
diff --git a/Makefile b/Makefile
index 3b24aba..1684dc2 100644
--- a/Makefile
+++ b/Makefile
@@ -5,7 +5,7 @@
 # https://github.com/python-poetry/poetry/issues/3160
 # when resolved, we should be able to run with hashes
 tests/requirements.txt: poetry.lock
-	poetry export --format=requirements.txt --dev --without-hashes --output=$@
+	poetry export --format=requirements.txt --with dev --without-hashes --output=$@
 
 clean:
 	rm -rf dist virl2_client.egg-info .built .pytest_cache .coverage coverage.xml
diff --git a/NOTICE b/NOTICE
index bcc647c..689156d 100644
--- a/NOTICE
+++ b/NOTICE
@@ -1,5 +1,5 @@
 Python bindings for the Cisco VIRL 2 Network Simulation Platform
 
-Copyright (c) 2019-2022, Cisco Systems, Inc. and/or its affiliates
+Copyright (c) 2019-2024, Cisco Systems, Inc. and/or its affiliates
 
 This project includes software developed at Cisco Systems, Inc. and/or its affiliates.
diff --git a/docs/source/conf.py b/docs/source/conf.py
index bf21f0d..8bb6f7a 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Configuration file for the Sphinx documentation builder.
@@ -25,7 +25,7 @@
 # -- Project information -----------------------------------------------------
 
 project = "virl2_client"
-copyright = "Copyright (c) 2019-2022, Cisco Systems, Inc."
+copyright = "Copyright (c) 2019-2024, Cisco Systems, Inc."
 author = "VIRL2 team <virl@cisco.com>"
 
 # The short X.Y version
@@ -176,7 +176,7 @@
 # Bibliographic Dublin Core info.
 epub_title = project
 
-# The unique identifier of the text. This can be a ISBN number
+# The unique identifier of the text. This can be an ISBN
 # or the project homepage.
 #
 # epub_identifier = ''
@@ -194,7 +194,7 @@
     # 'member-order': 'bysource',
     "member-order": "groupwise",
     "special-members": "__init__",
-    # "ignore-module-all": None,
+    "ignore-module-all": True,
     # 'undoc-members': None,
     # 'exclude-members': '__weakref__'
 }
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 9376369..8166992 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -14,7 +14,6 @@ CML 2 controller.
 
     intro
     examples
-    api/*
 
 .. only:: internal
 
diff --git a/examples/demo.ipynb b/examples/demo.ipynb
index ff51b0e..2b5f1d8 100644
--- a/examples/demo.ipynb
+++ b/examples/demo.ipynb
@@ -6,10 +6,8 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "import urllib3\n",
     "from virl2_client import ClientLibrary\n",
     "\n",
-    "urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)\n",
     "client = ClientLibrary(\"https://192.168.1.1\", \"virl2\", \"virl2\", ssl_verify=False)"
    ]
   },
diff --git a/examples/licensing.py b/examples/licensing.py
index 6579302..854bd03 100644
--- a/examples/licensing.py
+++ b/examples/licensing.py
@@ -1,7 +1,7 @@
 #!/usr/bin/env python3
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -43,7 +43,7 @@
 result = licensing.register_wait(SL_TOKEN)
 if not result:
     result = licensing.get_reservation_return_code()
-    print("ERROR: Failed to register with Smart License server: {}!".format(result))
+    print(f"ERROR: Failed to register with Smart License server: {result}!")
     exit(1)
 
 # Get the current registration status. This returns a JSON blob with license
diff --git a/examples/link_conditioning.py b/examples/link_conditioning.py
index 8f38577..9d4f4cb 100644
--- a/examples/link_conditioning.py
+++ b/examples/link_conditioning.py
@@ -1,7 +1,7 @@
 #!/usr/bin/env python3
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -21,7 +21,6 @@
 
 
 import getpass
-import re
 
 from httpx import HTTPStatusError
 
@@ -39,67 +38,61 @@
 labs = client.find_labs_by_title(LAB_NAME)
 
 if not labs or len(labs) != 1:
-    print("ERROR: Unable to find a unique lab named {}".format(LAB_NAME))
+    print(f"ERROR: Unable to find a unique lab named {LAB_NAME}")
     exit(1)
 
-lobj = client.join_existing_lab(labs[0].id)
+lab = client.join_existing_lab(labs[0].id)
 
-if not lobj:
-    print("ERROR: Failed to join lab {}".format(LAB_NAME))
+if not lab:
+    print(f"ERROR: Failed to join lab {LAB_NAME}")
     exit(1)
 
 # Print all links in the lab and ask which link to condition.
 i = 1
-liobjs = []
-for link in lobj.links():
+links = []
+for link in lab.links():
     print(
-        "{}. {}[{}] <-> {}[{}]".format(
-            i,
-            link.interface_a.node.label,
-            link.interface_a.label,
-            link.interface_b.node.label,
-            link.interface_b.label,
-        )
+        f"{i}. {link.interface_a.node.label}[{link.interface_a.label}] <-> "
+        f"{link.interface_b.node.label}[{link.interface_b.label}]"
     )
-    liobjs.append(lobj.get_link_by_interfaces(link.interface_a, link.interface_b))
+    links.append(link.interface_a.get_link_to(link.interface_b))
     i += 1
 
 print()
-lnum = 0
-while lnum < 1 or lnum > i:
-    lnum = input("Enter link number to condition (1-{}): ".format(i))
+link_number = 0
+while link_number < 1 or link_number > i:
     try:
-        lnum = int(lnum)
+        link_number = int(input(f"Enter link number to condition (1-{i}): "))
     except ValueError:
-        lnum = 0
+        link_number = 0
 
 # Print the selected link's current conditioning (if any).
-link = liobjs[lnum - 1]
-print("Current condition is {}".format(link.get_condition()))
+link = links[link_number - 1]
+print(f"Current condition is {link.get_condition()}")
 # Request the new conditoning for bandwidth, latency, jitter, and loss.
 # Bandwidth is an integer between 0-10000000 kbps
 # Bandwidth of 0 is "no bandwidth restriction"
 # Latency is an integer between 0-10000 ms
 # Jitter is an integer between 0-10000 ms
 # Loss is a float between 0-100%
-new_cond = input(
+new_condition = input(
     "enter new condition in format 'BANDWIDTH, "
     "LATENCY, JITTER, LOSS' or 'None' to disable: "
 )
 # If "None" is provided disable any conditioning on the link.
-if new_cond.lower() == "none":
+if new_condition.lower() == "none":
     link.remove_condition()
     print("Link conditioning has been disabled.")
 else:
     try:
         # Set the current conditioning based on the provided values.
-        cond_list = re.split(r"\s*,\s*", new_cond)
-        bw = int(cond_list[0])  # Bandwidth is an int
+        cond_list = new_condition.split(",")
+        bandwidth = int(cond_list[0])  # Bandwidth is an int
         latency = int(cond_list[1])  # Latency is an int
         jitter = int(cond_list[2])  # Jitter is an int
         loss = float(cond_list[3])  # Loss is a float
-        link.set_condition(bw, latency, jitter, loss)
+        link.set_condition(bandwidth, latency, jitter, loss)
         print("Link conditioning set.")
     except HTTPStatusError as exc:
-        print("ERROR: Failed to set link conditioning: {}", format(exc))
+        print(f"ERROR: Failed to set link conditioning: {exc}")
         exit(1)
diff --git a/examples/sample.py b/examples/sample.py
index e910f6e..1aa44fc 100644
--- a/examples/sample.py
+++ b/examples/sample.py
@@ -1,7 +1,7 @@
 #!/usr/bin/env python3
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -19,22 +19,11 @@
 # limitations under the License.
 #
 
-from virl2_client import ClientLibrary
+# This script demonstrates various functionalities of the client library.
+# Each example is accompanied by comments explaining its purpose and usage.
+import pathlib
 
-CERT = """-----BEGIN CERTIFICATE-----
-MIIDGzCCAgOgAwIBAgIBATANBgkqhkiG9w0BAQsFADAvMQ4wDAYDVQQKEwVDaXNj
-WhcNMzMwNDI0MjE1NTQzWjAvMQ4wDAYDVQQKEwVDaXNjbzEdMBsGA1UEAxMUTGlj
-ZW5zaW5nIFJvb3QgLSBERVYwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB
-AQCcVnEB1h7fLrzDunrg27JBs7QyipsA64qA0Cqob17xrr/etnvWrX2te0P1gnU7
-/8wcpaeEGgdpNNOvmQeO9heRlvpPs/LtOULHVr8coKnMmKen+eQ3JNnmHUeJ6eeS
-3Z8ntFF8K97Q61uaeHughdm78APwVjvgpEUMjxJ7VYM+vBOFLZutmGjTrgdcJ5h8
-HRMBAf8EBTADAQH/MB0GA1UdDgQWBBRDIUUhtfshehpNG7cCNuZky+yLZTANBgkq
-hkiG9w0BAQsFAAOCAQEAhfGx8q6ufS+cqwNRwynj8a6YLfEfXjyQ9gCzze1aFJH7
-3wfgbKoPQyWftMSuIID2dYw7esVOqqA+xbUKL2cK/4ftpkYvz8Q5Z8AkqzLuPM3P
-oEudrhu6u9rI31WHz1HLHABaKC+LUYpajG+bPKq6NEYy7zp1wvRUUHqbz9MMi+VK
-EYct4M8SANDRAY/ZrGhZaBZ+Qhybw5Ttm8hUY4OygUYHsr3t38FgW00WAHtocj4l
-z1LPIlCn0j76n2sH+w9jhp3MO7xlJQaTOM9rpsuO/Q==
------END CERTIFICATE-----"""
+from virl2_client import ClientLibrary
 
 SSMS = "https://sch-alpha.cisco.com/its/service/oddce/services/DDCEService"
 
@@ -45,55 +34,72 @@
 )
 
 
-# setup the connection and clean everything
-cl = ClientLibrary("http://localhost:8001", "cml2", "cml2cml2", allow_http=True)
-cl.is_system_ready(wait=True)
+# Set up the CML 2 connection
+server_url = "http://localhost:8001"
+username = "cml2"  # Default username if not changed in CML instance
+password = pathlib.Path("/etc/machine-id").read_text().strip()
+# Default password is equal to the contents of the CML instances /etc/machine-id file
+# If you are running this script remotely, replace the password above
+client_library = ClientLibrary(server_url, username, password, allow_http=True)
 
-# set transport if needed - also proxy can be set if needed
-# cl.licensing.licensing.set_transport(
-#     ssms=ssms, proxy_server="172.16.1.100", proxy_port=8888
-# )
-cl.licensing.set_transport(ssms=SSMS)
-cl.licensing.install_certificate(cert=CERT)
-# 'register_wait' method waits max 45s for registration status to become COMPLETED
-# and another 45s for authorization status to become IN_COMPLIANCE
-cl.licensing.register_wait(token=TOKEN)
+# Check if the CML 2 system is ready
+client_library.is_system_ready(wait=True)
 
+# Set up licensing configuration
+client_library.licensing.set_transport(ssms=SSMS)
+client_library.licensing.register_wait(token=TOKEN)
 
-lab_list = cl.get_lab_list()
+# Get a list of existing labs and print their details
+lab_list = client_library.get_lab_list()
 for lab_id in lab_list:
-    lab = cl.join_existing_lab(lab_id)
-    lab.stop()
-    lab.wipe()
-    cl.remove_lab(lab_id)
-
-lab = cl.create_lab()
-lab = cl.join_existing_lab(lab_id="lab_1")
-
-s1 = lab.create_node("s1", "server", 50, 100)
-s2 = lab.create_node("s2", "server", 50, 200)
-print(s1, s2)
-
-# create a link between s1 and s2, equivalent to
-#   s1_i1 = s1.create_interface()
-#   s2_i1 = s2.create_interface()
-#   lab.create_link(s1_i1, s2_i1)
-lab.connect_two_nodes(s1, s2)
-
-# this must remove the link between s1 and s2
-lab.remove_node(s2)
-
+    lab = client_library.join_existing_lab(lab_id)
+    print("Lab ID:", lab.id)
+    print("Lab Title:", lab.title)
+    print("Lab Description:", lab.description)
+    print("Lab State:", lab.state)
+    print("----")
+
+# A simpler way to join all labs at once
+labs = client_library.all_labs()
+
+# Create a lab
+lab = client_library.create_lab()
+
+# Create two server nodes
+server1 = lab.create_node("server1", "server", 50, 100)
+server2 = lab.create_node("server2", "server", 50, 200)
+print("Created nodes:", server1, server2)
+
+# Create a link between server1 and server2
+link = lab.connect_two_nodes(server1, server2)
+print("Created link between server1 and server2")
+
+# Remove the link between server1 and server2
+link.remove()
+print("Removed link between server1 and server2")
+
+# Manually synchronize lab states - this happens automatically once per second
+# by default, but we can skip the wait by calling this method
 lab.sync_states()
+
+# Print the state of each node and its interfaces
 for node in lab.nodes():
-    print(node, node.state)
-    for iface in node.interfaces():
-        print(iface, iface.state)
-
-assert [link for link in lab.links() if link.state is not None] == []
-
-# in case you's like to deregister after you're done
-status = cl.licensing.deregister()
-cl.licensing.remove_certificate()
-# set licensing back to default transport
-# default ssms is "https://smartreceiver.cisco.com/licservice/license"
-cl.licensing.set_default_transport()
+    print(f"Node: {node.label} | State: {node.state}")
+    for interface in node.interfaces():
+        print(f"    Interface: {interface.label} | State: {interface.state}")
+
+# Export a lab topology to a file
+lab_data = lab.download()
+with open("demo_lab_export.yaml", "w") as file:
+    file.write(lab_data)
+print("Lab exported successfully.")
+
+# Clean up the lab
+lab.stop()
+lab.wipe()
+lab.remove()  # or client_library.remove_lab(lab_id)
+
+# Deregister (optional) and set licensing back to the default transport (optional)
+# Default SSMS is "https://smartreceiver.cisco.com/licservice/license"
+client_library.licensing.deregister()
+client_library.licensing.set_default_transport()
diff --git a/poetry.lock b/poetry.lock
index 9ce9e34..f403624 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,54 +1,122 @@
+# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
+
 [[package]]
 name = "aiofiles"
-version = "23.1.0"
+version = "23.2.1"
 description = "File support for asyncio."
-category = "main"
 optional = true
-python-versions = ">=3.7,<4.0"
+python-versions = ">=3.7"
+files = [
+    {file = "aiofiles-23.2.1-py3-none-any.whl", hash = "sha256:19297512c647d4b27a2cf7c34caa7e405c0d60b5560618a29a9fe027b18b0107"},
+    {file = "aiofiles-23.2.1.tar.gz", hash = "sha256:84ec2218d8419404abcb9f0c02df3f34c6e0a68ed41072acfb1cef5cbc29051a"},
+]
 
 [[package]]
 name = "aiohttp"
-version = "3.8.5"
+version = "3.9.3"
 description = "Async http client/server framework (asyncio)"
-category = "main"
 optional = true
-python-versions = ">=3.6"
+python-versions = ">=3.8"
+files = [
+    {file = "aiohttp-3.9.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:939677b61f9d72a4fa2a042a5eee2a99a24001a67c13da113b2e30396567db54"},
+    {file = "aiohttp-3.9.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1f5cd333fcf7590a18334c90f8c9147c837a6ec8a178e88d90a9b96ea03194cc"},
+    {file = "aiohttp-3.9.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:82e6aa28dd46374f72093eda8bcd142f7771ee1eb9d1e223ff0fa7177a96b4a5"},
+    {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f56455b0c2c7cc3b0c584815264461d07b177f903a04481dfc33e08a89f0c26b"},
+    {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bca77a198bb6e69795ef2f09a5f4c12758487f83f33d63acde5f0d4919815768"},
+    {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e083c285857b78ee21a96ba1eb1b5339733c3563f72980728ca2b08b53826ca5"},
+    {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab40e6251c3873d86ea9b30a1ac6d7478c09277b32e14745d0d3c6e76e3c7e29"},
+    {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df822ee7feaaeffb99c1a9e5e608800bd8eda6e5f18f5cfb0dc7eeb2eaa6bbec"},
+    {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:acef0899fea7492145d2bbaaaec7b345c87753168589cc7faf0afec9afe9b747"},
+    {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cd73265a9e5ea618014802ab01babf1940cecb90c9762d8b9e7d2cc1e1969ec6"},
+    {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:a78ed8a53a1221393d9637c01870248a6f4ea5b214a59a92a36f18151739452c"},
+    {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:6b0e029353361f1746bac2e4cc19b32f972ec03f0f943b390c4ab3371840aabf"},
+    {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7cf5c9458e1e90e3c390c2639f1017a0379a99a94fdfad3a1fd966a2874bba52"},
+    {file = "aiohttp-3.9.3-cp310-cp310-win32.whl", hash = "sha256:3e59c23c52765951b69ec45ddbbc9403a8761ee6f57253250c6e1536cacc758b"},
+    {file = "aiohttp-3.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:055ce4f74b82551678291473f66dc9fb9048a50d8324278751926ff0ae7715e5"},
+    {file = "aiohttp-3.9.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6b88f9386ff1ad91ace19d2a1c0225896e28815ee09fc6a8932fded8cda97c3d"},
+    {file = "aiohttp-3.9.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c46956ed82961e31557b6857a5ca153c67e5476972e5f7190015018760938da2"},
+    {file = "aiohttp-3.9.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07b837ef0d2f252f96009e9b8435ec1fef68ef8b1461933253d318748ec1acdc"},
+    {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad46e6f620574b3b4801c68255492e0159d1712271cc99d8bdf35f2043ec266"},
+    {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ed3e046ea7b14938112ccd53d91c1539af3e6679b222f9469981e3dac7ba1ce"},
+    {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:039df344b45ae0b34ac885ab5b53940b174530d4dd8a14ed8b0e2155b9dddccb"},
+    {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7943c414d3a8d9235f5f15c22ace69787c140c80b718dcd57caaade95f7cd93b"},
+    {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84871a243359bb42c12728f04d181a389718710129b36b6aad0fc4655a7647d4"},
+    {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5eafe2c065df5401ba06821b9a054d9cb2848867f3c59801b5d07a0be3a380ae"},
+    {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9d3c9b50f19704552f23b4eaea1fc082fdd82c63429a6506446cbd8737823da3"},
+    {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:f033d80bc6283092613882dfe40419c6a6a1527e04fc69350e87a9df02bbc283"},
+    {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:2c895a656dd7e061b2fd6bb77d971cc38f2afc277229ce7dd3552de8313a483e"},
+    {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1f5a71d25cd8106eab05f8704cd9167b6e5187bcdf8f090a66c6d88b634802b4"},
+    {file = "aiohttp-3.9.3-cp311-cp311-win32.whl", hash = "sha256:50fca156d718f8ced687a373f9e140c1bb765ca16e3d6f4fe116e3df7c05b2c5"},
+    {file = "aiohttp-3.9.3-cp311-cp311-win_amd64.whl", hash = "sha256:5fe9ce6c09668063b8447f85d43b8d1c4e5d3d7e92c63173e6180b2ac5d46dd8"},
+    {file = "aiohttp-3.9.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:38a19bc3b686ad55804ae931012f78f7a534cce165d089a2059f658f6c91fa60"},
+    {file = "aiohttp-3.9.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:770d015888c2a598b377bd2f663adfd947d78c0124cfe7b959e1ef39f5b13869"},
+    {file = "aiohttp-3.9.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee43080e75fc92bf36219926c8e6de497f9b247301bbf88c5c7593d931426679"},
+    {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52df73f14ed99cee84865b95a3d9e044f226320a87af208f068ecc33e0c35b96"},
+    {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc9b311743a78043b26ffaeeb9715dc360335e5517832f5a8e339f8a43581e4d"},
+    {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b955ed993491f1a5da7f92e98d5dad3c1e14dc175f74517c4e610b1f2456fb11"},
+    {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:504b6981675ace64c28bf4a05a508af5cde526e36492c98916127f5a02354d53"},
+    {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6fe5571784af92b6bc2fda8d1925cccdf24642d49546d3144948a6a1ed58ca5"},
+    {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ba39e9c8627edc56544c8628cc180d88605df3892beeb2b94c9bc857774848ca"},
+    {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:e5e46b578c0e9db71d04c4b506a2121c0cb371dd89af17a0586ff6769d4c58c1"},
+    {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:938a9653e1e0c592053f815f7028e41a3062e902095e5a7dc84617c87267ebd5"},
+    {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:c3452ea726c76e92f3b9fae4b34a151981a9ec0a4847a627c43d71a15ac32aa6"},
+    {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff30218887e62209942f91ac1be902cc80cddb86bf00fbc6783b7a43b2bea26f"},
+    {file = "aiohttp-3.9.3-cp312-cp312-win32.whl", hash = "sha256:38f307b41e0bea3294a9a2a87833191e4bcf89bb0365e83a8be3a58b31fb7f38"},
+    {file = "aiohttp-3.9.3-cp312-cp312-win_amd64.whl", hash = "sha256:b791a3143681a520c0a17e26ae7465f1b6f99461a28019d1a2f425236e6eedb5"},
+    {file = "aiohttp-3.9.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0ed621426d961df79aa3b963ac7af0d40392956ffa9be022024cd16297b30c8c"},
+    {file = "aiohttp-3.9.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7f46acd6a194287b7e41e87957bfe2ad1ad88318d447caf5b090012f2c5bb528"},
+    {file = "aiohttp-3.9.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:feeb18a801aacb098220e2c3eea59a512362eb408d4afd0c242044c33ad6d542"},
+    {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f734e38fd8666f53da904c52a23ce517f1b07722118d750405af7e4123933511"},
+    {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b40670ec7e2156d8e57f70aec34a7216407848dfe6c693ef131ddf6e76feb672"},
+    {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fdd215b7b7fd4a53994f238d0f46b7ba4ac4c0adb12452beee724ddd0743ae5d"},
+    {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:017a21b0df49039c8f46ca0971b3a7fdc1f56741ab1240cb90ca408049766168"},
+    {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e99abf0bba688259a496f966211c49a514e65afa9b3073a1fcee08856e04425b"},
+    {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:648056db9a9fa565d3fa851880f99f45e3f9a771dd3ff3bb0c048ea83fb28194"},
+    {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8aacb477dc26797ee089721536a292a664846489c49d3ef9725f992449eda5a8"},
+    {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:522a11c934ea660ff8953eda090dcd2154d367dec1ae3c540aff9f8a5c109ab4"},
+    {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:5bce0dc147ca85caa5d33debc4f4d65e8e8b5c97c7f9f660f215fa74fc49a321"},
+    {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4b4af9f25b49a7be47c0972139e59ec0e8285c371049df1a63b6ca81fdd216a2"},
+    {file = "aiohttp-3.9.3-cp38-cp38-win32.whl", hash = "sha256:298abd678033b8571995650ccee753d9458dfa0377be4dba91e4491da3f2be63"},
+    {file = "aiohttp-3.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:69361bfdca5468c0488d7017b9b1e5ce769d40b46a9f4a2eed26b78619e9396c"},
+    {file = "aiohttp-3.9.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0fa43c32d1643f518491d9d3a730f85f5bbaedcbd7fbcae27435bb8b7a061b29"},
+    {file = "aiohttp-3.9.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:835a55b7ca49468aaaac0b217092dfdff370e6c215c9224c52f30daaa735c1c1"},
+    {file = "aiohttp-3.9.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06a9b2c8837d9a94fae16c6223acc14b4dfdff216ab9b7202e07a9a09541168f"},
+    {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abf151955990d23f84205286938796c55ff11bbfb4ccfada8c9c83ae6b3c89a3"},
+    {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59c26c95975f26e662ca78fdf543d4eeaef70e533a672b4113dd888bd2423caa"},
+    {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f95511dd5d0e05fd9728bac4096319f80615aaef4acbecb35a990afebe953b0e"},
+    {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:595f105710293e76b9dc09f52e0dd896bd064a79346234b521f6b968ffdd8e58"},
+    {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7c8b816c2b5af5c8a436df44ca08258fc1a13b449393a91484225fcb7545533"},
+    {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f1088fa100bf46e7b398ffd9904f4808a0612e1d966b4aa43baa535d1b6341eb"},
+    {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f59dfe57bb1ec82ac0698ebfcdb7bcd0e99c255bd637ff613760d5f33e7c81b3"},
+    {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:361a1026c9dd4aba0109e4040e2aecf9884f5cfe1b1b1bd3d09419c205e2e53d"},
+    {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:363afe77cfcbe3a36353d8ea133e904b108feea505aa4792dad6585a8192c55a"},
+    {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e2c45c208c62e955e8256949eb225bd8b66a4c9b6865729a786f2aa79b72e9d"},
+    {file = "aiohttp-3.9.3-cp39-cp39-win32.whl", hash = "sha256:f7217af2e14da0856e082e96ff637f14ae45c10a5714b63c77f26d8884cf1051"},
+    {file = "aiohttp-3.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:27468897f628c627230dba07ec65dc8d0db566923c48f29e084ce382119802bc"},
+    {file = "aiohttp-3.9.3.tar.gz", hash = "sha256:90842933e5d1ff760fae6caca4b2b3edba53ba8f4b71e95dacf2818a2aca06f7"},
+]
 
 [package.dependencies]
 aiosignal = ">=1.1.2"
-async-timeout = ">=4.0.0a3,<5.0"
+async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""}
 attrs = ">=17.3.0"
-charset-normalizer = ">=2.0,<4.0"
 frozenlist = ">=1.1.1"
 multidict = ">=4.5,<7.0"
 yarl = ">=1.0,<2.0"
 
 [package.extras]
-speedups = ["aiodns", "brotli", "cchardet"]
-
-[[package]]
-name = "aiohttp-swagger"
-version = "1.0.16"
-description = "Swagger API Documentation builder for aiohttp server"
-category = "main"
-optional = true
-python-versions = "*"
-
-[package.dependencies]
-aiohttp = ">=2.3.10"
-jinja2 = ">=2.11.2"
-pyYAML = ">=5.1"
-
-[package.extras]
-performance = ["ujson"]
+speedups = ["Brotli", "aiodns", "brotlicffi"]
 
 [[package]]
 name = "aiosignal"
 version = "1.3.1"
 description = "aiosignal: a list of registered asynchronous callbacks"
-category = "main"
 optional = true
 python-versions = ">=3.7"
+files = [
+    {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"},
+    {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"},
+]
 
 [package.dependencies]
 frozenlist = ">=1.1.0"
@@ -57,352 +125,700 @@ frozenlist = ">=1.1.0"
 name = "alabaster"
 version = "0.7.13"
 description = "A configurable sidebar-enabled Sphinx theme"
-category = "main"
 optional = true
 python-versions = ">=3.6"
+files = [
+    {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"},
+    {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"},
+]
 
 [[package]]
 name = "anyio"
-version = "3.6.2"
+version = "4.3.0"
 description = "High level compatibility layer for multiple asynchronous event loop implementations"
-category = "main"
 optional = false
-python-versions = ">=3.6.2"
+python-versions = ">=3.8"
+files = [
+    {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"},
+    {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"},
+]
 
 [package.dependencies]
+exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""}
 idna = ">=2.8"
 sniffio = ">=1.1"
+typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""}
 
 [package.extras]
-doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"]
-test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"]
-trio = ["trio (>=0.16,<0.22)"]
+doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
+test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"]
+trio = ["trio (>=0.23)"]
 
 [[package]]
 name = "async-lru"
-version = "2.0.2"
+version = "2.0.4"
 description = "Simple LRU cache for asyncio"
-category = "main"
 optional = true
 python-versions = ">=3.8"
+files = [
+    {file = "async-lru-2.0.4.tar.gz", hash = "sha256:b8a59a5df60805ff63220b2a0c5b5393da5521b113cd5465a44eb037d81a5627"},
+    {file = "async_lru-2.0.4-py3-none-any.whl", hash = "sha256:ff02944ce3c288c5be660c42dbcca0742b32c3b279d6dceda655190240b99224"},
+]
 
 [package.dependencies]
-typing-extensions = ">=4.0.0"
+typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""}
 
 [[package]]
 name = "async-timeout"
-version = "4.0.2"
+version = "4.0.3"
 description = "Timeout context manager for asyncio programs"
-category = "main"
 optional = true
-python-versions = ">=3.6"
+python-versions = ">=3.7"
+files = [
+    {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"},
+    {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"},
+]
 
 [[package]]
 name = "attrs"
-version = "23.1.0"
+version = "23.2.0"
 description = "Classes Without Boilerplate"
-category = "main"
 optional = true
 python-versions = ">=3.7"
+files = [
+    {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"},
+    {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"},
+]
 
 [package.extras]
-cov = ["attrs", "coverage[toml] (>=5.3)"]
-dev = ["attrs", "pre-commit"]
+cov = ["attrs[tests]", "coverage[toml] (>=5.3)"]
+dev = ["attrs[tests]", "pre-commit"]
 docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"]
-tests = ["attrs", "zope-interface"]
-tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest-mypy-plugins", "pytest-xdist", "pytest (>=4.3.0)"]
+tests = ["attrs[tests-no-zope]", "zope-interface"]
+tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"]
+tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"]
 
 [[package]]
 name = "babel"
-version = "2.12.1"
+version = "2.14.0"
 description = "Internationalization utilities"
-category = "main"
 optional = true
 python-versions = ">=3.7"
+files = [
+    {file = "Babel-2.14.0-py3-none-any.whl", hash = "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287"},
+    {file = "Babel-2.14.0.tar.gz", hash = "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363"},
+]
 
 [package.dependencies]
 pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""}
 
+[package.extras]
+dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"]
+
 [[package]]
 name = "certifi"
-version = "2023.7.22"
+version = "2024.2.2"
 description = "Python package for providing Mozilla's CA Bundle."
-category = "main"
 optional = false
 python-versions = ">=3.6"
+files = [
+    {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"},
+    {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"},
+]
 
 [[package]]
 name = "cffi"
-version = "1.15.1"
+version = "1.16.0"
 description = "Foreign Function Interface for Python calling C code."
-category = "main"
 optional = true
-python-versions = "*"
+python-versions = ">=3.8"
+files = [
+    {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"},
+    {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"},
+    {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"},
+    {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"},
+    {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"},
+    {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"},
+    {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"},
+    {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"},
+    {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"},
+    {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"},
+    {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"},
+    {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"},
+    {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"},
+    {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"},
+    {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"},
+    {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"},
+    {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"},
+    {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"},
+    {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"},
+    {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"},
+    {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"},
+    {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"},
+    {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"},
+    {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"},
+    {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"},
+    {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"},
+    {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"},
+    {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"},
+    {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"},
+    {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"},
+    {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"},
+    {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"},
+    {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"},
+    {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"},
+    {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"},
+    {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"},
+    {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"},
+    {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"},
+    {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"},
+    {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"},
+    {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"},
+    {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"},
+    {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"},
+    {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"},
+    {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"},
+    {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"},
+    {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"},
+    {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"},
+    {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"},
+    {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"},
+    {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"},
+    {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"},
+]
 
 [package.dependencies]
 pycparser = "*"
 
 [[package]]
 name = "cfgv"
-version = "3.3.1"
+version = "3.4.0"
 description = "Validate configuration and produce human readable error messages."
-category = "dev"
 optional = false
-python-versions = ">=3.6.1"
+python-versions = ">=3.8"
+files = [
+    {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
+    {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
+]
 
 [[package]]
 name = "chardet"
 version = "4.0.0"
 description = "Universal encoding detector for Python 2 and 3"
-category = "main"
 optional = true
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+files = [
+    {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"},
+    {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"},
+]
 
 [[package]]
 name = "charset-normalizer"
-version = "3.1.0"
+version = "3.3.2"
 description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
-category = "main"
 optional = true
 python-versions = ">=3.7.0"
+files = [
+    {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"},
+    {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"},
+    {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"},
+    {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"},
+    {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"},
+    {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"},
+    {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"},
+    {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"},
+    {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"},
+    {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"},
+    {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"},
+    {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"},
+    {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"},
+    {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"},
+    {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"},
+    {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"},
+    {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"},
+    {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"},
+    {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"},
+    {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"},
+    {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"},
+    {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"},
+    {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"},
+    {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"},
+    {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"},
+    {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"},
+    {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"},
+    {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"},
+    {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"},
+    {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"},
+    {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"},
+    {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"},
+    {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"},
+    {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"},
+    {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"},
+    {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"},
+    {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"},
+    {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"},
+    {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"},
+    {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"},
+    {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"},
+    {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"},
+    {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"},
+    {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"},
+    {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"},
+    {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"},
+    {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"},
+    {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"},
+]
 
 [[package]]
 name = "colorama"
 version = "0.4.6"
 description = "Cross-platform colored terminal text."
-category = "main"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+    {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+    {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
 
 [[package]]
 name = "cryptography"
-version = "41.0.4"
+version = "42.0.4"
 description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
-category = "main"
 optional = true
 python-versions = ">=3.7"
+files = [
+    {file = "cryptography-42.0.4-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:ffc73996c4fca3d2b6c1c8c12bfd3ad00def8621da24f547626bf06441400449"},
+    {file = "cryptography-42.0.4-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:db4b65b02f59035037fde0998974d84244a64c3265bdef32a827ab9b63d61b18"},
+    {file = "cryptography-42.0.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad9c385ba8ee025bb0d856714f71d7840020fe176ae0229de618f14dae7a6e2"},
+    {file = "cryptography-42.0.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69b22ab6506a3fe483d67d1ed878e1602bdd5912a134e6202c1ec672233241c1"},
+    {file = "cryptography-42.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e09469a2cec88fb7b078e16d4adec594414397e8879a4341c6ace96013463d5b"},
+    {file = "cryptography-42.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3e970a2119507d0b104f0a8e281521ad28fc26f2820687b3436b8c9a5fcf20d1"},
+    {file = "cryptography-42.0.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e53dc41cda40b248ebc40b83b31516487f7db95ab8ceac1f042626bc43a2f992"},
+    {file = "cryptography-42.0.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:c3a5cbc620e1e17009f30dd34cb0d85c987afd21c41a74352d1719be33380885"},
+    {file = "cryptography-42.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bfadd884e7280df24d26f2186e4e07556a05d37393b0f220a840b083dc6a824"},
+    {file = "cryptography-42.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:01911714117642a3f1792c7f376db572aadadbafcd8d75bb527166009c9f1d1b"},
+    {file = "cryptography-42.0.4-cp37-abi3-win32.whl", hash = "sha256:fb0cef872d8193e487fc6bdb08559c3aa41b659a7d9be48b2e10747f47863925"},
+    {file = "cryptography-42.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:c1f25b252d2c87088abc8bbc4f1ecbf7c919e05508a7e8628e6875c40bc70923"},
+    {file = "cryptography-42.0.4-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:15a1fb843c48b4a604663fa30af60818cd28f895572386e5f9b8a665874c26e7"},
+    {file = "cryptography-42.0.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1327f280c824ff7885bdeef8578f74690e9079267c1c8bd7dc5cc5aa065ae52"},
+    {file = "cryptography-42.0.4-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ffb03d419edcab93b4b19c22ee80c007fb2d708429cecebf1dd3258956a563a"},
+    {file = "cryptography-42.0.4-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1df6fcbf60560d2113b5ed90f072dc0b108d64750d4cbd46a21ec882c7aefce9"},
+    {file = "cryptography-42.0.4-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:44a64043f743485925d3bcac548d05df0f9bb445c5fcca6681889c7c3ab12764"},
+    {file = "cryptography-42.0.4-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:3c6048f217533d89f2f8f4f0fe3044bf0b2090453b7b73d0b77db47b80af8dff"},
+    {file = "cryptography-42.0.4-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6d0fbe73728c44ca3a241eff9aefe6496ab2656d6e7a4ea2459865f2e8613257"},
+    {file = "cryptography-42.0.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:887623fe0d70f48ab3f5e4dbf234986b1329a64c066d719432d0698522749929"},
+    {file = "cryptography-42.0.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce8613beaffc7c14f091497346ef117c1798c202b01153a8cc7b8e2ebaaf41c0"},
+    {file = "cryptography-42.0.4-cp39-abi3-win32.whl", hash = "sha256:810bcf151caefc03e51a3d61e53335cd5c7316c0a105cc695f0959f2c638b129"},
+    {file = "cryptography-42.0.4-cp39-abi3-win_amd64.whl", hash = "sha256:a0298bdc6e98ca21382afe914c642620370ce0470a01e1bef6dd9b5354c36854"},
+    {file = "cryptography-42.0.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5f8907fcf57392cd917892ae83708761c6ff3c37a8e835d7246ff0ad251d9298"},
+    {file = "cryptography-42.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:12d341bd42cdb7d4937b0cabbdf2a94f949413ac4504904d0cdbdce4a22cbf88"},
+    {file = "cryptography-42.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1cdcdbd117681c88d717437ada72bdd5be9de117f96e3f4d50dab3f59fd9ab20"},
+    {file = "cryptography-42.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0e89f7b84f421c56e7ff69f11c441ebda73b8a8e6488d322ef71746224c20fce"},
+    {file = "cryptography-42.0.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f1e85a178384bf19e36779d91ff35c7617c885da487d689b05c1366f9933ad74"},
+    {file = "cryptography-42.0.4-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d2a27aca5597c8a71abbe10209184e1a8e91c1fd470b5070a2ea60cafec35bcd"},
+    {file = "cryptography-42.0.4-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4e36685cb634af55e0677d435d425043967ac2f3790ec652b2b88ad03b85c27b"},
+    {file = "cryptography-42.0.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:f47be41843200f7faec0683ad751e5ef11b9a56a220d57f300376cd8aba81660"},
+    {file = "cryptography-42.0.4.tar.gz", hash = "sha256:831a4b37accef30cccd34fcb916a5d7b5be3cbbe27268a02832c3e450aea39cb"},
+]
 
 [package.dependencies]
-cffi = ">=1.12"
+cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""}
 
 [package.extras]
 docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"]
-docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"]
+docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"]
 nox = ["nox"]
-pep8test = ["black", "ruff", "mypy", "check-sdist"]
+pep8test = ["check-sdist", "click", "mypy", "ruff"]
 sdist = ["build"]
 ssh = ["bcrypt (>=3.1.5)"]
-test = ["pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist", "pretend"]
+test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
 test-randomorder = ["pytest-randomly"]
 
 [[package]]
 name = "dill"
-version = "0.3.6"
-description = "serialize all of python"
-category = "main"
+version = "0.3.8"
+description = "serialize all of Python"
 optional = true
-python-versions = ">=3.7"
+python-versions = ">=3.8"
+files = [
+    {file = "dill-0.3.8-py3-none-any.whl", hash = "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7"},
+    {file = "dill-0.3.8.tar.gz", hash = "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca"},
+]
 
 [package.extras]
 graph = ["objgraph (>=1.7.2)"]
+profile = ["gprof2dot (>=2022.7.29)"]
 
 [[package]]
 name = "distlib"
-version = "0.3.6"
+version = "0.3.8"
 description = "Distribution utilities"
-category = "dev"
 optional = false
 python-versions = "*"
+files = [
+    {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"},
+    {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"},
+]
 
 [[package]]
 name = "distro"
-version = "1.8.0"
+version = "1.9.0"
 description = "Distro - an OS platform information API"
-category = "main"
 optional = true
 python-versions = ">=3.6"
+files = [
+    {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"},
+    {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"},
+]
 
 [[package]]
 name = "docutils"
 version = "0.18.1"
 description = "Docutils -- Python Documentation Utilities"
-category = "main"
 optional = true
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+files = [
+    {file = "docutils-0.18.1-py2.py3-none-any.whl", hash = "sha256:23010f129180089fbcd3bc08cfefccb3b890b0050e1ca00c867036e9d161b98c"},
+    {file = "docutils-0.18.1.tar.gz", hash = "sha256:679987caf361a7539d76e584cbeddc311e3aee937877c87346f31debc63e9d06"},
+]
 
 [[package]]
 name = "exceptiongroup"
-version = "1.1.1"
+version = "1.2.0"
 description = "Backport of PEP 654 (exception groups)"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
+files = [
+    {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"},
+    {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"},
+]
 
 [package.extras]
 test = ["pytest (>=6)"]
 
 [[package]]
 name = "filelock"
-version = "3.11.0"
+version = "3.13.1"
 description = "A platform independent file lock."
-category = "dev"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
+files = [
+    {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"},
+    {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"},
+]
 
 [package.extras]
-docs = ["furo (>=2023.3.27)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)", "sphinx (>=6.1.3)"]
-testing = ["covdefaults (>=2.3)", "coverage (>=7.2.2)", "diff-cover (>=7.5)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)", "pytest (>=7.2.2)"]
+docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"]
+testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"]
+typing = ["typing-extensions (>=4.8)"]
 
 [[package]]
 name = "flake8"
-version = "6.0.0"
+version = "7.0.0"
 description = "the modular source code checker: pep8 pyflakes and co"
-category = "dev"
 optional = false
 python-versions = ">=3.8.1"
+files = [
+    {file = "flake8-7.0.0-py2.py3-none-any.whl", hash = "sha256:a6dfbb75e03252917f2473ea9653f7cd799c3064e54d4c8140044c5c065f53c3"},
+    {file = "flake8-7.0.0.tar.gz", hash = "sha256:33f96621059e65eec474169085dc92bf26e7b2d47366b70be2f67ab80dc25132"},
+]
 
 [package.dependencies]
 mccabe = ">=0.7.0,<0.8.0"
-pycodestyle = ">=2.10.0,<2.11.0"
-pyflakes = ">=3.0.0,<3.1.0"
+pycodestyle = ">=2.11.0,<2.12.0"
+pyflakes = ">=3.2.0,<3.3.0"
 
 [[package]]
 name = "frozenlist"
-version = "1.3.3"
+version = "1.4.1"
 description = "A list-like structure which implements collections.abc.MutableSequence"
-category = "main"
 optional = true
-python-versions = ">=3.7"
+python-versions = ">=3.8"
+files = [
+    {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"},
+    {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"},
+    {file = "frozenlist-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776"},
+    {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a"},
+    {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad"},
+    {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c"},
+    {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe"},
+    {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a"},
+    {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98"},
+    {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75"},
+    {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5"},
+    {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950"},
+    {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc"},
+    {file = "frozenlist-1.4.1-cp310-cp310-win32.whl", hash = "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1"},
+    {file = "frozenlist-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439"},
+    {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0"},
+    {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49"},
+    {file = "frozenlist-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced"},
+    {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0"},
+    {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106"},
+    {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068"},
+    {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2"},
+    {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19"},
+    {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82"},
+    {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec"},
+    {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a"},
+    {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74"},
+    {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2"},
+    {file = "frozenlist-1.4.1-cp311-cp311-win32.whl", hash = "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17"},
+    {file = "frozenlist-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825"},
+    {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae"},
+    {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb"},
+    {file = "frozenlist-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b"},
+    {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86"},
+    {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480"},
+    {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09"},
+    {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a"},
+    {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd"},
+    {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6"},
+    {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1"},
+    {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b"},
+    {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e"},
+    {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8"},
+    {file = "frozenlist-1.4.1-cp312-cp312-win32.whl", hash = "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89"},
+    {file = "frozenlist-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5"},
+    {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d"},
+    {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826"},
+    {file = "frozenlist-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb"},
+    {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6"},
+    {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d"},
+    {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887"},
+    {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a"},
+    {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b"},
+    {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701"},
+    {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0"},
+    {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11"},
+    {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09"},
+    {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7"},
+    {file = "frozenlist-1.4.1-cp38-cp38-win32.whl", hash = "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497"},
+    {file = "frozenlist-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09"},
+    {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e"},
+    {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d"},
+    {file = "frozenlist-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8"},
+    {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0"},
+    {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b"},
+    {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0"},
+    {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897"},
+    {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7"},
+    {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742"},
+    {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea"},
+    {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5"},
+    {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9"},
+    {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6"},
+    {file = "frozenlist-1.4.1-cp39-cp39-win32.whl", hash = "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932"},
+    {file = "frozenlist-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0"},
+    {file = "frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7"},
+    {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"},
+]
 
 [[package]]
 name = "gitdb"
-version = "4.0.10"
+version = "4.0.11"
 description = "Git Object Database"
-category = "main"
 optional = true
 python-versions = ">=3.7"
+files = [
+    {file = "gitdb-4.0.11-py3-none-any.whl", hash = "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4"},
+    {file = "gitdb-4.0.11.tar.gz", hash = "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b"},
+]
 
 [package.dependencies]
 smmap = ">=3.0.1,<6"
 
 [[package]]
 name = "gitpython"
-version = "3.1.37"
+version = "3.1.42"
 description = "GitPython is a Python library used to interact with Git repositories"
-category = "main"
 optional = true
 python-versions = ">=3.7"
+files = [
+    {file = "GitPython-3.1.42-py3-none-any.whl", hash = "sha256:1bf9cd7c9e7255f77778ea54359e54ac22a72a5b51288c457c881057b7bb9ecd"},
+    {file = "GitPython-3.1.42.tar.gz", hash = "sha256:2d99869e0fef71a73cbd242528105af1d6c1b108c60dfabd994bf292f76c3ceb"},
+]
 
 [package.dependencies]
 gitdb = ">=4.0.1,<5"
 
 [package.extras]
-test = ["black", "coverage", "ddt (>=1.1.1,!=1.4.3)", "mypy", "pre-commit", "pytest", "pytest-cov", "pytest-sugar"]
+test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar"]
 
 [[package]]
 name = "h11"
 version = "0.14.0"
 description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
-category = "main"
 optional = false
 python-versions = ">=3.7"
+files = [
+    {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
+    {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
+]
 
 [[package]]
 name = "httpcore"
-version = "0.17.0"
+version = "1.0.4"
 description = "A minimal low-level HTTP client."
-category = "main"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
+files = [
+    {file = "httpcore-1.0.4-py3-none-any.whl", hash = "sha256:ac418c1db41bade2ad53ae2f3834a3a0f5ae76b56cf5aa497d2d033384fc7d73"},
+    {file = "httpcore-1.0.4.tar.gz", hash = "sha256:cb2839ccfcba0d2d3c1131d3c3e26dfc327326fbe7a5dc0dbfe9f6c9151bb022"},
+]
 
 [package.dependencies]
-anyio = ">=3.0,<5.0"
 certifi = "*"
 h11 = ">=0.13,<0.15"
-sniffio = ">=1.0.0,<2.0.0"
 
 [package.extras]
+asyncio = ["anyio (>=4.0,<5.0)"]
 http2 = ["h2 (>=3,<5)"]
-socks = ["socksio (>=1.0.0,<2.0.0)"]
+socks = ["socksio (==1.*)"]
+trio = ["trio (>=0.22.0,<0.25.0)"]
 
 [[package]]
 name = "httpx"
-version = "0.24.0"
+version = "0.26.0"
 description = "The next generation HTTP client."
-category = "main"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
+files = [
+    {file = "httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd"},
+    {file = "httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf"},
+]
 
 [package.dependencies]
+anyio = "*"
 certifi = "*"
-httpcore = ">=0.15.0,<0.18.0"
+httpcore = "==1.*"
 idna = "*"
 sniffio = "*"
 
 [package.extras]
 brotli = ["brotli", "brotlicffi"]
-cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<14)"]
+cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
 http2 = ["h2 (>=3,<5)"]
-socks = ["socksio (>=1.0.0,<2.0.0)"]
+socks = ["socksio (==1.*)"]
 
 [[package]]
 name = "identify"
-version = "2.5.22"
+version = "2.5.35"
 description = "File identification library for Python"
-category = "dev"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
+files = [
+    {file = "identify-2.5.35-py2.py3-none-any.whl", hash = "sha256:c4de0081837b211594f8e877a6b4fad7ca32bbfc1a9307fdd61c28bfe923f13e"},
+    {file = "identify-2.5.35.tar.gz", hash = "sha256:10a7ca245cfcd756a554a7288159f72ff105ad233c7c4b9c6f0f4d108f5f6791"},
+]
 
 [package.extras]
 license = ["ukkonen"]
 
 [[package]]
 name = "idna"
-version = "3.4"
+version = "3.6"
 description = "Internationalized Domain Names in Applications (IDNA)"
-category = "main"
 optional = false
 python-versions = ">=3.5"
+files = [
+    {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"},
+    {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"},
+]
 
 [[package]]
 name = "imagesize"
 version = "1.4.1"
 description = "Getting image size from png/jpeg/jpeg2000/gif file"
-category = "main"
 optional = true
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+files = [
+    {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"},
+    {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"},
+]
 
 [[package]]
 name = "importlib-metadata"
-version = "6.4.1"
+version = "7.0.1"
 description = "Read metadata from Python packages"
-category = "main"
 optional = true
-python-versions = ">=3.7"
+python-versions = ">=3.8"
+files = [
+    {file = "importlib_metadata-7.0.1-py3-none-any.whl", hash = "sha256:4805911c3a4ec7c3966410053e9ec6a1fecd629117df5adee56dfc9432a1081e"},
+    {file = "importlib_metadata-7.0.1.tar.gz", hash = "sha256:f238736bb06590ae52ac1fab06a3a9ef1d8dce2b7a35b5ab329371d6c8f5d2cc"},
+]
 
 [package.dependencies]
 zipp = ">=0.5"
 
 [package.extras]
-testing = ["packaging", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "pytest-flake8", "importlib-resources (>=1.3)"]
-docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "sphinx-lint", "jaraco.tidelift (>=1.4)"]
+docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"]
 perf = ["ipython"]
+testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"]
 
 [[package]]
 name = "iniconfig"
 version = "2.0.0"
 description = "brain-dead simple config-ini parsing"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
+files = [
+    {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
+    {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
+]
 
 [[package]]
 name = "jinja2"
-version = "3.1.2"
+version = "3.1.3"
 description = "A very fast and expressive template engine."
-category = "main"
 optional = true
 python-versions = ">=3.7"
+files = [
+    {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"},
+    {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"},
+]
 
 [package.dependencies]
 MarkupSafe = ">=2.0"
@@ -414,80 +830,256 @@ i18n = ["Babel (>=2.7)"]
 name = "junit-xml"
 version = "1.9"
 description = "Creates JUnit XML test result documents that can be read by tools such as Jenkins"
-category = "main"
 optional = true
 python-versions = "*"
+files = [
+    {file = "junit-xml-1.9.tar.gz", hash = "sha256:de16a051990d4e25a3982b2dd9e89d671067548718866416faec14d9de56db9f"},
+    {file = "junit_xml-1.9-py2.py3-none-any.whl", hash = "sha256:ec5ca1a55aefdd76d28fcc0b135251d156c7106fa979686a4b48d62b761b4732"},
+]
 
 [package.dependencies]
 six = "*"
 
 [[package]]
 name = "markupsafe"
-version = "2.1.2"
+version = "2.1.5"
 description = "Safely add untrusted strings to HTML/XML markup."
-category = "main"
 optional = true
 python-versions = ">=3.7"
+files = [
+    {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"},
+    {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"},
+    {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"},
+    {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"},
+    {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"},
+    {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"},
+    {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"},
+    {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"},
+]
 
 [[package]]
 name = "mccabe"
 version = "0.7.0"
 description = "McCabe checker, plugin for flake8"
-category = "dev"
 optional = false
 python-versions = ">=3.6"
+files = [
+    {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
+    {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
+]
 
 [[package]]
 name = "multidict"
-version = "6.0.4"
+version = "6.0.5"
 description = "multidict implementation"
-category = "main"
 optional = true
 python-versions = ">=3.7"
+files = [
+    {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9"},
+    {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604"},
+    {file = "multidict-6.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:411bf8515f3be9813d06004cac41ccf7d1cd46dfe233705933dd163b60e37600"},
+    {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d147090048129ce3c453f0292e7697d333db95e52616b3793922945804a433c"},
+    {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:215ed703caf15f578dca76ee6f6b21b7603791ae090fbf1ef9d865571039ade5"},
+    {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c6390cf87ff6234643428991b7359b5f59cc15155695deb4eda5c777d2b880f"},
+    {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fd81c4ebdb4f214161be351eb5bcf385426bf023041da2fd9e60681f3cebae"},
+    {file = "multidict-6.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cc2ad10255f903656017363cd59436f2111443a76f996584d1077e43ee51182"},
+    {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6939c95381e003f54cd4c5516740faba40cf5ad3eeff460c3ad1d3e0ea2549bf"},
+    {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:220dd781e3f7af2c2c1053da9fa96d9cf3072ca58f057f4c5adaaa1cab8fc442"},
+    {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:766c8f7511df26d9f11cd3a8be623e59cca73d44643abab3f8c8c07620524e4a"},
+    {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:fe5d7785250541f7f5019ab9cba2c71169dc7d74d0f45253f8313f436458a4ef"},
+    {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1c1496e73051918fcd4f58ff2e0f2f3066d1c76a0c6aeffd9b45d53243702cc"},
+    {file = "multidict-6.0.5-cp310-cp310-win32.whl", hash = "sha256:7afcdd1fc07befad18ec4523a782cde4e93e0a2bf71239894b8d61ee578c1319"},
+    {file = "multidict-6.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:99f60d34c048c5c2fabc766108c103612344c46e35d4ed9ae0673d33c8fb26e8"},
+    {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f285e862d2f153a70586579c15c44656f888806ed0e5b56b64489afe4a2dbfba"},
+    {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:53689bb4e102200a4fafa9de9c7c3c212ab40a7ab2c8e474491914d2305f187e"},
+    {file = "multidict-6.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:612d1156111ae11d14afaf3a0669ebf6c170dbb735e510a7438ffe2369a847fd"},
+    {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7be7047bd08accdb7487737631d25735c9a04327911de89ff1b26b81745bd4e3"},
+    {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de170c7b4fe6859beb8926e84f7d7d6c693dfe8e27372ce3b76f01c46e489fcf"},
+    {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04bde7a7b3de05732a4eb39c94574db1ec99abb56162d6c520ad26f83267de29"},
+    {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85f67aed7bb647f93e7520633d8f51d3cbc6ab96957c71272b286b2f30dc70ed"},
+    {file = "multidict-6.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425bf820055005bfc8aa9a0b99ccb52cc2f4070153e34b701acc98d201693733"},
+    {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d3eb1ceec286eba8220c26f3b0096cf189aea7057b6e7b7a2e60ed36b373b77f"},
+    {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7901c05ead4b3fb75113fb1dd33eb1253c6d3ee37ce93305acd9d38e0b5f21a4"},
+    {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e0e79d91e71b9867c73323a3444724d496c037e578a0e1755ae159ba14f4f3d1"},
+    {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:29bfeb0dff5cb5fdab2023a7a9947b3b4af63e9c47cae2a10ad58394b517fddc"},
+    {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e030047e85cbcedbfc073f71836d62dd5dadfbe7531cae27789ff66bc551bd5e"},
+    {file = "multidict-6.0.5-cp311-cp311-win32.whl", hash = "sha256:2f4848aa3baa109e6ab81fe2006c77ed4d3cd1e0ac2c1fbddb7b1277c168788c"},
+    {file = "multidict-6.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:2faa5ae9376faba05f630d7e5e6be05be22913782b927b19d12b8145968a85ea"},
+    {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e"},
+    {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b"},
+    {file = "multidict-6.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5"},
+    {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450"},
+    {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496"},
+    {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a"},
+    {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226"},
+    {file = "multidict-6.0.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271"},
+    {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb"},
+    {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef"},
+    {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24"},
+    {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6"},
+    {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda"},
+    {file = "multidict-6.0.5-cp312-cp312-win32.whl", hash = "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5"},
+    {file = "multidict-6.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556"},
+    {file = "multidict-6.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:19fe01cea168585ba0f678cad6f58133db2aa14eccaf22f88e4a6dccadfad8b3"},
+    {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bf7a982604375a8d49b6cc1b781c1747f243d91b81035a9b43a2126c04766f5"},
+    {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:107c0cdefe028703fb5dafe640a409cb146d44a6ae201e55b35a4af8e95457dd"},
+    {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:403c0911cd5d5791605808b942c88a8155c2592e05332d2bf78f18697a5fa15e"},
+    {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aeaf541ddbad8311a87dd695ed9642401131ea39ad7bc8cf3ef3967fd093b626"},
+    {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4972624066095e52b569e02b5ca97dbd7a7ddd4294bf4e7247d52635630dd83"},
+    {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d946b0a9eb8aaa590df1fe082cee553ceab173e6cb5b03239716338629c50c7a"},
+    {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b55358304d7a73d7bdf5de62494aaf70bd33015831ffd98bc498b433dfe5b10c"},
+    {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a3145cb08d8625b2d3fee1b2d596a8766352979c9bffe5d7833e0503d0f0b5e5"},
+    {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d65f25da8e248202bd47445cec78e0025c0fe7582b23ec69c3b27a640dd7a8e3"},
+    {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c9bf56195c6bbd293340ea82eafd0071cb3d450c703d2c93afb89f93b8386ccc"},
+    {file = "multidict-6.0.5-cp37-cp37m-win32.whl", hash = "sha256:69db76c09796b313331bb7048229e3bee7928eb62bab5e071e9f7fcc4879caee"},
+    {file = "multidict-6.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:fce28b3c8a81b6b36dfac9feb1de115bab619b3c13905b419ec71d03a3fc1423"},
+    {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76f067f5121dcecf0d63a67f29080b26c43c71a98b10c701b0677e4a065fbd54"},
+    {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b82cc8ace10ab5bd93235dfaab2021c70637005e1ac787031f4d1da63d493c1d"},
+    {file = "multidict-6.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5cb241881eefd96b46f89b1a056187ea8e9ba14ab88ba632e68d7a2ecb7aadf7"},
+    {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e94e6912639a02ce173341ff62cc1201232ab86b8a8fcc05572741a5dc7d93"},
+    {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09a892e4a9fb47331da06948690ae38eaa2426de97b4ccbfafbdcbe5c8f37ff8"},
+    {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55205d03e8a598cfc688c71ca8ea5f66447164efff8869517f175ea632c7cb7b"},
+    {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37b15024f864916b4951adb95d3a80c9431299080341ab9544ed148091b53f50"},
+    {file = "multidict-6.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2a1dee728b52b33eebff5072817176c172050d44d67befd681609b4746e1c2e"},
+    {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:edd08e6f2f1a390bf137080507e44ccc086353c8e98c657e666c017718561b89"},
+    {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:60d698e8179a42ec85172d12f50b1668254628425a6bd611aba022257cac1386"},
+    {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:3d25f19500588cbc47dc19081d78131c32637c25804df8414463ec908631e453"},
+    {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4cc0ef8b962ac7a5e62b9e826bd0cd5040e7d401bc45a6835910ed699037a461"},
+    {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:eca2e9d0cc5a889850e9bbd68e98314ada174ff6ccd1129500103df7a94a7a44"},
+    {file = "multidict-6.0.5-cp38-cp38-win32.whl", hash = "sha256:4a6a4f196f08c58c59e0b8ef8ec441d12aee4125a7d4f4fef000ccb22f8d7241"},
+    {file = "multidict-6.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:0275e35209c27a3f7951e1ce7aaf93ce0d163b28948444bec61dd7badc6d3f8c"},
+    {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e7be68734bd8c9a513f2b0cfd508802d6609da068f40dc57d4e3494cefc92929"},
+    {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1d9ea7a7e779d7a3561aade7d596649fbecfa5c08a7674b11b423783217933f9"},
+    {file = "multidict-6.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ea1456df2a27c73ce51120fa2f519f1bea2f4a03a917f4a43c8707cf4cbbae1a"},
+    {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf590b134eb70629e350691ecca88eac3e3b8b3c86992042fb82e3cb1830d5e1"},
+    {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5c0631926c4f58e9a5ccce555ad7747d9a9f8b10619621f22f9635f069f6233e"},
+    {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dce1c6912ab9ff5f179eaf6efe7365c1f425ed690b03341911bf4939ef2f3046"},
+    {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0868d64af83169e4d4152ec612637a543f7a336e4a307b119e98042e852ad9c"},
+    {file = "multidict-6.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:141b43360bfd3bdd75f15ed811850763555a251e38b2405967f8e25fb43f7d40"},
+    {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7df704ca8cf4a073334e0427ae2345323613e4df18cc224f647f251e5e75a527"},
+    {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6214c5a5571802c33f80e6c84713b2c79e024995b9c5897f794b43e714daeec9"},
+    {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd6c8fca38178e12c00418de737aef1261576bd1b6e8c6134d3e729a4e858b38"},
+    {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:e02021f87a5b6932fa6ce916ca004c4d441509d33bbdbeca70d05dff5e9d2479"},
+    {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ebd8d160f91a764652d3e51ce0d2956b38efe37c9231cd82cfc0bed2e40b581c"},
+    {file = "multidict-6.0.5-cp39-cp39-win32.whl", hash = "sha256:04da1bb8c8dbadf2a18a452639771951c662c5ad03aefe4884775454be322c9b"},
+    {file = "multidict-6.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:d6f6d4f185481c9669b9447bf9d9cf3b95a0e9df9d169bbc17e363b7d5487755"},
+    {file = "multidict-6.0.5-py3-none-any.whl", hash = "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7"},
+    {file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"},
+]
 
 [[package]]
 name = "nodeenv"
-version = "1.7.0"
+version = "1.8.0"
 description = "Node.js virtual environment builder"
-category = "dev"
 optional = false
 python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*"
+files = [
+    {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"},
+    {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"},
+]
+
+[package.dependencies]
+setuptools = "*"
 
 [[package]]
 name = "packaging"
-version = "23.1"
+version = "23.2"
 description = "Core utilities for Python packages"
-category = "main"
 optional = false
 python-versions = ">=3.7"
+files = [
+    {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"},
+    {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"},
+]
 
 [[package]]
 name = "pathspec"
-version = "0.11.1"
+version = "0.12.1"
 description = "Utility library for gitignore style pattern matching of file paths."
-category = "main"
 optional = true
-python-versions = ">=3.7"
+python-versions = ">=3.8"
+files = [
+    {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
+    {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
+]
 
 [[package]]
 name = "platformdirs"
-version = "3.2.0"
+version = "4.2.0"
 description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
-category = "dev"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
+files = [
+    {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"},
+    {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"},
+]
 
 [package.extras]
-docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)", "sphinx (>=6.1.3)"]
-test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest (>=7.2.2)"]
+docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
+test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"]
 
 [[package]]
 name = "pluggy"
-version = "1.0.0"
+version = "1.4.0"
 description = "plugin and hook calling mechanisms for python"
-category = "dev"
 optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.8"
+files = [
+    {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"},
+    {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"},
+]
 
 [package.extras]
 dev = ["pre-commit", "tox"]
@@ -495,11 +1087,14 @@ testing = ["pytest", "pytest-benchmark"]
 
 [[package]]
 name = "pre-commit"
-version = "3.2.2"
+version = "3.5.0"
 description = "A framework for managing and maintaining multi-language pre-commit hooks."
-category = "dev"
 optional = false
 python-versions = ">=3.8"
+files = [
+    {file = "pre_commit-3.5.0-py2.py3-none-any.whl", hash = "sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660"},
+    {file = "pre_commit-3.5.0.tar.gz", hash = "sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32"},
+]
 
 [package.dependencies]
 cfgv = ">=2.0.0"
@@ -510,327 +1105,560 @@ virtualenv = ">=20.10.0"
 
 [[package]]
 name = "prettytable"
-version = "3.7.0"
+version = "3.10.0"
 description = "A simple Python library for easily displaying tabular data in a visually appealing ASCII table format"
-category = "main"
 optional = true
-python-versions = ">=3.7"
+python-versions = ">=3.8"
+files = [
+    {file = "prettytable-3.10.0-py3-none-any.whl", hash = "sha256:6536efaf0757fdaa7d22e78b3aac3b69ea1b7200538c2c6995d649365bddab92"},
+    {file = "prettytable-3.10.0.tar.gz", hash = "sha256:9665594d137fb08a1117518c25551e0ede1687197cf353a4fdc78d27e1073568"},
+]
 
 [package.dependencies]
 wcwidth = "*"
 
 [package.extras]
-tests = ["pytest", "pytest-cov", "pytest-lazy-fixture"]
+tests = ["pytest", "pytest-cov", "pytest-lazy-fixtures"]
 
 [[package]]
 name = "psutil"
-version = "5.9.5"
+version = "5.9.8"
 description = "Cross-platform lib for process and system monitoring in Python."
-category = "main"
 optional = true
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
+files = [
+    {file = "psutil-5.9.8-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:26bd09967ae00920df88e0352a91cff1a78f8d69b3ecabbfe733610c0af486c8"},
+    {file = "psutil-5.9.8-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:05806de88103b25903dff19bb6692bd2e714ccf9e668d050d144012055cbca73"},
+    {file = "psutil-5.9.8-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:611052c4bc70432ec770d5d54f64206aa7203a101ec273a0cd82418c86503bb7"},
+    {file = "psutil-5.9.8-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:50187900d73c1381ba1454cf40308c2bf6f34268518b3f36a9b663ca87e65e36"},
+    {file = "psutil-5.9.8-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:02615ed8c5ea222323408ceba16c60e99c3f91639b07da6373fb7e6539abc56d"},
+    {file = "psutil-5.9.8-cp27-none-win32.whl", hash = "sha256:36f435891adb138ed3c9e58c6af3e2e6ca9ac2f365efe1f9cfef2794e6c93b4e"},
+    {file = "psutil-5.9.8-cp27-none-win_amd64.whl", hash = "sha256:bd1184ceb3f87651a67b2708d4c3338e9b10c5df903f2e3776b62303b26cb631"},
+    {file = "psutil-5.9.8-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:aee678c8720623dc456fa20659af736241f575d79429a0e5e9cf88ae0605cc81"},
+    {file = "psutil-5.9.8-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cb6403ce6d8e047495a701dc7c5bd788add903f8986d523e3e20b98b733e421"},
+    {file = "psutil-5.9.8-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d06016f7f8625a1825ba3732081d77c94589dca78b7a3fc072194851e88461a4"},
+    {file = "psutil-5.9.8-cp36-cp36m-win32.whl", hash = "sha256:7d79560ad97af658a0f6adfef8b834b53f64746d45b403f225b85c5c2c140eee"},
+    {file = "psutil-5.9.8-cp36-cp36m-win_amd64.whl", hash = "sha256:27cc40c3493bb10de1be4b3f07cae4c010ce715290a5be22b98493509c6299e2"},
+    {file = "psutil-5.9.8-cp37-abi3-win32.whl", hash = "sha256:bc56c2a1b0d15aa3eaa5a60c9f3f8e3e565303b465dbf57a1b730e7a2b9844e0"},
+    {file = "psutil-5.9.8-cp37-abi3-win_amd64.whl", hash = "sha256:8db4c1b57507eef143a15a6884ca10f7c73876cdf5d51e713151c1236a0e68cf"},
+    {file = "psutil-5.9.8-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:d16bbddf0693323b8c6123dd804100241da461e41d6e332fb0ba6058f630f8c8"},
+    {file = "psutil-5.9.8.tar.gz", hash = "sha256:6be126e3225486dff286a8fb9a06246a5253f4c7c53b475ea5f5ac934e64194c"},
+]
 
 [package.extras]
-test = ["ipaddress", "mock", "enum34", "pywin32", "wmi"]
+test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"]
 
 [[package]]
 name = "pyats"
-version = "23.3"
+version = "24.1"
 description = "pyATS - Python Automation Test System"
-category = "main"
 optional = true
 python-versions = ">=3.5"
+files = [
+    {file = "pyats-24.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:5ef4f348f447b5b2ef5dcc02551cbe1e8b781505e2e41985311d72711e2db864"},
+    {file = "pyats-24.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:370d82ef6eb573b8428813889c74fb79e27ecaa69a4401457747ec48b51ecfc2"},
+    {file = "pyats-24.1-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:d4fc89a69261ebf3c05c396ab748f96e5e1f89c5b785022c7cc0a6498ae0ccfd"},
+    {file = "pyats-24.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:d7ec8dae88a481fe7e238befe7daebf299f5e1eefc28ef300613f5ed9ea69d21"},
+    {file = "pyats-24.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:0df176c265a91550b09a526ad3e339ef434314cc033e04963db532ddb2ca68b2"},
+    {file = "pyats-24.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:644a3e29870977a99299c2904bf4de053b0e7f03998e632ec4b16c6a9d358fee"},
+    {file = "pyats-24.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:9f7a1a90cb223fb6e87352cd69aed5fed205b297767202349ee390c4a4258dff"},
+    {file = "pyats-24.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:e645bb5b279d67a68aae822eca6082d4e7484f046280e8c8cdf863c299e03812"},
+    {file = "pyats-24.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:632271cb9b354b37deb97c2736b2035cbc2c2c656f42a0a996e5a6d7d87b804b"},
+    {file = "pyats-24.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:16256ff46cace7c478d3dade105031c7bd1a4322dd1bee7d29f84cafde0e3792"},
+    {file = "pyats-24.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:8cda950c51f3a414c27292235cc3ef30d4b33e0dc78262749aa9f94bec149f88"},
+    {file = "pyats-24.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:33f4600d4ddb76eeaf35b5aa98cd694ea8cd181b12bb21fe7bc12f42195c175b"},
+    {file = "pyats-24.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:48534cf253467a2bac2b847825b26418bc81f2d3d7fc04b96d6bea2edb2babaa"},
+]
 
 [package.dependencies]
 packaging = ">=20.0"
-"pyats.aereport" = ">=23.3.0,<23.4.0"
-"pyats.aetest" = ">=23.3.0,<23.4.0"
-"pyats.async" = ">=23.3.0,<23.4.0"
-"pyats.connections" = ">=23.3.0,<23.4.0"
-"pyats.datastructures" = ">=23.3.0,<23.4.0"
-"pyats.easypy" = ">=23.3.0,<23.4.0"
-"pyats.kleenex" = ">=23.3.0,<23.4.0"
-"pyats.log" = ">=23.3.0,<23.4.0"
-"pyats.reporter" = ">=23.3.0,<23.4.0"
-"pyats.results" = ">=23.3.0,<23.4.0"
-"pyats.tcl" = ">=23.3.0,<23.4.0"
-"pyats.topology" = ">=23.3.0,<23.4.0"
-"pyats.utils" = ">=23.3.0,<23.4.0"
+"pyats.aereport" = ">=24.1.0,<24.2.0"
+"pyats.aetest" = ">=24.1.0,<24.2.0"
+"pyats.async" = ">=24.1.0,<24.2.0"
+"pyats.connections" = ">=24.1.0,<24.2.0"
+"pyats.datastructures" = ">=24.1.0,<24.2.0"
+"pyats.easypy" = ">=24.1.0,<24.2.0"
+"pyats.kleenex" = ">=24.1.0,<24.2.0"
+"pyats.log" = ">=24.1.0,<24.2.0"
+"pyats.reporter" = ">=24.1.0,<24.2.0"
+"pyats.results" = ">=24.1.0,<24.2.0"
+"pyats.tcl" = ">=24.1.0,<24.2.0"
+"pyats.topology" = ">=24.1.0,<24.2.0"
+"pyats.utils" = ">=24.1.0,<24.2.0"
 
 [package.extras]
-full = ["cookiecutter", "genie (>=23.3.0,<23.4.0)", "pyats.robot (>=23.3.0,<23.4.0)", "genie.libs.robot (>=23.3.0,<23.4.0)", "genie.telemetry (>=23.3.0,<23.4.0)", "genie.trafficgen (>=23.3.0,<23.4.0)", "pyats.contrib (>=23.3.0,<23.4.0)"]
-library = ["genie (>=23.3.0,<23.4.0)"]
-robot = ["pyats.robot (>=23.3.0,<23.4.0)", "genie.libs.robot (>=23.3.0,<23.4.0)"]
+full = ["cookiecutter", "genie (>=24.1.0,<24.2.0)", "genie.libs.robot (>=24.1.0,<24.2.0)", "genie.telemetry (>=24.1.0,<24.2.0)", "genie.trafficgen (>=24.1.0,<24.2.0)", "pyats.contrib (>=24.1.0,<24.2.0)", "pyats.robot (>=24.1.0,<24.2.0)"]
+library = ["genie (>=24.1.0,<24.2.0)"]
+robot = ["genie.libs.robot (>=24.1.0,<24.2.0)", "pyats.robot (>=24.1.0,<24.2.0)"]
 template = ["cookiecutter"]
 
 [[package]]
-name = "pyats.aereport"
-version = "23.3"
+name = "pyats-aereport"
+version = "24.1"
 description = "pyATS AEreport: Result Collection and Reporting"
-category = "main"
 optional = true
 python-versions = ">=3.5"
+files = [
+    {file = "pyats.aereport-24.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:94a3dea44f7da59f6af32f332eb6280ac5a5b3e5ddcee39eb0b46c0542dc4ff2"},
+    {file = "pyats.aereport-24.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:7378863431b8a0b9406199b9d8b0716953017045cd88d6a84f4804c9ef868265"},
+    {file = "pyats.aereport-24.1-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:46df30af016765a05e578d60f7fd7f4161ba83f893fc3da7949672cbfcce9bed"},
+    {file = "pyats.aereport-24.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:0fef95a6ae61ae452d40b464d9abd280d2456b5c4561813bbf244dc1c96df2a6"},
+    {file = "pyats.aereport-24.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:fef98cb804883d1e958b5e7d828bf5889206e3a9e2f020e53c8e1eae3da0ee61"},
+    {file = "pyats.aereport-24.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:9bb74a1cd72a8889a612efdf5fbd662c9024c1fb10488c58ccb7e9a432218666"},
+    {file = "pyats.aereport-24.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:2dc2e81f6add495d85c60e36942fd5717a46fad6114ca047ba7f2f0ea426118c"},
+    {file = "pyats.aereport-24.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:34cf4ecffb8d80436fbf133c93f53e5ac315e920c85ee4a4c44fdda3152e7eb5"},
+    {file = "pyats.aereport-24.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:e84ec41b830dde20d3e0a3524a94a257635336143b92013642378b3b5b94fbf7"},
+    {file = "pyats.aereport-24.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:74ac822d17ce47083ba84c3e2fb2afae0676b6f63ebae2713852253875651f0e"},
+    {file = "pyats.aereport-24.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:5561969e29a195cec60f402d683e18874387a64f1f1d98cd88fac7847d3c55be"},
+    {file = "pyats.aereport-24.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:97aec08cef340f59c533f0656d87ee2079005eedd76222a2d91ecb51b2c453cd"},
+    {file = "pyats.aereport-24.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ba7cc827d593c6fd8c973c96afc0d0ac8a943e90e220f192bd18e949af39eafd"},
+]
 
 [package.dependencies]
 jinja2 = "*"
 junit-xml = "*"
 psutil = "*"
-"pyats.log" = ">=23.3.0,<23.4.0"
-"pyats.results" = ">=23.3.0,<23.4.0"
+"pyats.log" = ">=24.1.0,<24.2.0"
+"pyats.results" = ">=24.1.0,<24.2.0"
 
 [package.extras]
-dev = ["sphinx", "sphinx-rtd-theme"]
+dev = ["Sphinx", "sphinx-rtd-theme"]
 
 [[package]]
-name = "pyats.aetest"
-version = "23.3"
+name = "pyats-aetest"
+version = "24.1"
 description = "pyATS AEtest: Testscript Engine"
-category = "main"
 optional = true
 python-versions = ">=3.5"
+files = [
+    {file = "pyats.aetest-24.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:3b05444058807561ea77e2d345a044ed061af5d106f401653d550e89238aa3e2"},
+    {file = "pyats.aetest-24.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:8709b869ce985086d63dfde6ee38c97dd64fc7cd4a50bd7c011015e27198e894"},
+    {file = "pyats.aetest-24.1-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:c15b1264cf42a79eb0a37c2ca2e0a79f9f1f1d2389eebaa22eb40509ec75b6cf"},
+    {file = "pyats.aetest-24.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:4eab64e0ca79b2e41b47d3318016161b9206a50c9a38204093801b13365ec5e0"},
+    {file = "pyats.aetest-24.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:c4f284ea4da812fec7ef2b29a988e504a49a937a2db430bfbbadbbc293d1894e"},
+    {file = "pyats.aetest-24.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:414d9d692b34ca9cf9f362bb3489f8cd1abe5fb34e87005af055f130fc602dc3"},
+    {file = "pyats.aetest-24.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:474e38328450842d950a0dbd9b361498c858135d88212c3f158fbe87607413a5"},
+    {file = "pyats.aetest-24.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:bff851e49bdfbf3e7d706de0e25dc293722842280cd6a5b26cd959f3794a1d91"},
+    {file = "pyats.aetest-24.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:5420296c7627fcfdec97ee1d5480d6c7907d9477875e1aa0d8945bf82ae0df50"},
+    {file = "pyats.aetest-24.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:c797ca21c28315e629598d3a6d7e9e09bd78389da3ad76a73f9ca0cad4dcd88d"},
+    {file = "pyats.aetest-24.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:dc71662b3870d6afa479e68d669651c92e5f036f830e126ea69f3a77b14e9df3"},
+    {file = "pyats.aetest-24.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:f161f5e218d5969dea3af6743785f3331506fcb6ae6f32cada5184c2d88d9330"},
+    {file = "pyats.aetest-24.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1bef2859f2e25a9b88fd26f0d0b0e3302f23f4f210e529665437fca3e9314829"},
+]
 
 [package.dependencies]
 jinja2 = "*"
-"pyats.aereport" = ">=23.3.0,<23.4.0"
-"pyats.datastructures" = ">=23.3.0,<23.4.0"
-"pyats.log" = ">=23.3.0,<23.4.0"
-"pyats.results" = ">=23.3.0,<23.4.0"
-"pyats.utils" = ">=23.3.0,<23.4.0"
+prettytable = "*"
+"pyats.aereport" = ">=24.1.0,<24.2.0"
+"pyats.datastructures" = ">=24.1.0,<24.2.0"
+"pyats.log" = ">=24.1.0,<24.2.0"
+"pyats.results" = ">=24.1.0,<24.2.0"
+"pyats.utils" = ">=24.1.0,<24.2.0"
 pyyaml = "*"
 
 [package.extras]
-dev = ["sphinx", "sphinx-rtd-theme"]
+dev = ["Sphinx", "sphinx-rtd-theme"]
 
 [[package]]
-name = "pyats.async"
-version = "23.3"
+name = "pyats-async"
+version = "24.1"
 description = "pyATS Async: Asynchronous Execution of Codes"
-category = "main"
 optional = true
 python-versions = ">=3.5"
+files = [
+    {file = "pyats.async-24.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:d33aad3c33d8b81b2bb0df85de8233bb2434853f2647d4de304f53469350f076"},
+    {file = "pyats.async-24.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:fc878b8f5ffbf3f42d53ed8879c81ba7842ad5b93cab4c47ae54c9015e3e87bb"},
+    {file = "pyats.async-24.1-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:40138d93de565b2a4f2571bb95f6b3764b92923d88dc1727a040de6842757956"},
+    {file = "pyats.async-24.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:bea42c2a1f37b47e40c50975695c40ec7b1c33a84e3ab754f3fcc40ad76d9551"},
+    {file = "pyats.async-24.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:8bcad75d96006cdb92999a52345cefa420982486ec11fbf5685c35045802fe91"},
+    {file = "pyats.async-24.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:9a4736b5968bca03feea54876656a50eb7283120fb4ed77d4923a8debfa2bcdf"},
+    {file = "pyats.async-24.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:7a5fe8a53d0f4fc4086b608f6e9d692e6da865a8e74ae7703eeef7bb77795763"},
+    {file = "pyats.async-24.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:f7011869ee25c806c6057cba0fce314bf157c6203811e219a4d9b9f71d431ac9"},
+    {file = "pyats.async-24.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:3a0aaa4340ee23ae867c9eaa0c507385e73979339853a02332bcd74e66e9fa61"},
+    {file = "pyats.async-24.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:d77dddaa10c59718464f133f548032bafd417da19dbfd8f28d7aebbbe23dfd4c"},
+    {file = "pyats.async-24.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:cc882caddcb4ce46c5b2dc0b742aeae659fc97a6eacdd12179ea30d484a0a497"},
+    {file = "pyats.async-24.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:102867d8e73735847a096126bd25200d63909dab6ac9c3620f097fa0f866910e"},
+    {file = "pyats.async-24.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:788dbecb705787323f61cb6193aad747387928d644d237b734051e04ea302750"},
+]
 
 [package.dependencies]
-"pyats.log" = ">=23.3.0,<23.4.0"
+"pyats.log" = ">=24.1.0,<24.2.0"
 
 [package.extras]
-dev = ["sphinx", "sphinx-rtd-theme"]
+dev = ["Sphinx", "sphinx-rtd-theme"]
 
 [[package]]
-name = "pyats.connections"
-version = "23.3"
+name = "pyats-connections"
+version = "24.1"
 description = "pyATS Connection: Device Connection Handling & Base Classes"
-category = "main"
 optional = true
 python-versions = ">=3.5"
+files = [
+    {file = "pyats.connections-24.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c367625d958b5038c8671567d4e9d09cdfd9205597f9b0cbbd1731f9be6a650a"},
+    {file = "pyats.connections-24.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:d27705a4519e4d1ffa02cb1418f4d4a680207e9ba61994b5574c7e8c75503f50"},
+    {file = "pyats.connections-24.1-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:96baacdd1051ed99cb58d6718e92f3549b9f0fdf64ac3cb9101b299dad585fbb"},
+    {file = "pyats.connections-24.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:679d08972e6c7375d1588715c28ee17febac51b6e3db90dd0fa2e24775bff50f"},
+    {file = "pyats.connections-24.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:8578fa5349ca8b10856e1c20d723627353f7ae0198504a9101c1a9f6b8c98983"},
+    {file = "pyats.connections-24.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:7ea77e39618bdefdd8f4bfd890e931bc5069bf7985f950a9bbaf40b63f8c26e4"},
+    {file = "pyats.connections-24.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:de02024ef8fed87441ae645b60603dcf2b24b43989c1b5154a96554835c92a4a"},
+    {file = "pyats.connections-24.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:47aa98fee037417de7a07ff19d8c2d0f4b201352e1cd0af7fc87974da62bba5f"},
+    {file = "pyats.connections-24.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:68a3c7f1c6dc1027b893fb8080c047431d9e623cdf92af21cfca916c511c408b"},
+    {file = "pyats.connections-24.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:e68df72c83a2b8f6937423e8ed9de11957a26cf03b808e711a2495aabdb9dbf8"},
+    {file = "pyats.connections-24.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:8f23f3b1d997aa00a9f145806d58edab026d228d2d4ad7cdd0e74c895f1ea2b9"},
+    {file = "pyats.connections-24.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:bf6f2434842f37fad60925ac555503b2e21d03f759cadba7de533092078caeb0"},
+    {file = "pyats.connections-24.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:edec71147f744146bd3b242236e4d107f4a0e2493f5e8c5001c7d1055f5887d8"},
+]
 
 [package.dependencies]
-"pyats.async" = ">=23.3.0,<23.4.0"
-"pyats.datastructures" = ">=23.3.0,<23.4.0"
-unicon = ">=23.3.0,<23.4.0"
+"pyats.async" = ">=24.1.0,<24.2.0"
+"pyats.datastructures" = ">=24.1.0,<24.2.0"
+unicon = ">=24.1.0,<24.2.0"
 
 [package.extras]
-dev = ["sphinx", "sphinx-rtd-theme"]
+dev = ["Sphinx", "sphinx-rtd-theme"]
 
 [[package]]
-name = "pyats.datastructures"
-version = "23.3"
+name = "pyats-datastructures"
+version = "24.1"
 description = "pyATS Datastructures: Extended Datastructures for Grownups"
-category = "main"
 optional = true
 python-versions = ">=3.5"
+files = [
+    {file = "pyats.datastructures-24.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:d1057a8c18ee698c580c74399ab090db52478f9afba6453b4e57ba47aa9cab1e"},
+    {file = "pyats.datastructures-24.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:3bf22a0478530a07aab9cfd289329f9f7054024b20c5d5e6cb43c75d995a92bb"},
+    {file = "pyats.datastructures-24.1-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:268eb12a1494f5e14ad0ba777359681acbbf73fe5db6d7d20afc4ee3cd5d8be2"},
+    {file = "pyats.datastructures-24.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:37f9b99cc4cd1e0ddcdf46bd130db5007f6e65c66af87d775240712badcad543"},
+    {file = "pyats.datastructures-24.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d53f4a018774a9745cf973c055dac5b171b2ab5afa45d696e62c924453849ea8"},
+    {file = "pyats.datastructures-24.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:cb0e734a2549610d191dfef8f6695859422d875d71820682a5d322f10a2998d9"},
+    {file = "pyats.datastructures-24.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:12f72804156682a1fe4f023e28ac752c023f2462d343feb3f104761073765eaa"},
+    {file = "pyats.datastructures-24.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:08f00a9d44b78129da32a54720cfc0edf9340dd42004851e6a2a24b438329389"},
+    {file = "pyats.datastructures-24.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:992a03dd23c38974a2b62468a7e32e9466629d965141d94723e552ae3cddffba"},
+    {file = "pyats.datastructures-24.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:a7bca62de7c739ea2424df20a7c963f9fb954472345459c7838202726b1d9c21"},
+    {file = "pyats.datastructures-24.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:cd21706bc098d0f8c3df0e78c642834814369fb297e8fccdabead1741dca8fa4"},
+    {file = "pyats.datastructures-24.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:c71894f2b01282cad7647fd0917d2a8f3718d441b2dfdfc665c1f9d8cee9af4f"},
+    {file = "pyats.datastructures-24.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0d6188a2edf3c26e344282f4926b10f3249d53ed577efb355c44bd73174ca982"},
+]
 
 [package.extras]
-dev = ["sphinx", "sphinx-rtd-theme"]
+dev = ["Sphinx", "sphinx-rtd-theme"]
 
 [[package]]
-name = "pyats.easypy"
-version = "23.3"
+name = "pyats-easypy"
+version = "24.1"
 description = "pyATS Easypy: launcher and runtime environment"
-category = "main"
 optional = true
 python-versions = ">=3.5"
+files = [
+    {file = "pyats.easypy-24.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:e957d51ca572ac4a2950cf55bba6b9ef93204563300101f925798f1acf9e70d2"},
+    {file = "pyats.easypy-24.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:ec0aeb73eb7a391ea1f25e3d96ae8ccc162a2b3edaf39ac65b7d37187b89ecfe"},
+    {file = "pyats.easypy-24.1-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:502b3f0a591f8585aac00468fa08240004d84d2e6e70f1b06dee24028650d148"},
+    {file = "pyats.easypy-24.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:985bcdc42f47dbd5c577689b6f601e83a6fbd782ce8b225f77f58d0a52c0a605"},
+    {file = "pyats.easypy-24.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:f1377e447b90bcdfb9e6fee98e28a03c9f9d058929387d4e9ece6bdb8a8aa5bb"},
+    {file = "pyats.easypy-24.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:d7ffa1d129fcfe8a504f158d4ecf90e97668cf2e79bf2a6eac57b47769d7402c"},
+    {file = "pyats.easypy-24.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:a46f3b7ab839ff4f624c586f223221e81533b25b641529a90e3758034468bb24"},
+    {file = "pyats.easypy-24.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7a04cbf354a5f34ea8f8852d286f906b0a9fe788d47da6fe5ed805720d8f54e2"},
+    {file = "pyats.easypy-24.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:b9392a80db84f7d2682fac5d425346cb443642dcf711f81293547a06cc127d8d"},
+    {file = "pyats.easypy-24.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:295d2b972722a78b75daa34b5c1011b0beb1f8db8ec029a88da4aeb9ac358a66"},
+    {file = "pyats.easypy-24.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:15c6b830af1c2ca369d8b21c008837bdfdd5075d7c3218dc2a97ee03ab812daa"},
+    {file = "pyats.easypy-24.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:2a9c959ef1e52bc473591facd950c8edfd0228c19d71fe7eabe98b33b38b6722"},
+    {file = "pyats.easypy-24.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2f2e9827ce7da61a0bb4c49a2732f4dcbf99c6cc1d3a0b0385a4f32059344f0a"},
+]
 
 [package.dependencies]
 distro = "*"
 jinja2 = "*"
 psutil = "*"
-"pyats.aereport" = ">=23.3.0,<23.4.0"
-"pyats.datastructures" = ">=23.3.0,<23.4.0"
-"pyats.kleenex" = ">=23.3.0,<23.4.0"
-"pyats.log" = ">=23.3.0,<23.4.0"
-"pyats.results" = ">=23.3.0,<23.4.0"
-"pyats.topology" = ">=23.3.0,<23.4.0"
-"pyats.utils" = ">=23.3.0,<23.4.0"
+"pyats.aereport" = ">=24.1.0,<24.2.0"
+"pyats.datastructures" = ">=24.1.0,<24.2.0"
+"pyats.kleenex" = ">=24.1.0,<24.2.0"
+"pyats.log" = ">=24.1.0,<24.2.0"
+"pyats.results" = ">=24.1.0,<24.2.0"
+"pyats.topology" = ">=24.1.0,<24.2.0"
+"pyats.utils" = ">=24.1.0,<24.2.0"
+setuptools = "*"
 
 [package.extras]
-dev = ["sphinx", "sphinx-rtd-theme"]
+dev = ["Sphinx", "sphinx-rtd-theme"]
 
 [[package]]
-name = "pyats.kleenex"
-version = "23.3"
+name = "pyats-kleenex"
+version = "24.1"
 description = "pyATS Kleenex: Testbed Preparation, Clean & Finalization"
-category = "main"
 optional = true
 python-versions = ">=3.5"
+files = [
+    {file = "pyats.kleenex-24.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:a0fee331bc7fbdd48cc2630b322f676d2fe8b5bf9b5e2ae316991af39e58861f"},
+    {file = "pyats.kleenex-24.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:741ef7a4ce92cdf1b9bb9593a9d55daae03b0a77402f0ca896bb6af7309a104a"},
+    {file = "pyats.kleenex-24.1-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:fbfaf0ab41eab34af0f1c07f21af795740d0f9457a59a59a2ba47da0c520752d"},
+    {file = "pyats.kleenex-24.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:772a470e31be8c45032061122a2aadb9c50d59f67369b9187f71f5af1f6d2274"},
+    {file = "pyats.kleenex-24.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:e04be434934a1926a921f119103856562275e6cec41846de85bf32942118006c"},
+    {file = "pyats.kleenex-24.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:59466380d07068a376f89791abc309aad13c4d57d09db9bdad5b7145db2588e6"},
+    {file = "pyats.kleenex-24.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:fc46926f09c55636ba8df4ca4c91b523d4d2dba88624e664e2fc8274194bc55f"},
+    {file = "pyats.kleenex-24.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:69e66cb2330dda7e398c6d23ad253571b9e46505cf7c41d5651cf9684eab3ee2"},
+    {file = "pyats.kleenex-24.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:4da699a82df7bd17b8f776f8fb61dffc4d8ee3ec69411debdee40402c0284118"},
+    {file = "pyats.kleenex-24.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:7255006672f73bf3cf4af4bf6851ac328e6260dbf4f23970f82c1f4719fbcf00"},
+    {file = "pyats.kleenex-24.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:e1f3acddc5855ba209fda284a81cc1918df88b1133dff4325016007fd12196de"},
+    {file = "pyats.kleenex-24.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:042fb1ce231c92b684592a157ae9ac9848123e1e1145bd64eac09a2b6d194774"},
+    {file = "pyats.kleenex-24.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:30c2f04b1eee8ddec975f29f64edd8ddcef592039dda07b763cf8b19c42a8eff"},
+]
 
 [package.dependencies]
 distro = "*"
-"pyats.aetest" = ">=23.3.0,<23.4.0"
-"pyats.async" = ">=23.3.0,<23.4.0"
-"pyats.datastructures" = ">=23.3.0,<23.4.0"
-"pyats.log" = ">=23.3.0,<23.4.0"
-"pyats.topology" = ">=23.3.0,<23.4.0"
-"pyats.utils" = ">=23.3.0,<23.4.0"
+"pyats.aetest" = ">=24.1.0,<24.2.0"
+"pyats.async" = ">=24.1.0,<24.2.0"
+"pyats.datastructures" = ">=24.1.0,<24.2.0"
+"pyats.log" = ">=24.1.0,<24.2.0"
+"pyats.topology" = ">=24.1.0,<24.2.0"
+"pyats.utils" = ">=24.1.0,<24.2.0"
 requests = "*"
 
 [package.extras]
-dev = ["sphinx", "sphinx-rtd-theme"]
+dev = ["Sphinx", "sphinx-rtd-theme"]
 
 [[package]]
-name = "pyats.log"
-version = "23.3"
+name = "pyats-log"
+version = "24.1"
 description = "pyATS Log: Logging Format and Utilities"
-category = "main"
 optional = true
 python-versions = ">=3.5"
+files = [
+    {file = "pyats.log-24.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:157b84ea0c0ae09f53a1faee2f23bfa1cf43b260c00302bb34e6c22ec3e86579"},
+    {file = "pyats.log-24.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:b0e0a4e67447514daaf87e33e6bd8695d98faf52ba5decc70f2a0f3a1ec4cbe8"},
+    {file = "pyats.log-24.1-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:65d99392fb36a816ed1bc7ef19e7a23b9ef3654811e02326f80002c258b889c3"},
+    {file = "pyats.log-24.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:331f35801d97d4961a93df4a1d725f8f98b7ef8dc8d4606135677cd45f1242f7"},
+    {file = "pyats.log-24.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:20c668c1ac67e1246bb5a94aee7508971670103521be3b79377a4ce8d2058129"},
+    {file = "pyats.log-24.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:db8c05533bb508115f4ea69ff80f5a48de9c3ecd1a49f85a4e35a06e0836f90c"},
+    {file = "pyats.log-24.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:0b15ebee0bc9b843aea547594f46a32a781fc59f68201e011cbb860e1f370972"},
+    {file = "pyats.log-24.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:f2ad1a444d25038fc5df47d15f362a24aae32c5fa8836da30460216d6556877d"},
+    {file = "pyats.log-24.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:d84cbd4513635d695f6c25f63b13b5a4b7719caa4a550e23b83b7b3b8c468edd"},
+    {file = "pyats.log-24.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:60eec525c9b04b7f95d0105a6cbfd4d9b84c71d76aa08fb8b6cc24fd9dd1fe02"},
+    {file = "pyats.log-24.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:610342f154d728ee45b008faa49ab03363a728f4deacb543f3c1713162b188de"},
+    {file = "pyats.log-24.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:1fcd19cc76b20f0bb8ae4aa048a888e22eefc718c75e6a7d01e05c0d6b906100"},
+    {file = "pyats.log-24.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3d81f10061cd2f1f9eb49bd8a569680a1c54c41208373301ce34d50bb068e699"},
+]
 
 [package.dependencies]
 aiofiles = ">=0.6.0"
 aiohttp = {version = "<4.0", markers = "python_version >= \"3.6\""}
-aiohttp-swagger = ">=1.0.15"
 async-lru = ">=1.0.2"
 chardet = ">=3.0.4,<5.0.0"
 jinja2 = "*"
-"pyats.datastructures" = ">=23.3.0,<23.4.0"
+"pyats.datastructures" = ">=24.1.0,<24.2.0"
 python-engineio = ">=3.13.0,<4.0.0"
 python-socketio = ">=4.2.0,<5.0.0"
 pyyaml = "*"
 
 [package.extras]
-dev = ["sphinx", "sphinx-rtd-theme"]
+dev = ["Sphinx", "sphinx-rtd-theme"]
 
 [[package]]
-name = "pyats.reporter"
-version = "23.3"
+name = "pyats-reporter"
+version = "24.1"
 description = "pyATS Reporter: Result Collection and Reporting"
-category = "main"
 optional = true
 python-versions = ">=3.5"
+files = [
+    {file = "pyats.reporter-24.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7510bb10f1182468dde354ea3c305df0ae49ed2b08e948446e8f1f7417c9f354"},
+    {file = "pyats.reporter-24.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:6918c199bc4f1337b98191d59a478959b6a8fac4cd14ec73470efbd420496cb8"},
+    {file = "pyats.reporter-24.1-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:d3e20655f525d62f55f78ad0832c89e2c10e709e3750afc10d09062ac9ec7e8a"},
+    {file = "pyats.reporter-24.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:3657e4e84ea608de0f2be5f691b227da5b3d1fcb8b30b4e6d8fb326424def3ae"},
+    {file = "pyats.reporter-24.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:9a9bd1741f834d7610a94f3e3fc9736c6b29154582aa7c6e48bf4899df7aeddb"},
+    {file = "pyats.reporter-24.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:1266c2e0dcd02cf9d0ba4f09cbabc3daeb9d7c9cd4a6016b4feb52e0c8e21499"},
+    {file = "pyats.reporter-24.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:a1d0b6e55b7d4cc57c113f59c835c53d8b69e9107982c2a6a3a382e435d737b2"},
+    {file = "pyats.reporter-24.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:548686878067c45a977c1d391f06ccf27a7c798369df47c95e584013ff27cf2e"},
+    {file = "pyats.reporter-24.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:575f6306781322f335430574b23954a6603f1b5b15332100609bed3f61cfd4dd"},
+    {file = "pyats.reporter-24.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:4d604fbb424ed45d3b2c18887dad3dc0560d1b573bad3b732e4dd79e9be8f6bf"},
+    {file = "pyats.reporter-24.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:52962fda2aa8c7c70b82996ac5c43860dc46f11a1e0957b9dbf2ef602d55d33e"},
+    {file = "pyats.reporter-24.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:7c98257a7f844805a45e149a02a706eadcc6844bea9d25c4f23e9746a57be2b5"},
+    {file = "pyats.reporter-24.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb6eb128f92fa5c2cac2191fa8f84531c142cefc247506b1e4cc4e4ab664ccbc"},
+]
 
 [package.dependencies]
 gitpython = "*"
-"pyats.aereport" = ">=23.3.0,<23.4.0"
-"pyats.log" = ">=23.3.0,<23.4.0"
-"pyats.results" = ">=23.3.0,<23.4.0"
-"pyats.utils" = ">=23.3.0,<23.4.0"
+"pyats.aereport" = ">=24.1.0,<24.2.0"
+"pyats.log" = ">=24.1.0,<24.2.0"
+"pyats.results" = ">=24.1.0,<24.2.0"
+"pyats.utils" = ">=24.1.0,<24.2.0"
 pyyaml = "*"
 
 [package.extras]
-dev = ["sphinx", "sphinx-rtd-theme"]
+dev = ["Sphinx", "sphinx-rtd-theme"]
 
 [[package]]
-name = "pyats.results"
-version = "23.3"
+name = "pyats-results"
+version = "24.1"
 description = "pyATS Results: Representing Results using Objects"
-category = "main"
 optional = true
 python-versions = ">=3.5"
+files = [
+    {file = "pyats.results-24.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c81b3b53011e89abd3b44e0a80b9de9f995b111664a9f46ef04c22eefdf1180f"},
+    {file = "pyats.results-24.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:8494e0853d2a3fbba052dfee5337cca3f20fea875143035d5e6529d5ae12b348"},
+    {file = "pyats.results-24.1-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:2f049149ebbda01226c2c93418e3ab5ac46c3c889fbb4491f2397b8b5dd4b22f"},
+    {file = "pyats.results-24.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:6295818c0847a1bb453a4a697a9624915ea289076601dee520c729f32e3d7050"},
+    {file = "pyats.results-24.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:fba54a2a629161425f6e287b12de094df3a791a04fc7e7f00e3557dc174259fc"},
+    {file = "pyats.results-24.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:b67545875b4c90525291857fc5726e39fa1df6006f334127b01043c579d23531"},
+    {file = "pyats.results-24.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:125aa487fe9de9815a9b3bb7ba4e51926d66a0bcb2045e525d9baea7235a2307"},
+    {file = "pyats.results-24.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:cba9afaec6cb26aa39e76888ae944b87b80280cc9f4d98be0f00ecf4df00d8d0"},
+    {file = "pyats.results-24.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:cc4667334d36c98cc03bafa98206ebda4cfa1b3a609254c20f3035c930f6660e"},
+    {file = "pyats.results-24.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:f5f803044c9f98a7556a25379d0259ff375054f4354fbab1fc4101b557f9cfbc"},
+    {file = "pyats.results-24.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:07059f4a76d085e9e3d749957110c358d16128770ee69875cdd52a3195c159ff"},
+    {file = "pyats.results-24.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:6b11f1b8d7b5e9ce8d6801dee60551d9d803605b1237f821a1376d0b2bc840fb"},
+    {file = "pyats.results-24.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52c1fc35d38be836c45485d96c55afce2d6b415bbc351603b49ba9c401150063"},
+]
 
 [package.extras]
-dev = ["sphinx", "sphinx-rtd-theme"]
+dev = ["Sphinx", "sphinx-rtd-theme"]
 
 [[package]]
-name = "pyats.tcl"
-version = "23.3"
+name = "pyats-tcl"
+version = "24.1"
 description = "pyATS Tcl: Tcl Integration and Objects"
-category = "main"
 optional = true
 python-versions = ">=3.5"
+files = [
+    {file = "pyats.tcl-24.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:f71c5ab77cff26829a7f08dbe61cd9891493468a84b3594bf386b14f3baf4125"},
+    {file = "pyats.tcl-24.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:25b640e651bd6e90d3d92a25d09c7df93b0a9fb8741090538d99ecc57c598a2a"},
+    {file = "pyats.tcl-24.1-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:21211cd148504997991ce9c050fe22c325db29e80d6b935899b257fde829dd0f"},
+    {file = "pyats.tcl-24.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8962fa95fad160fe9020f07e7fbdbea19b583833e0f87061bd8861c8d455d506"},
+    {file = "pyats.tcl-24.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:685e5b27b3d466ba02f9e410396709bbb5c115766d1a1535f24d7c3e8667ef16"},
+    {file = "pyats.tcl-24.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:125fdc3eb034de4ae08852e8662e6b67232521d9f21f6d2ed3b311c134252952"},
+    {file = "pyats.tcl-24.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:5e40df3bd715c6d065b7f2fcc2d095a708cf3116b05ebe5e86e4949c5775d712"},
+    {file = "pyats.tcl-24.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:5b7003049ef15c480a79de670f4f56877b331e1273bbc9ea9749de6e200ac110"},
+    {file = "pyats.tcl-24.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:e18b129cfdd0ca118b664553b47d67c61e7ba6445ae3aa815888873d15063f9e"},
+    {file = "pyats.tcl-24.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:45a66d4f681609268dcaa0de48e85b8a74c22d1e1e9844138072fca17929465f"},
+    {file = "pyats.tcl-24.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:7e18f6aa37182ec2f1ebc142421a8334f21cd377c274cde29f3c9a5c9c7bd35f"},
+    {file = "pyats.tcl-24.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:da3df4579bcd4830d48f60fe1011297baa4aa7afc70a20866285943e06bf6dcc"},
+    {file = "pyats.tcl-24.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:16ea13ebcb06febea09b408543fdcec138d5d0929fca46b397c1ce55cf468860"},
+]
 
 [package.dependencies]
-"pyats.datastructures" = ">=23.3.0,<23.4.0"
-"pyats.log" = ">=23.3.0,<23.4.0"
+"pyats.datastructures" = ">=24.1.0,<24.2.0"
+"pyats.log" = ">=24.1.0,<24.2.0"
 
 [package.extras]
-dev = ["sphinx", "sphinx-rtd-theme"]
+dev = ["Sphinx", "sphinx-rtd-theme"]
 
 [[package]]
-name = "pyats.topology"
-version = "23.3"
+name = "pyats-topology"
+version = "24.1"
 description = "pyATS Topology: Topology Objects and Testbed YAMLs"
-category = "main"
 optional = true
 python-versions = ">=3.5"
+files = [
+    {file = "pyats.topology-24.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6d0e0212a770cc474c38c4fba3ad5253b2314679d36cef2487dd21998935b611"},
+    {file = "pyats.topology-24.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:1e5674439d203fc7f044ed8e78ebbf9f48689b266893a7dff776c93667e34856"},
+    {file = "pyats.topology-24.1-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:151f61cc0500327bd028ccae4c76441ebad37ba8f83c4f5b10305fe5964f7d8d"},
+    {file = "pyats.topology-24.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:1ec284ba32838b4238949e07ffde57c548d7315413119e5f99b7a9b106e3642e"},
+    {file = "pyats.topology-24.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:662e8654bab12191b1ef6a96e91e2d729c40987f11b27b510e5c474c90c4eff1"},
+    {file = "pyats.topology-24.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:c929f27caf7e680b6be900bfdbb4a2d02725e041395cee99c9b2e4d50d79017a"},
+    {file = "pyats.topology-24.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:c3a4f1fc8488dc903b545c08ac8b5c8ff5db09a433ebca8a6a4abaab6f86b968"},
+    {file = "pyats.topology-24.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:0cdf9f636711861fb731ca08f991e3a342f5d56e93aa29bcfb4168bd178ea4eb"},
+    {file = "pyats.topology-24.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:1b100948cd10f48ba193f57a59a6937362715245efd2c10fc4a4718c4689a5ac"},
+    {file = "pyats.topology-24.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:d32f3aa5c10b248fddd571c33a7113c20b4e3091fe3b01a31879b8abec939a5f"},
+    {file = "pyats.topology-24.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:3973da10b82e2615afd5c5f026bf74253d5fc24ff2b2362cd6da8a8ea2b46fd6"},
+    {file = "pyats.topology-24.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:5c1e64b3c6eec33e11d416bbdcf4b75a336bd3883bd58c1c4876494862d0cb12"},
+    {file = "pyats.topology-24.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:15d0825a7af5c8fb23c8a0babe6a92bc8860988927183d68ba93fb94fb1d4796"},
+]
 
 [package.dependencies]
-"pyats.connections" = ">=23.3.0,<23.4.0"
-"pyats.datastructures" = ">=23.3.0,<23.4.0"
-"pyats.utils" = ">=23.3.0,<23.4.0"
+"pyats.connections" = ">=24.1.0,<24.2.0"
+"pyats.datastructures" = ">=24.1.0,<24.2.0"
+"pyats.utils" = ">=24.1.0,<24.2.0"
 pyyaml = "*"
 yamllint = "*"
 
 [package.extras]
-dev = ["sphinx", "sphinx-rtd-theme"]
+dev = ["Sphinx", "sphinx-rtd-theme"]
 
 [[package]]
-name = "pyats.utils"
-version = "23.3"
+name = "pyats-utils"
+version = "24.1"
 description = "pyATS Utils: Utilities Module"
-category = "main"
 optional = true
 python-versions = ">=3.5"
+files = [
+    {file = "pyats.utils-24.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:87115c2f6779ca7d470de6f002b236487c9a7ab59e584fc7470327130bed5551"},
+    {file = "pyats.utils-24.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:b05c5061eb5792b023a6e18f0119207c96f81a33c2d63e1071a3e074ecc46706"},
+    {file = "pyats.utils-24.1-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:9ccd00e74e980dcb556729f718b8a83f330639ddc559ede9a3ea7f2d3bdc07b9"},
+    {file = "pyats.utils-24.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:9f571403cca75d66060f73b3d74e8f9efa5cd6b68915bbe2ba804fbd5c8d2a42"},
+    {file = "pyats.utils-24.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:188130922cb0a4e4a8aaad3ab5fe0e86a6a43b121e461d3830584932763d28ee"},
+    {file = "pyats.utils-24.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:08412536252ee5dd349fe3e77c97fc4fe9efd71b8944912ed3f3e48b25f1ec46"},
+    {file = "pyats.utils-24.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:5c367dbc673375fb0e9c733293a25cef54e5319b00d961825f92fe065add7038"},
+    {file = "pyats.utils-24.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:d8c40b64db4a92d491edc10e3ff9e74b3dff002084e489735be1d0ae6dcff507"},
+    {file = "pyats.utils-24.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:e63e0b66dcb2c41e6d4f716a18cc7694b7b9cc23a3facd96760903eb099dfa0f"},
+    {file = "pyats.utils-24.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:baa02c208b1bf74ac06eebd70a875beab62028bbca870ae91e0da2fb27bf905f"},
+    {file = "pyats.utils-24.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:ec686f66e74ede533e97ce3bce5606944ae9d9cb8259842857aca462767aff08"},
+    {file = "pyats.utils-24.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:7891eb7c79c929cdd1d69dba61c414c0527dc2b03499fc449d6806f6c264cf0a"},
+    {file = "pyats.utils-24.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:84c2ea6cddd528a1e82223e5b6c2b2b8934c80453e987d5561e80c4095bf11e6"},
+]
 
 [package.dependencies]
 cryptography = "*"
 distro = "*"
-"pyats.datastructures" = ">=23.3.0,<23.4.0"
-"pyats.topology" = ">=23.3.0,<23.4.0"
+"pyats.datastructures" = ">=24.1.0,<24.2.0"
+"pyats.topology" = ">=24.1.0,<24.2.0"
 
 [package.extras]
-dev = ["sphinx", "sphinx-rtd-theme", "requests-mock"]
+dev = ["Sphinx", "requests-mock", "sphinx-rtd-theme"]
 
 [[package]]
 name = "pycodestyle"
-version = "2.10.0"
+version = "2.11.1"
 description = "Python style guide checker"
-category = "dev"
 optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.8"
+files = [
+    {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"},
+    {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"},
+]
 
 [[package]]
 name = "pycparser"
 version = "2.21"
 description = "C parser in Python"
-category = "main"
 optional = true
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+files = [
+    {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
+    {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
+]
 
 [[package]]
 name = "pyflakes"
-version = "3.0.1"
+version = "3.2.0"
 description = "passive checker of Python programs"
-category = "dev"
 optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.8"
+files = [
+    {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"},
+    {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"},
+]
 
 [[package]]
 name = "pygments"
-version = "2.15.0"
+version = "2.17.2"
 description = "Pygments is a syntax highlighting package written in Python."
-category = "main"
 optional = true
 python-versions = ">=3.7"
+files = [
+    {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"},
+    {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"},
+]
 
 [package.extras]
 plugins = ["importlib-metadata"]
+windows-terminal = ["colorama (>=0.4.6)"]
 
 [[package]]
 name = "pytest"
-version = "7.3.1"
+version = "7.4.4"
 description = "pytest: simple powerful testing with Python"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
+files = [
+    {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"},
+    {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"},
+]
 
 [package.dependencies]
 colorama = {version = "*", markers = "sys_platform == \"win32\""}
@@ -841,62 +1669,125 @@ pluggy = ">=0.12,<2.0"
 tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
 
 [package.extras]
-testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
+testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
 
 [[package]]
 name = "python-engineio"
 version = "3.14.2"
 description = "Engine.IO server"
-category = "main"
 optional = true
 python-versions = "*"
+files = [
+    {file = "python-engineio-3.14.2.tar.gz", hash = "sha256:eab4553f2804c1ce97054c8b22cf0d5a9ab23128075248b97e1a5b2f29553085"},
+    {file = "python_engineio-3.14.2-py2.py3-none-any.whl", hash = "sha256:5a9e6086d192463b04a1428ff1f85b6ba631bbb19d453b144ffc04f530542b84"},
+]
 
 [package.dependencies]
 six = ">=1.9.0"
 
 [package.extras]
-asyncio_client = ["aiohttp (>=3.4)"]
+asyncio-client = ["aiohttp (>=3.4)"]
 client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"]
 
 [[package]]
 name = "python-socketio"
 version = "4.6.1"
 description = "Socket.IO server"
-category = "main"
 optional = true
 python-versions = "*"
+files = [
+    {file = "python-socketio-4.6.1.tar.gz", hash = "sha256:cd1f5aa492c1eb2be77838e837a495f117e17f686029ebc03d62c09e33f4fa10"},
+    {file = "python_socketio-4.6.1-py2.py3-none-any.whl", hash = "sha256:5a21da53fdbdc6bb6c8071f40e13d100e0b279ad997681c2492478e06f370523"},
+]
 
 [package.dependencies]
 python-engineio = ">=3.13.0,<4"
 six = ">=1.9.0"
 
 [package.extras]
-asyncio_client = ["aiohttp (>=3.4)", "websockets (>=7.0)"]
+asyncio-client = ["aiohttp (>=3.4)", "websockets (>=7.0)"]
 client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"]
 
 [[package]]
 name = "pytz"
-version = "2023.3"
+version = "2024.1"
 description = "World timezone definitions, modern and historical"
-category = "main"
 optional = true
 python-versions = "*"
+files = [
+    {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"},
+    {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"},
+]
 
 [[package]]
 name = "pyyaml"
-version = "6.0"
+version = "6.0.1"
 description = "YAML parser and emitter for Python"
-category = "main"
 optional = false
 python-versions = ">=3.6"
+files = [
+    {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"},
+    {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"},
+    {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
+    {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
+    {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
+    {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
+    {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
+    {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
+    {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
+    {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"},
+    {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
+    {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
+    {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
+    {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
+    {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
+    {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
+    {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
+    {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
+    {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
+    {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
+    {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
+    {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
+    {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
+    {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
+    {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
+    {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"},
+    {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"},
+    {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"},
+    {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"},
+    {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"},
+    {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"},
+    {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"},
+    {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"},
+    {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"},
+    {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"},
+    {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
+    {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
+    {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
+    {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
+    {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
+    {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
+    {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
+    {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"},
+    {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
+    {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
+    {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
+    {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
+    {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
+    {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
+    {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
+]
 
 [[package]]
 name = "requests"
 version = "2.31.0"
 description = "Python HTTP for Humans."
-category = "main"
 optional = true
 python-versions = ">=3.7"
+files = [
+    {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"},
+    {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"},
+]
 
 [package.dependencies]
 certifi = ">=2017.4.17"
@@ -906,64 +1797,98 @@ urllib3 = ">=1.21.1,<3"
 
 [package.extras]
 socks = ["PySocks (>=1.5.6,!=1.5.7)"]
-use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"]
+use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
 
 [[package]]
 name = "respx"
-version = "0.20.1"
+version = "0.20.2"
 description = "A utility for mocking out the Python HTTPX and HTTP Core libraries."
-category = "dev"
 optional = false
 python-versions = ">=3.7"
+files = [
+    {file = "respx-0.20.2-py2.py3-none-any.whl", hash = "sha256:ab8e1cf6da28a5b2dd883ea617f8130f77f676736e6e9e4a25817ad116a172c9"},
+    {file = "respx-0.20.2.tar.gz", hash = "sha256:07cf4108b1c88b82010f67d3c831dae33a375c7b436e54d87737c7f9f99be643"},
+]
 
 [package.dependencies]
 httpx = ">=0.21.0"
 
+[[package]]
+name = "setuptools"
+version = "69.1.0"
+description = "Easily download, build, install, upgrade, and uninstall Python packages"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "setuptools-69.1.0-py3-none-any.whl", hash = "sha256:c054629b81b946d63a9c6e732bc8b2513a7c3ea645f11d0139a2191d735c60c6"},
+    {file = "setuptools-69.1.0.tar.gz", hash = "sha256:850894c4195f09c4ed30dba56213bf7c3f21d86ed6bdaafb5df5972593bfc401"},
+]
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
+testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
+testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
+
 [[package]]
 name = "six"
 version = "1.16.0"
 description = "Python 2 and 3 compatibility utilities"
-category = "main"
 optional = true
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+files = [
+    {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+    {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
+]
 
 [[package]]
 name = "smmap"
-version = "5.0.0"
+version = "5.0.1"
 description = "A pure Python implementation of a sliding window memory map manager"
-category = "main"
 optional = true
-python-versions = ">=3.6"
+python-versions = ">=3.7"
+files = [
+    {file = "smmap-5.0.1-py3-none-any.whl", hash = "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da"},
+    {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"},
+]
 
 [[package]]
 name = "sniffio"
 version = "1.3.0"
 description = "Sniff out which async library your code is running under"
-category = "main"
 optional = false
 python-versions = ">=3.7"
+files = [
+    {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"},
+    {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"},
+]
 
 [[package]]
 name = "snowballstemmer"
 version = "2.2.0"
 description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms."
-category = "main"
 optional = true
 python-versions = "*"
+files = [
+    {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"},
+    {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"},
+]
 
 [[package]]
 name = "sphinx"
-version = "6.1.3"
+version = "7.1.2"
 description = "Python documentation generator"
-category = "main"
 optional = true
 python-versions = ">=3.8"
+files = [
+    {file = "sphinx-7.1.2-py3-none-any.whl", hash = "sha256:d170a81825b2fcacb6dfd5a0d7f578a053e45d3f2b153fecc948c37344eb4cbe"},
+    {file = "sphinx-7.1.2.tar.gz", hash = "sha256:780f4d32f1d7d1126576e0e5ecc19dc32ab76cd24e950228dcf7b1f6d3d9e22f"},
+]
 
 [package.dependencies]
 alabaster = ">=0.7,<0.8"
 babel = ">=2.9"
 colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
-docutils = ">=0.18,<0.20"
+docutils = ">=0.18.1,<0.21"
 imagesize = ">=1.3"
 importlib-metadata = {version = ">=4.8", markers = "python_version < \"3.10\""}
 Jinja2 = ">=3.0"
@@ -980,68 +1905,83 @@ sphinxcontrib-serializinghtml = ">=1.1.5"
 
 [package.extras]
 docs = ["sphinxcontrib-websupport"]
-lint = ["flake8 (>=3.5.0)", "flake8-simplify", "isort", "ruff", "mypy (>=0.990)", "sphinx-lint", "docutils-stubs", "types-requests"]
-test = ["pytest (>=4.6)", "html5lib", "cython"]
+lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-simplify", "isort", "mypy (>=0.990)", "ruff", "sphinx-lint", "types-requests"]
+test = ["cython", "filelock", "html5lib", "pytest (>=4.6)"]
 
 [[package]]
 name = "sphinx-rtd-theme"
-version = "1.2.0"
+version = "1.3.0"
 description = "Read the Docs theme for Sphinx"
-category = "main"
 optional = true
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
+files = [
+    {file = "sphinx_rtd_theme-1.3.0-py2.py3-none-any.whl", hash = "sha256:46ddef89cc2416a81ecfbeaceab1881948c014b1b6e4450b815311a89fb977b0"},
+    {file = "sphinx_rtd_theme-1.3.0.tar.gz", hash = "sha256:590b030c7abb9cf038ec053b95e5380b5c70d61591eb0b552063fbe7c41f0931"},
+]
 
 [package.dependencies]
 docutils = "<0.19"
-sphinx = ">=1.6,<7"
-sphinxcontrib-jquery = {version = ">=2.0.0,<3.0.0 || >3.0.0", markers = "python_version > \"3\""}
+sphinx = ">=1.6,<8"
+sphinxcontrib-jquery = ">=4,<5"
 
 [package.extras]
-dev = ["transifex-client", "sphinxcontrib-httpdomain", "bump2version", "wheel"]
+dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client", "wheel"]
 
 [[package]]
 name = "sphinxcontrib-applehelp"
 version = "1.0.4"
 description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books"
-category = "main"
 optional = true
 python-versions = ">=3.8"
+files = [
+    {file = "sphinxcontrib-applehelp-1.0.4.tar.gz", hash = "sha256:828f867945bbe39817c210a1abfd1bc4895c8b73fcaade56d45357a348a07d7e"},
+    {file = "sphinxcontrib_applehelp-1.0.4-py3-none-any.whl", hash = "sha256:29d341f67fb0f6f586b23ad80e072c8e6ad0b48417db2bde114a4c9746feb228"},
+]
 
 [package.extras]
-lint = ["flake8", "mypy", "docutils-stubs"]
+lint = ["docutils-stubs", "flake8", "mypy"]
 test = ["pytest"]
 
 [[package]]
 name = "sphinxcontrib-devhelp"
 version = "1.0.2"
 description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document."
-category = "main"
 optional = true
 python-versions = ">=3.5"
+files = [
+    {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"},
+    {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"},
+]
 
 [package.extras]
-lint = ["flake8", "mypy", "docutils-stubs"]
+lint = ["docutils-stubs", "flake8", "mypy"]
 test = ["pytest"]
 
 [[package]]
 name = "sphinxcontrib-htmlhelp"
 version = "2.0.1"
 description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files"
-category = "main"
 optional = true
 python-versions = ">=3.8"
+files = [
+    {file = "sphinxcontrib-htmlhelp-2.0.1.tar.gz", hash = "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff"},
+    {file = "sphinxcontrib_htmlhelp-2.0.1-py3-none-any.whl", hash = "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903"},
+]
 
 [package.extras]
-lint = ["flake8", "mypy", "docutils-stubs"]
-test = ["pytest", "html5lib"]
+lint = ["docutils-stubs", "flake8", "mypy"]
+test = ["html5lib", "pytest"]
 
 [[package]]
 name = "sphinxcontrib-jquery"
 version = "4.1"
 description = "Extension to include jQuery on newer Sphinx releases"
-category = "main"
 optional = true
 python-versions = ">=2.7"
+files = [
+    {file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"},
+    {file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"},
+]
 
 [package.dependencies]
 Sphinx = ">=1.8"
@@ -1050,144 +1990,282 @@ Sphinx = ">=1.8"
 name = "sphinxcontrib-jsmath"
 version = "1.0.1"
 description = "A sphinx extension which renders display math in HTML via JavaScript"
-category = "main"
 optional = true
 python-versions = ">=3.5"
+files = [
+    {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"},
+    {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"},
+]
 
 [package.extras]
-test = ["pytest", "flake8", "mypy"]
+test = ["flake8", "mypy", "pytest"]
 
 [[package]]
 name = "sphinxcontrib-qthelp"
 version = "1.0.3"
 description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document."
-category = "main"
 optional = true
 python-versions = ">=3.5"
+files = [
+    {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"},
+    {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"},
+]
 
 [package.extras]
-lint = ["flake8", "mypy", "docutils-stubs"]
+lint = ["docutils-stubs", "flake8", "mypy"]
 test = ["pytest"]
 
 [[package]]
 name = "sphinxcontrib-serializinghtml"
 version = "1.1.5"
 description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)."
-category = "main"
 optional = true
 python-versions = ">=3.5"
+files = [
+    {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"},
+    {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"},
+]
 
 [package.extras]
-lint = ["flake8", "mypy", "docutils-stubs"]
+lint = ["docutils-stubs", "flake8", "mypy"]
 test = ["pytest"]
 
 [[package]]
 name = "tomli"
 version = "2.0.1"
 description = "A lil' TOML parser"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
+files = [
+    {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
+    {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
+]
 
 [[package]]
 name = "typing-extensions"
-version = "4.5.0"
-description = "Backported and Experimental Type Hints for Python 3.7+"
-category = "main"
-optional = true
-python-versions = ">=3.7"
+version = "4.9.0"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"},
+    {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"},
+]
 
 [[package]]
 name = "unicon"
-version = "23.3"
+version = "24.1"
 description = "Unicon Connection Library"
-category = "main"
 optional = true
 python-versions = ">=3.5"
+files = [
+    {file = "unicon-24.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:fc938026ebf283cf70dd58b39da2ef3922da0c01abd387e5c9beed2a57903a21"},
+    {file = "unicon-24.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:c1846b738cdc47fdc5b689b4aef2bf41ccc5d8eea85eb5a2ae61f6f2779f82c6"},
+    {file = "unicon-24.1-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:900b6902e8af1114a5a01542420ffc81d1611b01854b77d265d7a10164800aad"},
+    {file = "unicon-24.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:ff68da6b7cef51269c085570aa44189428549ee69cf7db86654b00cb4aa26e15"},
+    {file = "unicon-24.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:915e06f7d7e5d06ac74b4f1464b968756aa0b008946efef2b26c89875dfed9ce"},
+    {file = "unicon-24.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:d6a21940da0ba95094d9d93d4cac6d299f61ecd0275a57238b7380467bc46890"},
+    {file = "unicon-24.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:b2d802360f6feedc7112996990de7e1bfd0d4f61e0499cb2c971bbaf32e8866d"},
+    {file = "unicon-24.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fe62346f5572eecfb55e6009703e2a649706b6790cbf4b560871132191272832"},
+    {file = "unicon-24.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:448897c77d729375a9cb55f03d7c5fa0b568f903447ea95d1c2eeffe66716f6d"},
+    {file = "unicon-24.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:aabf008239e99e201e7f7c642c088320e0123fa2602b313c0bc54f472e0fcf11"},
+    {file = "unicon-24.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:a68b63af7e8225ecfcd2c0f9345ed7271da9a58f1a0e030833a778ace3f64e40"},
+    {file = "unicon-24.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:275db380cdd42284cf49a91fd633c4e42cec858c017968ed7ea32021c7892049"},
+    {file = "unicon-24.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:77260d4e377a76c2703264a090a6b3358dba42468355f0a347a855944116a5c3"},
+]
 
 [package.dependencies]
 dill = "*"
 pyyaml = "*"
-"unicon.plugins" = ">=23.3.0,<23.4.0"
+"unicon.plugins" = ">=24.1.0,<24.2.0"
 
 [package.extras]
-dev = ["cisco-distutils", "coverage", "restview", "sphinx", "sphinxcontrib-napoleon", "sphinxcontrib-mockautodoc", "sphinx-rtd-theme"]
+dev = ["Sphinx", "cisco-distutils", "coverage", "restview", "sphinx-rtd-theme", "sphinxcontrib-mockautodoc", "sphinxcontrib-napoleon"]
 pyats = ["pyats"]
 robot = ["robotframework"]
 
 [[package]]
-name = "unicon.plugins"
-version = "23.3"
+name = "unicon-plugins"
+version = "24.1"
 description = "Unicon Connection Library Plugins"
-category = "main"
 optional = true
 python-versions = "*"
+files = [
+    {file = "unicon.plugins-24.1-py3-none-any.whl", hash = "sha256:28e963315c87895efb7d144106dad3ff82791410f43a0e1d96f4cb20f0b0d4cd"},
+]
 
 [package.dependencies]
 PrettyTable = "*"
 pyyaml = "*"
-unicon = ">=23.3.0,<23.4.0"
+unicon = ">=24.1.0,<24.2.0"
 
 [package.extras]
-dev = ["setuptools", "pip", "wheel", "coverage", "restview", "sphinx", "sphinxcontrib-napoleon", "sphinxcontrib-mockautodoc", "sphinx-rtd-theme"]
+dev = ["Sphinx", "coverage", "pip", "restview", "setuptools", "sphinx-rtd-theme", "sphinxcontrib-mockautodoc", "sphinxcontrib-napoleon", "wheel"]
 
 [[package]]
 name = "urllib3"
-version = "1.26.17"
+version = "2.2.1"
 description = "HTTP library with thread-safe connection pooling, file post, and more."
-category = "main"
-optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
+optional = true
+python-versions = ">=3.8"
+files = [
+    {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"},
+    {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"},
+]
 
 [package.extras]
-brotli = ["brotlicffi (>=0.8.0)", "brotli (==1.0.9)", "brotlipy (>=0.6.0)", "brotli (>=1.0.9)"]
-secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"]
-socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
+brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
+h2 = ["h2 (>=4,<5)"]
+socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
+zstd = ["zstandard (>=0.18.0)"]
 
 [[package]]
 name = "virtualenv"
-version = "20.21.0"
+version = "20.25.0"
 description = "Virtual Python Environment builder"
-category = "dev"
 optional = false
 python-versions = ">=3.7"
+files = [
+    {file = "virtualenv-20.25.0-py3-none-any.whl", hash = "sha256:4238949c5ffe6876362d9c0180fc6c3a824a7b12b80604eeb8085f2ed7460de3"},
+    {file = "virtualenv-20.25.0.tar.gz", hash = "sha256:bf51c0d9c7dd63ea8e44086fa1e4fb1093a31e963b86959257378aef020e1f1b"},
+]
 
 [package.dependencies]
-distlib = ">=0.3.6,<1"
-filelock = ">=3.4.1,<4"
-platformdirs = ">=2.4,<4"
+distlib = ">=0.3.7,<1"
+filelock = ">=3.12.2,<4"
+platformdirs = ">=3.9.1,<5"
 
 [package.extras]
-docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx-argparse (>=0.4)", "sphinx (>=6.1.3)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"]
-test = ["covdefaults (>=2.2.2)", "coverage-enable-subprocess (>=1)", "coverage (>=7.1)", "flaky (>=3.7)", "packaging (>=23)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "pytest (>=7.2.1)"]
+docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
+test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
 
 [[package]]
 name = "wcwidth"
-version = "0.2.6"
+version = "0.2.13"
 description = "Measures the displayed width of unicode strings in a terminal"
-category = "main"
 optional = true
 python-versions = "*"
+files = [
+    {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"},
+    {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"},
+]
 
 [[package]]
 name = "yamllint"
-version = "1.30.0"
+version = "1.35.1"
 description = "A linter for YAML files."
-category = "main"
 optional = true
-python-versions = ">=3.7"
+python-versions = ">=3.8"
+files = [
+    {file = "yamllint-1.35.1-py3-none-any.whl", hash = "sha256:2e16e504bb129ff515b37823b472750b36b6de07963bd74b307341ef5ad8bdc3"},
+    {file = "yamllint-1.35.1.tar.gz", hash = "sha256:7a003809f88324fd2c877734f2d575ee7881dd9043360657cc8049c809eba6cd"},
+]
 
 [package.dependencies]
 pathspec = ">=0.5.3"
 pyyaml = "*"
 
+[package.extras]
+dev = ["doc8", "flake8", "flake8-import-order", "rstcheck[sphinx]", "sphinx"]
+
 [[package]]
 name = "yarl"
-version = "1.8.2"
+version = "1.9.4"
 description = "Yet another URL library"
-category = "main"
 optional = true
 python-versions = ">=3.7"
+files = [
+    {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e"},
+    {file = "yarl-1.9.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2"},
+    {file = "yarl-1.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66"},
+    {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234"},
+    {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392"},
+    {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551"},
+    {file = "yarl-1.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455"},
+    {file = "yarl-1.9.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c"},
+    {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53"},
+    {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385"},
+    {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863"},
+    {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b"},
+    {file = "yarl-1.9.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541"},
+    {file = "yarl-1.9.4-cp310-cp310-win32.whl", hash = "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d"},
+    {file = "yarl-1.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b"},
+    {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099"},
+    {file = "yarl-1.9.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c"},
+    {file = "yarl-1.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0"},
+    {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525"},
+    {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8"},
+    {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9"},
+    {file = "yarl-1.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42"},
+    {file = "yarl-1.9.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe"},
+    {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce"},
+    {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9"},
+    {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572"},
+    {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958"},
+    {file = "yarl-1.9.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98"},
+    {file = "yarl-1.9.4-cp311-cp311-win32.whl", hash = "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31"},
+    {file = "yarl-1.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1"},
+    {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81"},
+    {file = "yarl-1.9.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142"},
+    {file = "yarl-1.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074"},
+    {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129"},
+    {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2"},
+    {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78"},
+    {file = "yarl-1.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4"},
+    {file = "yarl-1.9.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0"},
+    {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51"},
+    {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff"},
+    {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7"},
+    {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc"},
+    {file = "yarl-1.9.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10"},
+    {file = "yarl-1.9.4-cp312-cp312-win32.whl", hash = "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7"},
+    {file = "yarl-1.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984"},
+    {file = "yarl-1.9.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f"},
+    {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17"},
+    {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14"},
+    {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5"},
+    {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd"},
+    {file = "yarl-1.9.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7"},
+    {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e"},
+    {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec"},
+    {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c"},
+    {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead"},
+    {file = "yarl-1.9.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434"},
+    {file = "yarl-1.9.4-cp37-cp37m-win32.whl", hash = "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749"},
+    {file = "yarl-1.9.4-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2"},
+    {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be"},
+    {file = "yarl-1.9.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f"},
+    {file = "yarl-1.9.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf"},
+    {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1"},
+    {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57"},
+    {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa"},
+    {file = "yarl-1.9.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130"},
+    {file = "yarl-1.9.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559"},
+    {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23"},
+    {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec"},
+    {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78"},
+    {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be"},
+    {file = "yarl-1.9.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3"},
+    {file = "yarl-1.9.4-cp38-cp38-win32.whl", hash = "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece"},
+    {file = "yarl-1.9.4-cp38-cp38-win_amd64.whl", hash = "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b"},
+    {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27"},
+    {file = "yarl-1.9.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1"},
+    {file = "yarl-1.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91"},
+    {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b"},
+    {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5"},
+    {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34"},
+    {file = "yarl-1.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136"},
+    {file = "yarl-1.9.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7"},
+    {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e"},
+    {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4"},
+    {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec"},
+    {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c"},
+    {file = "yarl-1.9.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0"},
+    {file = "yarl-1.9.4-cp39-cp39-win32.whl", hash = "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575"},
+    {file = "yarl-1.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15"},
+    {file = "yarl-1.9.4-py3-none-any.whl", hash = "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad"},
+    {file = "yarl-1.9.4.tar.gz", hash = "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf"},
+]
 
 [package.dependencies]
 idna = ">=2.0"
@@ -1195,15 +2273,18 @@ multidict = ">=4.0"
 
 [[package]]
 name = "zipp"
-version = "3.15.0"
+version = "3.17.0"
 description = "Backport of pathlib-compatible object wrapper for zip files"
-category = "main"
 optional = true
-python-versions = ">=3.7"
+python-versions = ">=3.8"
+files = [
+    {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"},
+    {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"},
+]
 
 [package.extras]
-docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "sphinx-lint", "jaraco.tidelift (>=1.4)"]
-testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "jaraco.functools", "more-itertools", "big-o", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "pytest-flake8"]
+docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
 
 [extras]
 docs = ["sphinx", "sphinx_rtd_theme"]
@@ -1211,104 +2292,6 @@ events = ["aiohttp"]
 pyats = ["pyats"]
 
 [metadata]
-lock-version = "1.1"
+lock-version = "2.0"
 python-versions = "^3.8.1"
-content-hash = "ace151bdcad40eb78cf9f7f6ac32a733663aec0b6c562300c8f4dbf168818ac7"
-
-[metadata.files]
-aiofiles = []
-aiohttp = []
-aiohttp-swagger = []
-aiosignal = []
-alabaster = []
-anyio = []
-async-lru = []
-async-timeout = []
-attrs = []
-babel = []
-certifi = []
-cffi = []
-cfgv = []
-chardet = []
-charset-normalizer = []
-colorama = []
-cryptography = []
-dill = []
-distlib = []
-distro = []
-docutils = []
-exceptiongroup = []
-filelock = []
-flake8 = []
-frozenlist = []
-gitdb = []
-gitpython = []
-h11 = []
-httpcore = []
-httpx = []
-identify = []
-idna = []
-imagesize = []
-importlib-metadata = []
-iniconfig = []
-jinja2 = []
-junit-xml = []
-markupsafe = []
-mccabe = []
-multidict = []
-nodeenv = []
-packaging = []
-pathspec = []
-platformdirs = []
-pluggy = []
-pre-commit = []
-prettytable = []
-psutil = []
-pyats = []
-"pyats.aereport" = []
-"pyats.aetest" = []
-"pyats.async" = []
-"pyats.connections" = []
-"pyats.datastructures" = []
-"pyats.easypy" = []
-"pyats.kleenex" = []
-"pyats.log" = []
-"pyats.reporter" = []
-"pyats.results" = []
-"pyats.tcl" = []
-"pyats.topology" = []
-"pyats.utils" = []
-pycodestyle = []
-pycparser = []
-pyflakes = []
-pygments = []
-pytest = []
-python-engineio = []
-python-socketio = []
-pytz = []
-pyyaml = []
-requests = []
-respx = []
-six = []
-smmap = []
-sniffio = []
-snowballstemmer = []
-sphinx = []
-sphinx-rtd-theme = []
-sphinxcontrib-applehelp = []
-sphinxcontrib-devhelp = []
-sphinxcontrib-htmlhelp = []
-sphinxcontrib-jquery = []
-sphinxcontrib-jsmath = []
-sphinxcontrib-qthelp = []
-sphinxcontrib-serializinghtml = []
-tomli = []
-typing-extensions = []
-unicon = []
-"unicon.plugins" = []
-urllib3 = []
-virtualenv = []
-wcwidth = []
-yamllint = []
-yarl = []
-zipp = []
+content-hash = "ff4e38f7f8277ad87574ccb3d5a34077c5dc9508dc607caa5cb3b5efb79e6326"
diff --git a/pyproject.toml b/pyproject.toml
index 0a0d4c0..5f1d790 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "virl2_client"
-version = "2.6.1"
+version = "2.7.0"
 description = "VIRL2 Client Library"
 authors = ["Simon Knight <simknigh@cisco.com>", "Ralph Schmieder <rschmied@cisco.com>"]
 license = "Apache-2.0"
@@ -21,28 +21,26 @@ classifiers = [
 
 [tool.poetry.dependencies]
 python = "^3.8.1"
-httpx = "^0.24.0"
-# SIMPLE-5904
-urllib3 = "^1"
+httpx = "^0.26.0"
 
+# https://github.com/python-poetry/poetry/pull/606 -- no support for optional dev-deps
 # optional package for events
-aiohttp = {version = "*", optional = true}
+aiohttp = {version = "^3.8", optional = true}
 
 # optional pyATS package
 # (this pulls in a lot more dependencies)
 # it does not pull in genie, need to specify extras=["full"] for pyATS.
-# pyats = {extras=["full"], version = "^23", optional = true}
-pyats = {version = "^23", optional = true}
+pyats = {version = "^24", optional = true}
 
-# needed to build documentation, technically, this is a dev-dependency
-# https://github.com/python-poetry/poetry/pull/606 -- no support
+# optional packages for documentation build
 sphinx_rtd_theme = {version="^1", optional = true}
-sphinx =  {version="^6", optional = true}
+sphinx =  {version="^7", optional = true}
 
-[tool.poetry.dev-dependencies]
-flake8 = "^6"
+[tool.poetry.group.dev.dependencies]
+flake8 = "^7"
 pre-commit = "^3"
-pytest = "*"
+# https://github.com/pytest-dev/pytest-asyncio/issues/737
+pytest = "<8"
 respx = "^0.20.0"
 
 [tool.poetry.extras]
@@ -51,5 +49,5 @@ pyats = ["pyats"]
 docs = ["sphinx", "sphinx_rtd_theme"]
 
 [build-system]
-requires = ["poetry>=1.1.8"]
+requires = ["poetry-core"]
 build-backend = "poetry.core.masonry.api"
diff --git a/virl2_client/__init__.py b/virl2_client/__init__.py
index aae4f90..d36ed1f 100644
--- a/virl2_client/__init__.py
+++ b/virl2_client/__init__.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
diff --git a/virl2_client/event_handling.py b/virl2_client/event_handling.py
index d15325a..ab19cf0 100644
--- a/virl2_client/event_handling.py
+++ b/virl2_client/event_handling.py
@@ -1,10 +1,30 @@
+#
+# This file is part of VIRL 2
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
+# All rights reserved.
+#
+# Python bindings for the Cisco VIRL 2 Network Simulation Platform
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
 from __future__ import annotations
 
 import asyncio
 import logging
 from abc import ABC, abstractmethod
 from os import name as os_name
-from typing import TYPE_CHECKING, Any, Optional, Union
+from typing import TYPE_CHECKING, Any, Union
 
 from .exceptions import ElementNotFound, LabNotFound
 
@@ -22,160 +42,234 @@
 
 
 class Event:
-    """An event object, stores parsed info about the event it represents."""
-
     def __init__(self, event_dict: dict[str, Any]):
+        """
+        An event object, stores parsed info about the event it represents.
+
+        :param event_dict: A dictionary containing event data.
+        """
         self.type: str = (event_dict.get("event_type") or "").casefold()
-        self.subtype_original: Optional[str] = event_dict.get("event")
+        self.subtype_original: str | None = event_dict.get("event")
         self.subtype: str = (self.subtype_original or "").casefold()
         self.element_type: str = (event_dict.get("element_type") or "").casefold()
         self.lab_id: str = event_dict.get("lab_id", "")
         self.element_id: str = event_dict.get("element_id", "")
-        self.data: Optional[dict] = event_dict.get("data")
-        self.lab: Optional[Lab] = None
+        self.data: dict | None = event_dict.get("data")
+        self.lab: Lab | None = None
         self.element: Union[Node, Interface, Link, None] = None
 
+    def __str__(self):
+        return (
+            f"Event type: {self.type}, "
+            f"Subtype: {self.subtype}, "
+            f"Element type: {self.element_type}"
+        )
 
-class EventHandlerBase(ABC):
-    """
-    Abstract base class for event handlers. Subclass this if you
-    want to implement the entire event handling mechanism from scratch,
-    otherwise subclass EventHandler instead.
-    """
 
+class EventHandlerBase(ABC):
     def __init__(self, client_library: ClientLibrary = None):
-        self.client_library = client_library
+        """
+        Abstract base class for event handlers.
 
-    def parse_event(self, event: Event) -> None:
+        Subclass this if you want to implement the entire event handling mechanism
+        from scratch, otherwise subclass EventHandler instead.
+
+        :param client_library: The client library which should be modified by events.
+        """
+        self._client_library = client_library
+
+    def handle_event(self, event: Event) -> None:
+        """
+        Parse and handle the given event.
+
+        :param event: An Event object representing the event to be parsed.
+        """
         if event.type == "lab_event":
-            self._parse_lab(event)
+            self._handle_lab(event)
         elif event.type == "lab_element_event":
-            self._parse_element(event)
+            self._handle_element(event)
         elif event.type == "state_change":
-            self._parse_state_change(event)
+            self._handle_state_change(event)
         else:
-            self._parse_other(event)
+            self._handle_other(event)
+
+    def _handle_lab(self, event: Event) -> None:
+        """
+        Handle lab events.
 
-    def _parse_lab(self, event: Event) -> None:
+        :param event: An Event object representing the lab event.
+        """
         if event.subtype == "created":
-            self._parse_lab_created(event)
+            self._handle_lab_created(event)
         elif event.subtype == "modified":
-            self._parse_lab_modified(event)
+            self._handle_lab_modified(event)
         elif event.subtype == "deleted":
-            self._parse_lab_deleted(event)
+            self._handle_lab_deleted(event)
         elif event.subtype == "state":
-            self._parse_lab_state(event)
+            self._handle_lab_state(event)
         else:
-            _LOGGER.warning("Received an invalid lab event (%s)", event.subtype)
+            # There are only four subtypes, anything else is invalid
+            _LOGGER.warning(f"Received an invalid event. {event}")
 
     @abstractmethod
-    def _parse_lab_created(self, event: Event) -> None:
+    def _handle_lab_created(self, event: Event) -> None:
+        """
+        Handle lab created events.
+
+        :param event: An Event object representing the lab created event.
+        """
         pass
 
     @abstractmethod
-    def _parse_lab_modified(self, event: Event) -> None:
+    def _handle_lab_modified(self, event: Event) -> None:
+        """
+        Handle lab modified events.
+
+        :param event: An Event object representing the lab modified event.
+        """
         pass
 
     @abstractmethod
-    def _parse_lab_deleted(self, event: Event) -> None:
+    def _handle_lab_deleted(self, event: Event) -> None:
+        """
+        Handle lab deleted events.
+
+        :param event: An Event object representing the lab deleted event.
+        """
         pass
 
     @abstractmethod
-    def _parse_lab_state(self, event: Event) -> None:
-        pass  # TODO: implement lab state sync via events
+    def _handle_lab_state(self, event: Event) -> None:
+        """
+        Handle lab state events.
 
-    def _parse_element(self, event: Event) -> None:
-        if event.subtype == "created":
-            self._parse_element_created(event)
+        :param event: An Event object representing the lab state event.
+        """
+        pass
+
+    def _handle_element(self, event: Event) -> None:
+        """
+        Handle lab element events.
+
+        :param event: An Event object representing the lab element event.
+        """
+        if event.element_type in ("annotation", "connectormapping"):
+            # These are not used in this client library
+            _LOGGER.debug(f"Received an unused element type: {event.data}")
+        elif event.subtype == "created":
+            self._handle_element_created(event)
         elif event.subtype == "modified":
-            self._parse_element_modified(event)
+            self._handle_element_modified(event)
         elif event.subtype == "deleted":
-            self._parse_element_deleted(event)
+            self._handle_element_deleted(event)
         else:
-            _LOGGER.warning("Received an invalid element event (%s)", event.subtype)
+            # There are only three subtypes, anything else is invalid
+            # ("state" is under type "state_change", not "lab_element_event")
+            _LOGGER.warning(f"Received an invalid event. {event}")
 
     @abstractmethod
-    def _parse_element_created(self, event: Event) -> None:
+    def _handle_element_created(self, event: Event) -> None:
+        """
+        Handle element created events.
+
+        :param event: An Event object representing the element created event.
+        """
         pass
 
     @abstractmethod
-    def _parse_element_modified(self, event: Event) -> None:
+    def _handle_element_modified(self, event: Event) -> None:
+        """
+        Handle element modified events.
+
+        :param event: An Event object representing the element modified event.
+        """
         pass
 
     @abstractmethod
-    def _parse_element_deleted(self, event: Event) -> None:
+    def _handle_element_deleted(self, event: Event) -> None:
+        """
+        Handle element deleted events.
+
+        :param event: An Event object representing the element deleted event.
+        """
         pass
 
     @abstractmethod
-    def _parse_state_change(self, event: Event) -> None:
+    def _handle_state_change(self, event: Event) -> None:
+        """
+        Handle state change events.
+
+        :param event: An Event object representing the state change event.
+        """
         pass
 
-    def _parse_other(self, event: Event) -> None:
+    def _handle_other(self, event: Event) -> None:
+        """
+        Handle other events.
+
+        :param event: An Event object representing the other event.
+        """
         # All other events are useless to the client, but in case some handling
         # needs to be done on them, this method can be overridden
         pass
 
 
 class EventHandler(EventHandlerBase):
-    """Handler for JSON events received from controller over websockets.
+    """
+    Handler for JSON events received from controller over websockets.
     Used by EventListener by default, but can be subclassed and methods overridden
-    to change/extend the handling mechanism, then passed to EventListener."""
+    to change/extend the handling mechanism, then passed to EventListener.
+    """
 
-    def parse_event(self, event: Event) -> None:
-        if event.type in ("lab_stats", "system_stats"):
+    def handle_event(self, event: Event) -> None:
+        if event.type in ("lab_stats", "system_stats") or (
+            event.element_type in ("annotation", "connectormapping")
+        ):
+            # Some events are unused in the client library
+            _LOGGER.debug(f"Received an unused event: {event}")
             return
 
         try:
-            event.lab = self.client_library.get_local_lab(event.lab_id)
+            event.lab = self._client_library.get_local_lab(event.lab_id)
         except LabNotFound:
             # lab is not locally joined, so we can ignore its events
             return
 
         if event.subtype != "created":
+            event_types = {
+                "node": event.lab.get_node_by_id,
+                "interface": event.lab.get_interface_by_id,
+                "link": event.lab.get_link_by_id,
+            }
             try:
-                if event.element_type == "node":
-                    event.element = event.lab.get_node_by_id(event.element_id)
-                if event.element_type == "interface":
-                    event.element = event.lab.get_interface_by_id(event.element_id)
-                if event.element_type == "link":
-                    event.element = event.lab.get_link_by_id(event.element_id)
+                event.element = event_types[event.element_type](event.element_id)
             except ElementNotFound:
-                return
-                # This should really raise the following error, but the order
-                # in which the events arrive is inconsistent, so sometimes
-                # when e.g. a node is deleted, the node deletion event
-                # arrives before the interface deletion events, so the interfaces
-                # no longer exist by the time their deletion events arrive,
-                # which would raise an unwarranted DesynchronizedError
-                # so instead for now, we just ignore those events
-                # TODO: implement serverside echo prevention and consistent message
-                #  order for all events and raise the following error
-                # raise DesynchronizedError(
-                #     "{} {} not found in lab {}, "
-                #     "likely due to desynchronization".format(
-                #         event.element_type,
-                #         event.element_id,
-                #         event.lab_id,
-                #     )
-                # )
-
-        super().parse_event(event)
-
-    def _parse_lab_created(self, event: Event) -> None:
+                if event.subtype == "deleted":
+                    # Element was likely already deleted in a cascading deletion
+                    # (e.g. node being deleted and all its links and interfaces being
+                    # deleted with it) so the event is useless
+                    return
+                else:
+                    # A modify event arrived for a missing element - something is wrong
+                    raise
+
+        super().handle_event(event)
+
+    def _handle_lab_created(self, event: Event) -> None:
         # we don't care about labs the user hasn't joined,
         # so we don't need the lab creation event
         pass
 
-    def _parse_lab_modified(self, event: Event) -> None:
+    def _handle_lab_modified(self, event: Event) -> None:
         event.lab.update_lab_properties(event.data)
 
-    def _parse_lab_deleted(self, event: Event) -> None:
-        self.client_library._remove_lab_local(event.lab)
+    def _handle_lab_deleted(self, event: Event) -> None:
+        self._client_library._remove_lab_local(event.lab)
 
-    def _parse_lab_state(self, event: Event) -> None:
+    def _handle_lab_state(self, event: Event) -> None:
         event.lab._state = event.data["state"]
 
-    def _parse_element_created(self, event: Event) -> None:
+    def _handle_element_created(self, event: Event) -> None:
         new_element: Node | Interface | Link
         existing_elements: dict = getattr(event.lab, f"_{event.element_type}s", {})
         if event.element_id in existing_elements:
@@ -183,7 +277,7 @@ def _parse_element_created(self, event: Event) -> None:
             # but the event might at least contain some new data
             # that was added during serverside creation
             event.element = existing_elements[event.element_id]
-            self._parse_element_modified(event)
+            self._handle_element_modified(event)
             return
         if event.element_type == "node":
             new_element = event.lab._import_node(
@@ -203,13 +297,17 @@ def _parse_element_created(self, event: Event) -> None:
                 event.data["interface_b"],
             )
         else:
-            _LOGGER.warning("Received an invalid element type (%s)", event.element_type)
+            # "Annotation" and "ConnectorMapping" were weeded out before,
+            # so we should never get here
+            _LOGGER.warning(f"Received an invalid event. {event}")
             return
         new_element._state = event.data.get("state")
 
-    def _parse_element_modified(self, event: Event) -> None:
+    def _handle_element_modified(self, event: Event) -> None:
         if event.element_type == "node":
-            event.element.update(event.data, exclude_configurations=False)
+            event.element.update(
+                event.data, exclude_configurations=False, push_to_server=False
+            )
 
         elif event.element_type == "interface":
             # it seems only port change info arrives here,
@@ -222,13 +320,11 @@ def _parse_element_modified(self, event: Event) -> None:
             pass
 
         else:
-            _LOGGER.warning(
-                "Received an invalid lab element event (%s %s)",
-                event.subtype,
-                event.element_type,
-            )
+            # "Annotation" and "ConnectorMapping" were weeded out before,
+            # so we should never get here
+            _LOGGER.warning(f"Received an invalid event. {event}")
 
-    def _parse_element_deleted(self, event: Event) -> None:
+    def _handle_element_deleted(self, event: Event) -> None:
         if event.element_type == "node":
             event.lab._remove_node_local(event.element)
 
@@ -239,11 +335,9 @@ def _parse_element_deleted(self, event: Event) -> None:
             event.lab._remove_link_local(event.element)
 
         else:
-            _LOGGER.warning(
-                "Received an invalid lab element event (%s %s)",
-                event.subtype,
-                event.element_type,
-            )
+            # "Annotation" and "ConnectorMapping" were weeded out before,
+            # so we should never get here
+            _LOGGER.warning(f"Received an invalid event. {event}")
 
-    def _parse_state_change(self, event: Event) -> None:
+    def _handle_state_change(self, event: Event) -> None:
         event.element._state = event.subtype_original
diff --git a/virl2_client/event_listening.py b/virl2_client/event_listening.py
index dd7f7c8..ce9ce25 100644
--- a/virl2_client/event_listening.py
+++ b/virl2_client/event_listening.py
@@ -1,3 +1,23 @@
+#
+# This file is part of VIRL 2
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
+# All rights reserved.
+#
+# Python bindings for the Cisco VIRL 2 Network Simulation Platform
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
 from __future__ import annotations
 
 import asyncio
@@ -6,7 +26,7 @@
 import ssl
 import threading
 from pathlib import Path
-from typing import TYPE_CHECKING, Coroutine, Optional
+from typing import TYPE_CHECKING, Coroutine
 from urllib.parse import urlparse
 
 import aiohttp
@@ -22,25 +42,26 @@
 class EventListener:
     def __init__(self, client_library: ClientLibrary):
         """
-        Initializes a EventListener instance.
+        Initialize an EventListener instance.
         EventListener creates and listens to a websocket connection to the server.
-        Events are then sent to the EventHandler instance for handling
+        Events are then sent to the EventHandler instance for handling.
         Use start_listening() to open and stop_listening() to close connection.
 
-        :param client_library: parent ClientLibrary instance which provides connection
+        :param client_library: Parent ClientLibrary instance which provides connection
             info and is modified when synchronizing.
         """
-        self._thread: Optional[threading.Thread] = None
-        self._ws_close: Optional[Coroutine] = None
-        self._ws_close_event: Optional[asyncio.Event] = None
-        self._ws_connected_event: Optional[threading.Event] = None
+        self._thread: threading.Thread | None = None
+        self._ws_close: Coroutine | None = None
+        self._ws_close_event: asyncio.Event | None = None
+        self._ws_connected_event: threading.Event | None = None
         self._synchronizing = False
 
         self._listening = False
         self._connected = False
-        self._queue: Optional[asyncio.Queue] = None
-        self._ws_url: Optional[str] = None
-        self._ssl_context: Optional[ssl.SSLContext] = None
+        self._auth_data = None
+        self._queue: asyncio.Queue | None = None
+        self._ws_url: str | None = None
+        self._ssl_context: ssl.SSLContext | None = None
 
         self._event_handler = EventHandler(client_library)
         self._init_ws_connection_data(client_library)
@@ -49,10 +70,10 @@ def __bool__(self):
         return self._listening
 
     def _init_ws_connection_data(self, client_library: ClientLibrary) -> None:
-        """Creates an SSL context and Websocket url from client library data.
+        """
+        Create an SSL context and Websocket url from client library data.
 
-        :param client_library: the client library instance
-        :returns: a tuple of url and context
+        :param client_library: The client library instance.
         """
         ssl_verify = client_library._ssl_verify
         # Create an SSL context based on the 'verify' str/bool,
@@ -75,11 +96,12 @@ def _init_ws_connection_data(self, client_library: ClientLibrary) -> None:
         self._ws_url = str(ws_url_pieces.geturl())
 
         self._auth_data = {
-            "token": client_library.session.auth.token,
+            "token": client_library._session.auth.token,
             "client_uuid": client_library.uuid,
         }
 
     def start_listening(self):
+        """Start listening for events."""
         if self._listening:
             return
 
@@ -92,6 +114,7 @@ def start_listening(self):
         self._thread.start()
 
     def stop_listening(self):
+        """Stop listening for events."""
         if not self._listening:
             return
 
@@ -132,7 +155,7 @@ async def _parse(self):
                     await self._ws_close
                 return
             event = Event(json.loads(queue_get.result()))
-            self._event_handler.parse_event(event)
+            self._event_handler.handle_event(event)
             self._queue.task_done()
 
     async def _ws_client(self):
diff --git a/virl2_client/exceptions.py b/virl2_client/exceptions.py
index 0d87291..daab7ee 100644
--- a/virl2_client/exceptions.py
+++ b/virl2_client/exceptions.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -35,6 +35,10 @@ class ElementNotFound(VirlException, KeyError):
     pass
 
 
+class AnnotationNotFound(ElementNotFound):
+    pass
+
+
 class NodeNotFound(ElementNotFound):
     pass
 
@@ -63,9 +67,33 @@ class InvalidImageFile(VirlException):
     pass
 
 
+class InvalidAnnotationType(VirlException):
+    pass
+
+
 class InvalidProperty(VirlException):
     pass
 
 
 class MethodNotActive(VirlException):
     pass
+
+
+class PyatsException(Exception):
+    pass
+
+
+class PyatsNotInstalled(PyatsException):
+    pass
+
+
+class PyatsDeviceNotFound(PyatsException):
+    pass
+
+
+class InvalidMacAddressBlock(VirlException):
+    message = "MAC address block has to be in range 0-7"
+
+
+class ControllerNotFound(VirlException):
+    message = "Controller not found"
diff --git a/virl2_client/models/__init__.py b/virl2_client/models/__init__.py
index 2ae8ca7..b62d50c 100644
--- a/virl2_client/models/__init__.py
+++ b/virl2_client/models/__init__.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
diff --git a/virl2_client/models/annotation.py b/virl2_client/models/annotation.py
new file mode 100644
index 0000000..4fa5de8
--- /dev/null
+++ b/virl2_client/models/annotation.py
@@ -0,0 +1,708 @@
+#
+# This file is part of VIRL 2
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
+# All rights reserved.
+#
+# Python bindings for the Cisco VIRL 2 Network Simulation Platform
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Any, Literal
+
+from ..exceptions import InvalidProperty
+from ..utils import check_stale, get_url_from_template, locked
+from ..utils import property_s as property
+
+if TYPE_CHECKING:
+    import httpx
+
+    from .lab import Lab
+
+    AnnotationTypeString = Literal["text", "line", "ellipse", "rectangle"]
+    AnnotationType = (
+        "Annotation"
+        | "AnnotationRectangle"
+        | "AnnotationEllipse"
+        | "AnnotationLine"
+        | "AnnotationText"
+    )
+
+_LOGGER = logging.getLogger(__name__)
+
+# map properties to the annotation types by using binary flags array
+# ---X: rectangle
+# --X-: ellipse
+# -X--: line
+# X---: text
+ANNOTATION_PROPERTY_MAP = {
+    "border_color": 0b1111,
+    "border_radius": 0b0001,
+    "border_style": 0b1111,
+    "color": 0b1111,
+    "line_end": 0b0100,
+    "line_start": 0b0100,
+    "rotation": 0b1000,
+    "text_bold": 0b1000,
+    "text_content": 0b1000,
+    "text_font": 0b1000,
+    "text_italic": 0b1000,
+    "text_size": 0b1000,
+    "text_unit": 0b1000,
+    "thickness": 0b1111,
+    "type": 0b1111,
+    "x1": 0b1111,
+    "x2": 0b0111,
+    "y1": 0b1111,
+    "y2": 0b0111,
+    "z_index": 0b1111,
+}
+
+ANNOTATION_PROPERTIES_DEFAULTS = {
+    "border_color": {
+        "rectangle": "#808080FF",
+        "ellipse": "#808080FF",
+        "line": "#808080FF",
+        "text": "#00000000",
+    },
+    "border_radius": 0,
+    "border_style": "",
+    "color": {
+        "rectangle": "#FFFFFFFF",
+        "ellipse": "#FFFFFFFF",
+        "line": "#FFFFFFFF",
+        "text": "#808080FF",
+    },
+    "line_end": None,
+    "line_start": None,
+    "rotation": 0,
+    "text_bold": False,
+    "text_content": "text annotation",
+    "text_font": "monospace",
+    "text_italic": False,
+    "text_size": 12,
+    "text_unit": "pt",
+    "thickness": 1,
+    "x1": 0,
+    "x2": 100,
+    "y1": 0,
+    "y2": 100,
+    "z_index": 0,
+}
+
+_ANNOTATION_TYPES = ["text", "line", "ellipse", "rectangle"]
+
+
+class Annotation:
+    _URL_TEMPLATES = {
+        "annotations": "labs/{lab_id}/annotations",
+        "annotation": "labs/{lab_id}/annotations/{annotation_id}",
+    }
+
+    def __init__(
+        self,
+        lab: Lab,
+        annotation_id: str,
+        annotation_type: AnnotationTypeString,
+    ) -> None:
+        """
+        A VIRL2 lab annotation.
+
+        :param lab: The lab object to which the link belongs.
+        :param annotation_id: The ID of the annotation.
+        :param annotation_type: annotation type (text, line, ellipse, rectangle)
+        """
+        self._id = annotation_id
+        self._lab = lab
+        self._session: httpx.Client = lab._session
+        # When the annotationis removed on the server, this annotation object is marked
+        # stale and can no longer be interacted with - the user should discard it
+        self._stale = False
+
+        # set properties required by all annotations
+        # values set to 'None' have type-specific default values
+        self._border_color = None
+        self._border_style = ""
+        self._color = None
+        self._thickness = 1
+        self._type = annotation_type
+        self._x1 = 0
+        self._y1 = 0
+        self._z_index = 0
+
+    def __str__(self):
+        return (
+            f"{self.__class__.__name__}: {self._id}{' (STALE)' if self._stale else ''}"
+        )
+
+    def __repr__(self):
+        return "{}({!r}, {!r}, {!r})".format(
+            self.__class__.__name__,
+            str(self._lab),
+            self._id,
+            self._type,
+        )
+
+    def __eq__(self, other: object):
+        if not isinstance(other, Annotation):
+            return False
+        return self._id == other._id
+
+    def __hash__(self):
+        return hash(self._id)
+
+    @property
+    def id(self) -> str:
+        """Return ID of the annotation."""
+        return self._id
+
+    @property
+    def border_color(self) -> str:
+        """Border color (example: `#FF00FF00`)."""
+        self._lab.sync_topology_if_outdated()
+        return self._border_color
+
+    @border_color.setter
+    @locked
+    def border_color(self, value: str) -> None:
+        """Set border color (example: `#FF00FF00`)."""
+        self._set_annotation_property("border_color", value)
+        self._border_color = value
+
+    @property
+    def border_style(self) -> str:
+        self._lab.sync_topology_if_outdated()
+        return self._border_style
+
+    @border_style.setter
+    @locked
+    def border_style(self, value: str) -> None:
+        """
+        Set border style; valid values: '' (solid), '2,2' (dotted), '4,2' (dashed).
+        """
+        self._set_annotation_property("border_style", value)
+        self._border_style = value
+
+    @property
+    def color(self) -> str:
+        """Annotation color (example: `#00AAFF`)."""
+        self._lab.sync_topology_if_outdated()
+        return self._color
+
+    @color.setter
+    @locked
+    def color(self, value: str) -> None:
+        """Set annotation color (example: `#00AAFF`)."""
+        self._set_annotation_property("color", value)
+        self._color = value
+
+    @property
+    def thickness(self) -> int:
+        """Annotation border thickness."""
+        self._lab.sync_topology_if_outdated()
+        return self._thickness
+
+    @thickness.setter
+    @locked
+    def thickness(self, value: int) -> None:
+        """Set annotation border thickness."""
+        self._set_annotation_property("thickness", value)
+        self._thickness = value
+
+    @property
+    def type(self) -> str:
+        """Return type of the annotation."""
+        return self._type
+
+    @property
+    def x1(self) -> int:
+        """X1 coordinate."""
+        self._lab.sync_topology_if_outdated()
+        return self._x1
+
+    @x1.setter
+    @locked
+    def x1(self, value: int) -> None:
+        """Set x1 coordinate."""
+        self._set_annotation_property("x1", value)
+        self._x1 = value
+
+    @property
+    def y1(self) -> int:
+        """Y1 coordinate."""
+        self._lab.sync_topology_if_outdated()
+        return self._y1
+
+    @y1.setter
+    @locked
+    def y1(self, value: int) -> None:
+        """Set y1 coordinate."""
+        self._set_annotation_property("y1", value)
+        self._y1 = value
+
+    @property
+    def z_index(self) -> int:
+        """Z layer (depth) of an annotation."""
+        self._lab.sync_topology_if_outdated()
+        return self._z_index
+
+    @z_index.setter
+    @locked
+    def z_index(self, value: int) -> None:
+        """Set Z layer (depth) of an annotation."""
+        self._set_annotation_property("z_index", value)
+        self._z_index = value
+
+    def _url_for(self, endpoint: str, **kwargs) -> str:
+        """
+        Generate the URL for a given API endpoint.
+
+        :param endpoint: The desired endpoint.
+        :param **kwargs: Keyword arguments used to format the URL.
+        :returns: The formatted URL.
+        """
+        kwargs["lab_id"] = self._lab._id
+        kwargs["annotation_id"] = self._id
+        return get_url_from_template(endpoint, self._URL_TEMPLATES, kwargs)
+
+    @classmethod
+    def get_default_property_values(cls, annotation_type: str) -> dict[str, Any]:
+        """
+        Return a list of all valid properties set to default values for the selected
+        annotation type.
+        """
+        default_values = {}
+        annotation_map = {
+            "text": 0b1000,
+            "line": 0b0100,
+            "ellipse": 0b0010,
+            "rectangle": 0b0001,
+        }
+        for ppty in ANNOTATION_PROPERTY_MAP:
+            if ppty == "type":
+                continue
+            if not annotation_map[annotation_type] & ANNOTATION_PROPERTY_MAP[ppty]:
+                continue
+            ppty_default = ANNOTATION_PROPERTIES_DEFAULTS[ppty]
+            if isinstance(ppty_default, dict):
+                ppty_default = ppty_default[annotation_type]
+            default_values[ppty] = ppty_default
+
+        return default_values
+
+    @classmethod
+    def is_valid_property(
+        cls,
+        annotation_type: AnnotationTypeString,
+        _property: str,
+    ) -> bool:
+        """Check if the given property is recognized by the selected annotation type."""
+        try:
+            assert annotation_type in _ANNOTATION_TYPES
+            assert _property in ANNOTATION_PROPERTY_MAP
+        except AssertionError:
+            return False
+        annotation_map = {
+            "text": 0b1000,
+            "line": 0b0100,
+            "ellipse": 0b0010,
+            "rectangle": 0b0001,
+        }
+        return annotation_map[annotation_type] & ANNOTATION_PROPERTY_MAP[_property] > 0
+
+    @locked
+    def as_dict(self) -> dict[str, Any]:
+        """
+        Convert the annotation object to a dictionary representation.
+
+        :returns: A dictionary representation of the annotation object.
+        """
+        return {
+            "id": self._id,
+            **{
+                ppty: getattr(self, ppty)
+                for ppty in ANNOTATION_PROPERTY_MAP
+                if Annotation.is_valid_property(self._type, ppty)
+            },
+        }
+
+    def remove(self) -> None:
+        """Remove annotation from the lab."""
+        self._lab.remove_annotation(self)
+
+    @check_stale
+    def _remove_on_server(self) -> None:
+        """Remove annotation on the server side."""
+        _LOGGER.info(f"Removing annotation {self}")
+        url = self._url_for("annotation")
+        self._session.delete(url)
+
+    @check_stale
+    @locked
+    def update(
+        self, annotation_data: dict[str, Any], push_to_server: bool = True
+    ) -> None:
+        """
+        Update annotation properties.
+
+        :param annotation_data: JSON dict with new annotation property:value pairs.
+        :param push_to_server: Whether to push the changes to the server.
+            Defaults to True; should only be False when used by internal methods.
+        """
+        if "type" not in annotation_data:
+            annotation_data["type"] = self._type
+        elif annotation_data["type"] != self._type:
+            raise ValueError("Can't update annotation type.")
+
+        # make sure all properties we want to update are valid
+        for key, value in annotation_data.items():
+            if key not in dir(self):
+                raise InvalidProperty(f"Invalid annotation property: {key}")
+
+        if push_to_server:
+            self._set_annotation_properties(annotation_data)
+
+        # update locally
+        for key, value in annotation_data.items():
+            if key == "id":
+                continue
+            setattr(self, f"_{key}", value)
+
+    def _set_annotation_property(self, key: str, val: Any) -> None:
+        """
+        Set a property of the annotation.
+
+        :param key: The name of the property to set.
+        :param val: The value to set.
+        """
+        _LOGGER.debug(f"Setting annotation property {self} {key}: {val}")
+        self._set_annotation_properties({key: val})
+
+    @check_stale
+    def _set_annotation_properties(self, annotation_data: dict[str, Any]) -> None:
+        """Update annotation properties server-side."""
+        if "type" not in annotation_data:
+            annotation_data["type"] = self._type
+        self._session.patch(url=self._url_for("annotation"), json=annotation_data)
+
+
+# ~~~~~< Annotation subclasses >~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+
+class AnnotationRectangle(Annotation):
+    """
+    Annotation class representing rectangle annotation.
+    """
+
+    def __init__(
+        self,
+        lab: Lab,
+        annotation_id: str,
+        annotation_data: dict[str, Any] | None = None,
+    ):
+        super().__init__(lab, annotation_id, "rectangle")
+
+        # default values
+        self._border_color = "#808080FF"
+        self._border_radius = 0
+        self._color = "#FFFFFFFF"
+        self._x2 = 100
+        self._y2 = 100
+        if annotation_data:
+            self.update(annotation_data, push_to_server=False)
+
+    @property
+    def border_radius(self) -> int:
+        """Border radius."""
+        self._lab.sync_topology_if_outdated()
+        return self._border_radius
+
+    @border_radius.setter
+    @locked
+    def border_radius(self, value: int) -> None:
+        """Set border radius."""
+        self._set_annotation_property("border_radius", value)
+        self._border_radius = value
+
+    @property
+    def x2(self) -> int:
+        """X2 coordinate."""
+        self._lab.sync_topology_if_outdated()
+        return self._x2
+
+    @x2.setter
+    @locked
+    def x2(self, value: int) -> None:
+        """Set x2 coordinate."""
+        self._set_annotation_property("x2", value)
+        self._x2 = value
+
+    @property
+    def y2(self) -> int:
+        """Y2 coordinate."""
+        self._lab.sync_topology_if_outdated()
+        return self._y2
+
+    @y2.setter
+    @locked
+    def y2(self, value: int) -> None:
+        """Set y2 coordinate."""
+        self._set_annotation_property("y2", value)
+        self._y2 = value
+
+
+class AnnotationEllipse(Annotation):
+    """
+    Annotation class representing ellipse annotation.
+    """
+
+    def __init__(
+        self,
+        lab: Lab,
+        annotation_id: str,
+        annotation_data: dict[str, Any] | None = None,
+    ):
+        super().__init__(lab, annotation_id, "ellipse")
+
+        # default values
+        self._border_color = "#808080FF"
+        self._color = "#FFFFFFFF"
+        self._x2 = 100
+        self._y2 = 100
+        if annotation_data:
+            self.update(annotation_data, push_to_server=False)
+
+    @property
+    def x2(self) -> int:
+        """X2 coordinate."""
+        self._lab.sync_topology_if_outdated()
+        return self._x2
+
+    @x2.setter
+    @locked
+    def x2(self, value: int) -> None:
+        """Set x2 coordinate."""
+        self._set_annotation_property("x2", value)
+        self._x2 = value
+
+    @property
+    def y2(self) -> int:
+        """Y2 coordinate."""
+        self._lab.sync_topology_if_outdated()
+        return self._y2
+
+    @y2.setter
+    @locked
+    def y2(self, value: int) -> None:
+        """Set y2 coordinate."""
+        self._set_annotation_property("y2", value)
+        self._y2 = value
+
+
+class AnnotationLine(Annotation):
+    """
+    Annotation class representing line annotation.
+    """
+
+    def __init__(
+        self,
+        lab: Lab,
+        annotation_id: str,
+        annotation_data: dict[str, Any] | None = None,
+    ):
+        super().__init__(lab, annotation_id, "line")
+
+        # default values
+        self._border_color = "#808080FF"
+        self._color = "#FFFFFFFF"
+        self._x2 = 100
+        self._y2 = 100
+        self._line_start = None
+        self._line_end = None
+        if annotation_data:
+            self.update(annotation_data, push_to_server=False)
+
+    @property
+    def x2(self) -> int:
+        """X2 coordinate."""
+        self._lab.sync_topology_if_outdated()
+        return self._x2
+
+    @x2.setter
+    @locked
+    def x2(self, value: int) -> None:
+        """Set x2 coordinate."""
+        self._set_annotation_property("x2", value)
+        self._x2 = value
+
+    @property
+    def y2(self) -> int:
+        """Y2 coordinate."""
+        self._lab.sync_topology_if_outdated()
+        return self._y2
+
+    @y2.setter
+    @locked
+    def y2(self, value: int) -> None:
+        """Set y2 coordinate."""
+        self._set_annotation_property("y2", value)
+        self._y2 = value
+
+    @property
+    def line_start(self) -> str | None:
+        """Line arrow start style."""
+        self._lab.sync_topology_if_outdated()
+        return self._line_start
+
+    @line_start.setter
+    @locked
+    def line_start(self, value: str | None) -> None:
+        """Set line arrow start style: (arrow, square, circle)."""
+        self._set_annotation_property("line_start", value)
+        self._line_start = value
+
+    @property
+    def line_end(self) -> str | None:
+        """Line arrow end style."""
+        self._lab.sync_topology_if_outdated()
+        return self._line_end
+
+    @line_end.setter
+    @locked
+    def line_end(self, value: str | None) -> None:
+        """Set line arrow end style: (arrow, square, circle)."""
+        self._set_annotation_property("line_end", value)
+        self._line_end = value
+
+
+class AnnotationText(Annotation):
+    """
+    Annotation class representing text annotation.
+    """
+
+    def __init__(
+        self,
+        lab: Lab,
+        annotation_id: str,
+        annotation_data: dict[str, Any] | None = None,
+    ):
+        super().__init__(lab, annotation_id, "text")
+
+        # default values
+        self._border_color = "#00000000"
+        self._color = "#808080FF"
+        self._x2 = 100
+        self._y2 = 100
+        self._rotation = 0
+        self._text_bold = False
+        self._text_content = "text annotation"
+        self._text_font = "monospace"
+        self._text_italic = False
+        self._text_size = 12
+        self._text_unit = "pt"
+        if annotation_data:
+            self.update(annotation_data, push_to_server=False)
+
+    @property
+    def rotation(self) -> int:
+        """Rotation of an object, in degrees."""
+        self._lab.sync_topology_if_outdated()
+        return self._rotation
+
+    @rotation.setter
+    @locked
+    def rotation(self, value: int) -> None:
+        """Set rotation of an object, in degrees."""
+        self._set_annotation_property("rotation", value)
+        self._rotation = value
+
+    @property
+    def text_bold(self) -> bool:
+        """Text boldness."""
+        self._lab.sync_topology_if_outdated()
+        return self._text_bold
+
+    @text_bold.setter
+    @locked
+    def text_bold(self, value: bool) -> None:
+        """Set text boldness (bool)."""
+        self._set_annotation_property("text_bold", value)
+        self._text_bold = value
+
+    @property
+    def text_content(self) -> str:
+        """Text annotation content."""
+        self._lab.sync_topology_if_outdated()
+        return self._text_content
+
+    @text_content.setter
+    @locked
+    def text_content(self, value: str) -> None:
+        """Set text annotation content."""
+        self._set_annotation_property("text_content", value)
+        self._text_content = value
+
+    @property
+    def text_font(self) -> str:
+        """Text font."""
+        self._lab.sync_topology_if_outdated()
+        return self._text_font
+
+    @text_font.setter
+    @locked
+    def text_font(self, value: str) -> None:
+        """Set text font."""
+        self._set_annotation_property("text_font", value)
+        self._text_font = value
+
+    @property
+    def text_italic(self) -> bool:
+        """Text cursive."""
+        self._lab.sync_topology_if_outdated()
+        return self._text_italic
+
+    @text_italic.setter
+    @locked
+    def text_italic(self, value: bool) -> None:
+        """Set text cursive (bool)."""
+        self._set_annotation_property("text_italic", value)
+        self._text_italic = value
+
+    @property
+    def text_size(self) -> int:
+        """Size of the text."""
+        self._lab.sync_topology_if_outdated()
+        return self._text_size
+
+    @text_size.setter
+    @locked
+    def text_size(self, value: int) -> None:
+        """Set size of the text (various units are recognized)."""
+        self._set_annotation_property("text_size", value)
+        self._text_size = value
+
+    @property
+    def text_unit(self) -> str:
+        """Text size unit."""
+        self._lab.sync_topology_if_outdated()
+        return self._text_unit
+
+    @text_unit.setter
+    @locked
+    def text_unit(self, value: str) -> None:
+        """Set text size unit (pt, px, em, ...)."""
+        self._set_annotation_property("text_unit", value)
+        self._text_unit = value
diff --git a/virl2_client/models/auth_management.py b/virl2_client/models/auth_management.py
index c565f2c..e0f3250 100644
--- a/virl2_client/models/auth_management.py
+++ b/virl2_client/models/auth_management.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -21,28 +21,29 @@
 from __future__ import annotations
 
 import time
-from typing import TYPE_CHECKING, Any, Optional
+from typing import TYPE_CHECKING, Any
 
 from ..exceptions import MethodNotActive
+from ..utils import get_url_from_template
 from .resource_pools import ResourcePool
 
 if TYPE_CHECKING:
     from httpx import Client
 
 
-_BASE_URL = "system/auth"
-_CONFIG_URL = _BASE_URL + "/config"
-_TEST_URL = _BASE_URL + "/test"
-
-
 class AuthManagement:
+    _URL_TEMPLATES = {
+        "config": "system/auth/config",
+        "test": "system/auth/test",
+    }
+
     def __init__(self, session: Client, auto_sync=True, auto_sync_interval=1.0):
         """
-        Sync and modify authentication settings.
+        Manage authentication settings and synchronization.
 
-        :param session: parent client's httpx.Client object
-        :param auto_sync: whether to automatically synchronize resource pools
-        :param auto_sync_interval: how often to synchronize resource pools in seconds
+        :param session: The httpx-based HTTP client for this session with the server.
+        :param auto_sync: Whether to automatically synchronize resource pools.
+        :param auto_sync_interval: How often to synchronize resource pools in seconds.
         """
         self.auto_sync = auto_sync
         self.auto_sync_interval = auto_sync_interval
@@ -54,7 +55,21 @@ def __init__(self, session: Client, auto_sync=True, auto_sync_interval=1.0):
             "ldap": LDAPManager(self),
         }
 
+    def _url_for(self, endpoint, **kwargs):
+        """
+        Generate the URL for a given API endpoint.
+
+        :param endpoint: The desired endpoint.
+        :param **kwargs: Keyword arguments used to format the URL.
+        :returns: The formatted URL.
+        """
+        return get_url_from_template(endpoint, self._URL_TEMPLATES, kwargs)
+
     def sync_if_outdated(self) -> None:
+        """
+        Synchronize local data with the server if the auto sync interval
+        has elapsed since the last synchronization.
+        """
         timestamp = time.time()
         if (
             self.auto_sync
@@ -63,60 +78,71 @@ def sync_if_outdated(self) -> None:
             self.sync()
 
     def sync(self) -> None:
-        self._settings = self._session.get(_CONFIG_URL).json()
+        """Synchronize the authentication settings with the server."""
+        url = self._url_for("config")
+        self._settings = self._session.get(url).json()
         self._last_sync_time = time.time()
 
     @property
     def method(self) -> str:
-        """Current authentication method."""
+        """Return the current authentication method."""
         self.sync_if_outdated()
         return self._settings["method"]
 
     @property
     def manager(self) -> AuthMethodManager:
-        """
-        The property manager for the current authentication method.
-        """
+        """Return the property manager for the current authentication method."""
         self.sync_if_outdated()
         return self._managers[self._settings["method"]]
 
     def _get_current_settings(self) -> dict:
-        return self._session.get(_CONFIG_URL).json()
+        """
+        Get the current authentication settings.
+
+        :returns: The current authentication settings.
+        """
+        url = self._url_for("config")
+        return self._session.get(url).json()
 
     def _get_setting(self, setting: str) -> Any:
         """
-        Returns the value of a specific setting of the current authentication method.
+        Get the value of a specific setting of the current authentication method.
 
-        Note: consider using parameters instead.
+        :param setting: The setting to retrieve.
+        :returns: The value of the specified setting.
         """
         self.sync_if_outdated()
         return self._settings[setting]
 
     def get_settings(self) -> dict:
-        """Returns a dictionary of the settings of the current authentication method."""
+        """
+        Get a dictionary of the settings of the current authentication method.
+
+        :returns: A dictionary of the settings of the current authentication method.
+        """
         self.sync_if_outdated()
         return self._settings.copy()
 
     def _update_setting(self, setting: str, value: Any) -> None:
         """
-        Update a setting for the current auth method.
+        Update a setting for the current authentication method.
 
-        :param setting: the setting to update
-        :param value: the value to set the setting to
+        :param setting: The setting to update.
+        :param value: The value to set the setting to.
         """
-
-        self._session.put(
-            _CONFIG_URL,
-            json={setting: value, "method": self._settings["method"]},
-        )
+        url = self._url_for("config")
+        settings = {setting: value, "method": self._settings["method"]}
+        self._session.put(url, json=settings)
         if setting in self._settings:
             self._settings[setting] = value
 
-    def update_settings(self, settings_dict: Optional[dict] = None, **kwargs) -> None:
+    def update_settings(self, settings_dict: dict | None = None, **kwargs) -> None:
         """
-        Update multiple auth settings at once.
-        More efficient in batches than setting manager properties.
-        If passed a dictionary, reads the dictionary for settings.
+        Update multiple authentication settings at once.
+
+        This method is more efficient in batches than setting manager properties.
+
+        If passed a dictionary, it reads the dictionary for settings.
         If passed multiple keyword arguments, each is taken as a setting.
         Can mix and match; keywords take precedence.
 
@@ -128,8 +154,8 @@ def update_settings(self, settings_dict: Optional[dict] = None, **kwargs) -> Non
             # "verify_tls" key in dictionary overridden by keyword argument into False
             am.update_settings({"method": "ldap", "verify_tls": True}, verify_tls=False)
 
-        :param settings_dict: a dictionary of settings
-        :param kwargs: keywords of settings
+        :param settings_dict: A dictionary of settings.
+        :param kwargs: Setting keywords.
         """
         settings = {}
         if settings_dict:
@@ -137,37 +163,39 @@ def update_settings(self, settings_dict: Optional[dict] = None, **kwargs) -> Non
         settings.update(kwargs)
         if not settings:
             raise TypeError("No settings to update.")
-        self._session.put(_CONFIG_URL, json=settings)
+        url = self._url_for("config")
+        self._session.put(url, json=settings)
         self.sync()
 
     def test_auth(self, config: dict, username: str, password: str) -> dict:
         """
-        Tests a set of credentials against the specified authentication configuration.
+        Test a set of credentials against the specified authentication configuration.
 
-        :param config: a dictionary of authentication settings to test against
-            (including manager password)
-        :param username: the username to test
-        :param password: the password to test
-        :returns: results of the test
+        :param config: A dictionary of authentication settings to test against
+            (including manager password).
+        :param username: The username to test.
+        :param password: The password to test.
+        :returns: Results of the test.
         """
         body = {
             "auth-config": config,
             "auth-data": {"username": username, "password": password},
         }
-        response = self._session.post(_TEST_URL, json=body)
+        url = self._url_for("test")
+        response = self._session.post(url, json=body)
         return response.json()
 
     def test_current_auth(
         self, manager_password: str, username: str, password: str
     ) -> dict:
         """
-        Tests a set of credentials against the currently applied authentication
+        Test a set of credentials against the currently applied authentication
         configuration.
 
-        :param manager_password: the auth manager password to allow testing
-        :param username: the username to test
-        :param password: the password to test
-        :returns: results of the test
+        :param manager_password: The manager password to allow testing.
+        :param username: The username to test.
+        :param password: The password to test.
+        :returns: Results of the test.
         """
         current = self.get_settings()
         current["manager_password"] = manager_password
@@ -175,147 +203,214 @@ def test_current_auth(
             "auth-config": current,
             "auth-data": {"username": username, "password": password},
         }
-        response = self._session.post(_TEST_URL, json=body)
+        url = self._url_for("test")
+        response = self._session.post(url, json=body)
         return response.json()
 
 
 class AuthMethodManager:
+    """
+    Base class for managing authentication methods.
+    Provides a mechanism to retrieve and update settings
+    related to authentication methods.
+    """
+
     METHOD = ""
 
     def __init__(self, auth_management: AuthManagement):
+        """
+        Initialize the AuthMethodManager with an instance of AuthManagement.
+
+        :param auth_management: An instance of AuthManagement.
+        """
+
         self._auth_management = auth_management
 
     def _check_method(self):
-        """This makes sure that storing the manager in a variable won't allow the user
-        to accidentaly set settings of other methods using this."""
+        """
+        Internal method to ensure the current method is active.
+
+        :raises MethodNotActive: If the current method is not active.
+        """
+
         if self._auth_management._get_setting("method") != self.METHOD:
             raise MethodNotActive(f"{self.METHOD} is not the currently active method.")
 
     def _get_setting(self, setting: str) -> Any:
+        """
+        Internal method to get a setting value from the AuthManagement instance.
+
+        :param setting: The name of the setting.
+        :returns: The value of the setting.
+        """
+
         self._check_method()
         return self._auth_management._get_setting(setting)
 
     def _update_setting(self, setting: str, value: Any) -> None:
+        """
+        Internal method to update a setting value in the AuthManagement instance.
+
+        :param setting: The name of the setting.
+        :param value: The new value for the setting.
+        """
+
         self._check_method()
         self._auth_management._update_setting(setting, value)
 
 
 class LDAPManager(AuthMethodManager):
+    """
+    Manages LDAP authentication settings.
+
+    Extends the AuthMethodManager class and provides properties
+    for retrieving and updating LDAP settings.
+    """
+
     METHOD = "ldap"
 
     @property
     def server_urls(self) -> str:
+        """Return the LDAP server URLs."""
         return self._get_setting("server_urls")
 
     @server_urls.setter
     def server_urls(self, value: str) -> None:
+        """Set the LDAP server URLs."""
         self._update_setting("server_urls", value)
 
     @property
     def verify_tls(self) -> bool:
+        """Return the flag indicating whether to verify the TLS certificate."""
         return self._get_setting("verify_tls")
 
     @verify_tls.setter
     def verify_tls(self, value: bool) -> None:
+        """Set the flag indicating whether to verify the TLS certificate."""
         self._update_setting("verify_tls", value)
 
     @property
     def cert_data_pem(self) -> str:
+        """Return the PEM-encoded certificate data."""
         return self._get_setting("cert_data_pem")
 
     @cert_data_pem.setter
     def cert_data_pem(self, value: str) -> None:
+        """Set the PEM-encoded certificate data."""
         self._update_setting("cert_data_pem", value)
 
     @property
     def use_ntlm(self) -> bool:
+        """Return the flag indicating whether to use NTLM authentication."""
         return self._get_setting("use_ntlm")
 
     @use_ntlm.setter
     def use_ntlm(self, value: bool) -> None:
+        """Set the flag indicating whether to use NTLM authentication."""
         self._update_setting("use_ntlm", value)
 
     @property
     def root_dn(self) -> str:
+        """Return the root DN."""
         return self._get_setting("root_dn")
 
     @root_dn.setter
     def root_dn(self, value: str) -> None:
+        """Set the root DN."""
         self._update_setting("root_dn", value)
 
     @property
     def user_search_base(self) -> str:
+        """Return the user search base."""
         return self._get_setting("user_search_base")
 
     @user_search_base.setter
     def user_search_base(self, value: str) -> None:
+        """Set the user search base."""
         self._update_setting("user_search_base", value)
 
     @property
     def user_search_filter(self) -> str:
+        """Return the user search filter."""
         return self._get_setting("user_search_filter")
 
     @user_search_filter.setter
     def user_search_filter(self, value: str) -> None:
+        """Set the user search filter."""
         self._update_setting("user_search_filter", value)
 
     @property
     def admin_search_filter(self) -> str:
+        """Return the admin search filter."""
         return self._get_setting("admin_search_filter")
 
     @admin_search_filter.setter
     def admin_search_filter(self, value: str) -> None:
+        """Set the admin search filter."""
         self._update_setting("admin_search_filter", value)
 
     @property
     def group_search_base(self) -> str:
+        """Return the group search base."""
         return self._get_setting("group_search_base")
 
     @group_search_base.setter
     def group_search_base(self, value: str) -> None:
+        """Set the group search base."""
         self._update_setting("group_search_base", value)
 
     @property
     def group_search_filter(self) -> str:
+        """Return the group search filter."""
         return self._get_setting("group_search_filter")
 
     @group_search_filter.setter
     def group_search_filter(self, value: str) -> None:
+        """Set the group search filter."""
         self._update_setting("group_search_filter", value)
 
     @property
     def group_via_user(self) -> bool:
+        """Return the flag indicating whether to use group via user."""
         return self._get_setting("group_via_user")
 
     @group_via_user.setter
     def group_via_user(self, value: bool) -> None:
+        """Set the flag indicating whether to use group via user."""
         self._update_setting("group_via_user", value)
 
     @property
     def group_user_attribute(self) -> str:
+        """Return the group user attribute."""
         return self._get_setting("group_user_attribute")
 
     @group_user_attribute.setter
     def group_user_attribute(self, value: str) -> None:
+        """Set the group user attribute."""
         self._update_setting("group_user_attribute", value)
 
     @property
     def group_membership_filter(self) -> str:
+        """Return the group membership filter."""
         return self._get_setting("group_membership_filter")
 
     @group_membership_filter.setter
     def group_membership_filter(self, value: str) -> None:
+        """Set the group membership filter."""
         self._update_setting("group_membership_filter", value)
 
     @property
     def manager_dn(self) -> str:
+        """Return the manager DN."""
         return self._get_setting("manager_dn")
 
     @manager_dn.setter
     def manager_dn(self, value: str) -> None:
+        """Set the manager DN."""
         self._update_setting("manager_dn", value)
 
     def manager_password(self, value: str) -> None:
+        """Set the manager password."""
         self._update_setting("display_attribute", value)
 
     # manager password can't be retrieved, only set, so we only provide a setter
@@ -323,26 +418,32 @@ def manager_password(self, value: str) -> None:
 
     @property
     def display_attribute(self) -> str:
+        """Return the display name LDAP attribute."""
         return self._get_setting("display_attribute")
 
     @display_attribute.setter
     def display_attribute(self, value: str) -> None:
+        """Set the display name LDAP attribute."""
         self._update_setting("display_attribute", value)
 
     @property
     def email_address_attribute(self) -> str:
+        """Return the email address LDAP attribute."""
         return self._get_setting("email_address_attribute")
 
     @email_address_attribute.setter
     def email_address_attribute(self, value: str) -> None:
+        """Set the email address LDAP attribute."""
         self._update_setting("email_address_attribute", value)
 
     @property
     def resource_pool(self) -> str:
+        """Return the resource pool a new user will be added to."""
         return self._get_setting("resource_pool")
 
     @resource_pool.setter
     def resource_pool(self, value: str | ResourcePool) -> None:
+        """Set the resource pool a new user will be added to."""
         if isinstance(value, ResourcePool):
             value = value.id
         self._update_setting("resource_pool", value)
diff --git a/virl2_client/models/authentication.py b/virl2_client/models/authentication.py
index 003cbf3..bf812c8 100644
--- a/virl2_client/models/authentication.py
+++ b/virl2_client/models/authentication.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -22,7 +22,7 @@
 
 import json
 import logging
-from typing import TYPE_CHECKING, Generator, Optional
+from typing import TYPE_CHECKING, Generator
 from uuid import uuid4
 
 import httpx
@@ -33,66 +33,98 @@
     from ..virl2_client import ClientLibrary
 
 
+_AUTH_URL = "authenticate"
+
+
 class TokenAuth(httpx.Auth):
     """
-    Initially inspired by:
+    Token-based authentication for an httpx session.
+
+    Inspired by:
     https://requests.readthedocs.io/en/v2.9.1/user/authentication/?highlight=AuthBase#new-forms-of-authentication
-    and modified for httpx based on:
+    Modified for httpx based on:
     https://www.python-httpx.org/advanced/#customizing-authentication
     """
 
     requires_response_body = True
 
     def __init__(self, client_library: ClientLibrary):
+        """
+        Initialize the TokenAuth object with a client library instance.
+
+        :param client_library: A client library instance.
+        """
         self.client_library = client_library
-        self._token: Optional[str] = None
+        self._token: str | None = None
 
     @property
-    def token(self) -> Optional[str]:
+    def token(self) -> str | None:
+        """
+        Return the authentication token. If the token has not been set, it is obtained
+        from the server.
+        """
         if self._token is not None:
             return self._token
 
-        auth_url = "authenticate"
-        base_url = self.client_library.session.base_url
+        base_url = self.client_library._session.base_url
         if base_url.port is not None and base_url.port != 443:
-            _LOGGER.warning("Not using SSL port of 443: %d", base_url.port)
+            _LOGGER.warning(f"Not using SSL port of 443: {base_url.port:d}")
         if base_url.scheme != "https":
-            _LOGGER.warning("Not using https scheme: %s", base_url.scheme)
+            _LOGGER.warning(f"Not using https scheme: {base_url.scheme}")
         data = {
             "username": self.client_library.username,
             "password": self.client_library.password,
         }
-        response = self.client_library.session.post(
-            auth_url, json=data, auth=None  # type: ignore
+        response = self.client_library._session.post(
+            _AUTH_URL, json=data, auth=None  # type: ignore
         )  # auth=None works but is missing from .post's type hint
         response_raise(response)
         self._token = response.json()
         return self._token
 
     @token.setter
-    def token(self, value: Optional[str]):
+    def token(self, value: str | None) -> None:
+        """
+        Set the authentication token to the specified value.
+
+        :param value: The value to set as the authentication token.
+        """
         self._token = value
 
     def auth_flow(
         self, request: httpx.Request
     ) -> Generator[httpx.Request, httpx.Response, None]:
-        request.headers["Authorization"] = "Bearer {}".format(self.token)
+        """
+        Implement the authentication flow for the token-based authentication.
+
+        :param request: The request object to authenticate.
+        :returns: A generator of the authenticated request and response objects.
+        """
+        request.headers["Authorization"] = f"Bearer {self.token}"
         response = yield request
 
         if response.status_code == 401:
             _LOGGER.warning("re-auth called on 401 unauthorized")
             self.token = None
-            request.headers["Authorization"] = "Bearer {}".format(self.token)
+            request.headers["Authorization"] = f"Bearer {self.token}"
             response = yield request
 
         response_raise(response)
 
-    def logout(self, clear_all_sessions=False):
+    def logout(self, clear_all_sessions=False) -> bool:
+        """
+        Log out the user. Invalidates the current token.
+
+        :param clear_all_sessions: Whether to clear all sessions.
+        :returns: Whether the logout succeeded.
+        """
         url = "logout" + ("?clear_all_sessions=true" if clear_all_sessions else "")
-        return self.client_library.session.delete(url).json()
+        return self.client_library._session.delete(url).json()
 
 
 class BlankAuth(httpx.Auth):
+    """A class that implements an httpx Auth object that does nothing."""
+
     def auth_flow(
         self, request: httpx.Request
     ) -> Generator[httpx.Request, httpx.Response, None]:
@@ -101,7 +133,13 @@ def auth_flow(
 
 
 def response_raise(response: httpx.Response) -> None:
-    """Replaces the useless link to httpstatuses.com with error description."""
+    """
+    Raise an exception if the response has an HTTP status error.
+    Replace the useless link to httpstatuses.com with error description.
+
+    :param response: The response object to check.
+    :raises httpx.HTTPStatusError: If the response has an HTTP status error.
+    """
     try:
         response.raise_for_status()
     except httpx.HTTPStatusError as error:
@@ -117,6 +155,17 @@ def response_raise(response: httpx.Response) -> None:
 
 
 def make_session(base_url: str, ssl_verify: bool = True) -> httpx.Client:
+    """
+    Create an httpx Client object with the specified base URL
+    and SSL verification setting.
+
+    Note: The base URL is automatically prepended to all HTTP calls. This means you
+    should use ``_session.get("labs")`` rather than ``_session.get(base_url + "labs")``.
+
+    :param base_url: The base URL for the client.
+    :param ssl_verify: Whether to perform SSL verification.
+    :returns: The created httpx Client object.
+    """
     return httpx.Client(
         base_url=base_url,
         verify=ssl_verify,
diff --git a/virl2_client/models/cl_pyats.py b/virl2_client/models/cl_pyats.py
index c40df41..2bcbd46 100644
--- a/virl2_client/models/cl_pyats.py
+++ b/virl2_client/models/cl_pyats.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -21,104 +21,120 @@
 from __future__ import annotations
 
 import io
-from typing import TYPE_CHECKING, Any, Optional
+from typing import TYPE_CHECKING
 
-if TYPE_CHECKING:
-    from pyats.topology import Device
-
-    from .lab import Lab
+try:
+    from pyats.topology.loader.base import TestbedFileLoader as _PyatsTFLoader
+    from pyats.topology.loader.markup import TestbedMarkupProcessor as _PyatsTMProcessor
+    from pyats.utils.yaml.markup import Processor as _PyatsProcessor
+except ImportError:
+    _PyatsTFLoader = None
+    _PyatsTMProcessor = None
+else:
+    # Ensure markup processor never uses the command line arguments as that's broken
+    _PyatsProcessor.argv.clear()
 
 
-class PyatsNotInstalled(Exception):
-    pass
+from ..exceptions import PyatsDeviceNotFound, PyatsNotInstalled
 
+if TYPE_CHECKING:
+    from genie.libs.conf.device import Device
+    from genie.libs.conf.testbed import Testbed
 
-class PyatsDeviceNotFound(Exception):
-    pass
+    from .lab import Lab
 
 
 class ClPyats:
-    def __init__(self, lab: Lab, hostname: Optional[str] = None) -> None:
+    def __init__(self, lab: Lab, hostname: str | None = None) -> None:
         """
-        Creates a pyATS object that can be used to run commands
+        Create a pyATS object that can be used to run commands
         against a device either in exec mode ``show version`` or in
         configuration mode ``interface gi0/0 \\n no shut``.
 
-        :param lab: the lab which should be used with pyATS
-        :param hostname: force hostname/ip and port for console terminal server
-        :raises PyatsNotInstalled: when pyATS can not be found
-        :raises PyatsDeviceNotFound: when the device can not be found
+        :param lab: The lab object to be used with pyATS.
+        :param hostname: Forced hostname or IP address and port of the console
+            terminal server.
+        :raises PyatsNotInstalled: If pyATS is not installed.
+        :raises PyatsDeviceNotFound: If the device cannot be found.
         """
-        self._pyats_installed = False
         self._lab = lab
         self._hostname = hostname
-        try:
-            import pyats  # noqa: F401
-        except ImportError:
-            return
-        else:
-            self._pyats_installed = True
-        self._testbed: Any = None
-        self._connections: list[Any] = []
+        self._testbed: Testbed | None = None
+        self._connections: set[Device] = set()
 
     @property
-    def hostname(self) -> Optional[str]:
-        """Return forced hostname/ip and port terminal server setting"""
+    def hostname(self) -> str | None:
+        """Return the forced hostname/IP and port terminal server setting."""
         return self._hostname
 
     @hostname.setter
-    def hostname(self, hostname: Optional[str] = None) -> None:
+    def hostname(self, hostname: str | None = None) -> None:
+        """
+        Set the forced hostname/IP and port terminal server setting.
+
+        :param hostname: The hostname or IP address and port of the console terminal
+            server.
+        """
         self._hostname = hostname
 
     def _check_pyats_installed(self) -> None:
-        if not self._pyats_installed:
+        """
+        Check if pyATS is installed and raise an exception if not.
+
+        :raises PyatsNotInstalled: If pyATS is not installed.
+        """
+        if _PyatsTFLoader is None:
             raise PyatsNotInstalled
 
-    def sync_testbed(self, username: str, password: str) -> None:
+    def _load_pyats_testbed(self, testbed_yaml: str) -> Testbed:
         """
-        Syncs the testbed from the server. Note that this
-        will always fetch the latest topology data from the server.
+        Load a PyATS testbed instance from YAML representation.
 
-        :param username: the username that will be inserted into
-            the testbed data
-        :param password: the password that will be inserted into
-            the testbed data
+        Disable all templating features of PyATS markup processor.
+        Also disable extensions loading (which still uses all of the templating)
+        https://pubhub.devnetcloud.com/media/pyats/docs/utilities/yaml_markup.html
         """
-        self._check_pyats_installed()
-        from pyats.topology import loader
+        processor = _PyatsTMProcessor(
+            reference=True,
+            callable=False,
+            env_var=False,
+            include_file=False,
+            ask=False,
+            encode=False,
+            cli_var=False,
+            extend_list=False,
+        )
+        loader = _PyatsTFLoader(markupprocessor=processor, enable_extensions=False)
+        return loader.load(io.StringIO(testbed_yaml))
 
-        testbed_yaml = self._lab.get_pyats_testbed(self._hostname)
-        data = loader.load(io.StringIO(testbed_yaml))
-        data.devices.terminal_server.credentials.default.username = username
-        data.devices.terminal_server.credentials.default.password = password
-        self._testbed = data
+    def sync_testbed(self, username: str, password: str) -> None:
+        """
+        Sync the testbed from the server.
+        This fetches the latest topology data from the server.
 
-    def _prepare_device(self, node_label: str) -> Device:
+        :param username: The username to be inserted into the testbed data.
+        :param password: The password to be inserted into the testbed data.
+        """
         self._check_pyats_installed()
-        try:
-            pyats_device = self._testbed.devices[node_label]
-        except KeyError:
-            raise PyatsDeviceNotFound(node_label)
-
-        # TODO: later check if connected
-        # TODO: later look at pooling connections
-        pyats_device.connect(log_stdout=False, learn_hostname=True)
-        self._connections.append(pyats_device)
-        return pyats_device
+        testbed_yaml = self._lab.get_pyats_testbed(self._hostname)
+        testbed = self._load_pyats_testbed(testbed_yaml)
+        testbed.devices.terminal_server.credentials.default.username = username
+        testbed.devices.terminal_server.credentials.default.password = password
+        self._testbed = testbed
 
     def _prepare_params(
         self,
-        init_exec_commands: Optional[list] = None,
-        init_config_commands: Optional[list] = None,
+        init_exec_commands: list[str] | None = None,
+        init_config_commands: list[str] | None = None,
     ) -> dict:
         """
         Prepare a dictionary of optional parameters to be executed before a command.
-        None means that default commands will be executed.  If you want no commands
-        to be executed pass an empty list instead.
+        None means that default commands will be executed. If you want no commands
+        to be executed, pass an empty list instead.
 
-        :param init_exec_commands: a list of exec commands to be executed
-        :param init_config_commangs: a list of config commands to be executed
-        :returns:  a dictionary of optional parameters to be executed with a command
+        :param init_exec_commands: A list of exec commands to be executed.
+        :param init_config_commands: A list of config commands to be executed.
+        :returns: A dictionary of optional parameters to be executed with a command.
         """
         params = {}
         if init_exec_commands:
@@ -127,48 +143,108 @@ def _prepare_params(
             params["init_config_commands"] = init_config_commands
         return params
 
-    def run_command(
+    def _execute_command(
         self,
         node_label: str,
         command: str,
-        init_exec_commands: Optional[list] = None,
-        init_config_commands: Optional[list] = None,
+        configure_mode: bool = False,
+        init_exec_commands: list[str] | None = None,
+        init_config_commands: list[str] | None = None,
     ) -> str:
         """
-        Run a command on the device in `exec` mode.
-
-        :param node_label: the label / title of the device
-        :param command: the command to be run in exec mode
-        :param init_exec_commands: a list of exec commands to be executed
-        :param init_config_commangs: a list of config commands to be executed
-        :returns: the output from the device
+        Execute a command on the device.
+
+        :param node_label: The label/title of the device.
+        :param command: The command to be executed.
+        :param configure_mode: True if the command is to be run in configure mode,
+            False for exec mode.
+        :param init_exec_commands: A list of exec commands to be executed
+            before the command. Default commands will be run if omitted.
+            Pass an empty list to run no commands.
+        :param init_config_commands: A list of config commands to be executed
+            before the command. Default commands will be run if omitted.
+            Pass an empty list to run no commands.
+        :returns: The output from the device.
+        :raises PyatsDeviceNotFound: If the device cannot be found.
         """
-        pyats_device = self._prepare_device(node_label)
+        self._check_pyats_installed()
+
+        try:
+            pyats_device: Device = self._testbed.devices[node_label]
+        except KeyError:
+            raise PyatsDeviceNotFound(node_label)
+
+        if pyats_device not in self._connections or not pyats_device.is_connected():
+            if pyats_device in self._connections:
+                pyats_device.destroy()
+
+            pyats_device.connect(log_stdout=False, learn_hostname=True)
+            self._connections.add(pyats_device)
         params = self._prepare_params(init_exec_commands, init_config_commands)
-        return pyats_device.execute(command, log_stdout=False, **params)
+        if configure_mode:
+            return pyats_device.configure(command, log_stdout=False, **params)
+        else:
+            return pyats_device.execute(command, log_stdout=False, **params)
 
-    def run_config_command(
+    def run_command(
         self,
         node_label: str,
         command: str,
-        init_exec_commands: Optional[list] = None,
-        init_config_commands: Optional[list] = None,
+        init_exec_commands: list[str] | None = None,
+        init_config_commands: list[str] | None = None,
     ) -> str:
         """
-        Run a command on the device in `configure` mode. pyATS
-        handles the change into `configure` mode automatically.
+        Run a command on the device in exec mode.
+
+        :param node_label: The label/title of the device.
+        :param command: The command to be run in exec mode.
+        :param init_exec_commands: A list of exec commands to be executed
+            before the command. Default commands will be run if omitted.
+            Pass an empty list to run no commands.
+        :param init_config_commands: A list of config commands to be executed
+            before the command. Default commands will be run if omitted.
+            Pass an empty list to run no commands.
+        :returns: The output from the device.
+        """
+        return self._execute_command(
+            node_label,
+            command,
+            configure_mode=False,
+            init_exec_commands=init_exec_commands,
+            init_config_commands=init_config_commands,
+        )
 
-        :param node_label: the label / title of the device
-        :param command: the command to be run in exec mode
-        :param init_exec_commands: a list of exec commands to be executed
-        :param init_config_commangs: a list of config commands to be executed
-        :returns: the output from the device
+    def run_config_command(
+        self,
+        node_label: str,
+        command: str,
+        init_exec_commands: list[str] | None = None,
+        init_config_commands: list[str] | None = None,
+    ) -> str:
         """
-        pyats_device = self._prepare_device(node_label)
-        params = self._prepare_params(init_exec_commands, init_config_commands)
-        return pyats_device.configure(command, log_stdout=False, **params)
+        Run a command on the device in configure mode. pyATS automatically handles the
+        change into configure mode.
+
+        :param node_label: The label/title of the device.
+        :param command: The command to be run in configure mode.
+        :param init_exec_commands: A list of exec commands to be executed
+            before the command. Default commands will be run if omitted.
+            Pass an empty list to run no commands.
+        :param init_config_commands: A list of config commands to be executed
+            before the command. Default commands will be run if omitted.
+            Pass an empty list to run no commands.
+        :returns: The output from the device.
+        """
+        return self._execute_command(
+            node_label,
+            command,
+            configure_mode=True,
+            init_exec_commands=init_exec_commands,
+            init_config_commands=init_config_commands,
+        )
 
     def cleanup(self) -> None:
-        """Cleans up the pyATS connections."""
+        """Clean up the pyATS connections."""
         for pyats_device in self._connections:
             pyats_device.destroy()
+        self._connections.clear()
diff --git a/virl2_client/models/configuration.py b/virl2_client/models/configuration.py
index 79d8fd3..08192ac 100644
--- a/virl2_client/models/configuration.py
+++ b/virl2_client/models/configuration.py
@@ -1,7 +1,24 @@
+#
+# This file is part of VIRL 2
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
+# All rights reserved.
+#
+# Python bindings for the Cisco VIRL 2 Network Simulation Platform
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
 """
-https://github.com/CiscoDevNet/virlutils/blob/master/virl/api/credentials.py
-
-Collection of utility classes to make getting credentials and
+A collection of utility classes to make getting credentials and
 configuration easier.
 """
 from __future__ import annotations
@@ -10,12 +27,19 @@
 import os
 from pathlib import Path
 
-from virl2_client.exceptions import InitializationError
+from ..exceptions import InitializationError
 
 _CONFIG_FILE_NAME = ".virlrc"
 
 
 def _get_from_file(virlrc_parent: Path, prop_name: str) -> str | None:
+    """
+    Retrieve a property value from a file.
+
+    :param virlrc_parent: The parent directory of the file.
+    :param prop_name: The name of the property to retrieve.
+    :returns: The value of the property if found, otherwise None.
+    """
     virlrc = virlrc_parent / _CONFIG_FILE_NAME
     if virlrc.is_file():
         with virlrc.open() as fh:
@@ -32,12 +56,17 @@ def _get_from_file(virlrc_parent: Path, prop_name: str) -> str | None:
 
 def _get_prop(prop_name: str) -> str:
     """
-    Gets a variable using the following order:
-    * Check for .virlrc in current directory
-    * Recurse up directory tree for .virlrc
-    * Check environment variables
-    * Check ~/.virlrc
+    Get the value of a variable.
+
+    The function follows the order:
+
+    1. Check for .virlrc in the current directory
+    2. Recurse up the directory tree for .virlrc
+    3. Check environment variables
+    4. Check ~/.virlrc
 
+    :param prop_name: The name of the property to retrieve.
+    :returns: The value of the property if found, otherwise None.
     """
     # check for .virlrc in current directory
     cwd = Path.cwd()
@@ -64,20 +93,30 @@ def get_configuration(
     host: str, username: str, password: str, ssl_verify: bool | str
 ) -> tuple[str, str, str, str]:
     """
-    Used to get login configuration
-    The login configuration is taken in the following order:
-    * Check for .virlrc in the current directory
-    * Recurse up directory tree for .virlrc
-    * Check environment variables
-    * Check ~/.virlrc
-    * Prompt user (except cert path)
+    Get the login configuration.
+
+    The login configuration is retrieved in the following order:
+
+    1. Retrieve from function arguments
+    2. Check for .virlrc in the current directory
+    3. Recurse up the directory tree for .virlrc
+    4. Check environment variables
+    5. Check ~/.virlrc
+    6. Prompt the user (except for the cert path)
+
+    :param host: The host address of the CML server.
+    :param username: The username.
+    :param password: The password.
+    :param ssl_verify: The CA bundle path or boolean value indicating SSL verification.
+    :returns: A tuple containing the host, username, password,
+        and SSL verification information.
     """
     if not (
         host
         or (host := _get_prop("VIRL2_URL") or _get_prop("VIRL_HOST"))
         or (host := input("Please enter the IP / hostname of your virl server: "))
     ):
-        message = "no URL provided"
+        message = "No URL provided."
         raise InitializationError(message)
 
     if not (
@@ -85,7 +124,7 @@ def get_configuration(
         or (username := _get_prop("VIRL2_USER") or _get_prop("VIRL_USERNAME"))
         or (username := input("Please enter your VIRL username: "))
     ):
-        message = "no username provided"
+        message = "No username provided."
         raise InitializationError(message)
 
     if not (
@@ -93,7 +132,7 @@ def get_configuration(
         or (password := _get_prop("VIRL2_PASS") or _get_prop("VIRL_PASSWORD"))
         or (password := getpass.getpass("Please enter your password: "))
     ):
-        message = "no password provided"
+        message = "No password provided."
         raise InitializationError(message)
 
     if ssl_verify is True:
diff --git a/virl2_client/models/groups.py b/virl2_client/models/groups.py
index 5394323..2c43974 100644
--- a/virl2_client/models/groups.py
+++ b/virl2_client/models/groups.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -20,65 +20,81 @@
 
 from __future__ import annotations
 
-from typing import TYPE_CHECKING, Optional
+from typing import TYPE_CHECKING
 
-from ..utils import UNCHANGED
+from ..utils import UNCHANGED, get_url_from_template
 
 if TYPE_CHECKING:
     import httpx
 
 
 class GroupManagement:
+    """Manage groups."""
+
+    _URL_TEMPLATES = {
+        "groups": "groups",
+        "group": "groups/{group_id}",
+        "members": "groups/{group_id}/members",
+        "labs": "groups/{group_id}/labs",
+        "id": "groups/{group_name}/id",
+    }
+
     def __init__(self, session: httpx.Client) -> None:
         self._session = session
 
-    @property
-    def base_url(self) -> str:
-        return "groups"
+    def _url_for(self, endpoint, **kwargs):
+        """
+        Generate the URL for a given API endpoint.
+
+        :param endpoint: The desired endpoint.
+        :param **kwargs: Keyword arguments used to format the URL.
+        :returns: The formatted URL.
+        """
+        return get_url_from_template(endpoint, self._URL_TEMPLATES, kwargs)
 
     def groups(self) -> list:
         """
         Get the list of available groups.
 
-        :return: list of group objects
+        :returns: A list of group objects.
         """
-        return self._session.get(self.base_url).json()
+        url = self._url_for("groups")
+        return self._session.get(url).json()
 
     def get_group(self, group_id: str) -> dict:
         """
-        Gets info for the specified group.
+        Get information for the specified group.
 
-        :param group_id: group uuid4
-        :return: group object
+        :param group_id: The UUID4 of the group.
+        :returns: A group object.
         """
-        url = self.base_url + "/{}".format(group_id)
+        url = self._url_for("group", group_id=group_id)
         return self._session.get(url).json()
 
     def delete_group(self, group_id: str) -> None:
         """
-        Deletes a group.
+        Delete a group.
 
-        :param group_id: group uuid4
-        :return: None
+        :param group_id: The UUID4 of the group.
         """
-        url = self.base_url + "/{}".format(group_id)
+        url = self._url_for("group", group_id=group_id)
         self._session.delete(url)
 
     def create_group(
         self,
         name: str,
         description: str = "",
-        members: Optional[list[str]] = None,
-        labs: Optional[list[dict[str, str]]] = None,
+        members: list[str] | None = None,
+        labs: list[dict[str, str]] | None = None,
     ) -> dict:
         """
-        Creates a group.
+        Create a group.
 
-        :param name: group name
-        :param description: group description
-        :param members: group members
-        :param labs: group labs
-        :return: created group object
+        :param name: The name of the group.
+        :param description: The description of the group.
+        :param members: The members of the group.
+        :param labs: The labs associated with the group.
+        :returns: The created group object.
         """
         data = {
             "name": name,
@@ -86,25 +102,26 @@ def create_group(
             "members": members or [],
             "labs": labs or [],
         }
-        return self._session.post(self.base_url, json=data).json()
+        url = self._url_for("groups")
+        return self._session.post(url, json=data).json()
 
     def update_group(
         self,
         group_id: str,
-        name: Optional[str] = None,
-        description: Optional[str] = UNCHANGED,
-        members: Optional[list[str]] = UNCHANGED,
-        labs: Optional[list[dict[str, str]]] = UNCHANGED,
+        name: str | None = None,
+        description: str | None = UNCHANGED,
+        members: list[str] | None = UNCHANGED,
+        labs: list[dict[str, str]] | None = UNCHANGED,
     ) -> dict:
         """
-        Updates a group.
+        Update a group.
 
-        :param group_id: group uuid4
-        :param name: new group name
-        :param description: group description
-        :param members: group members
-        :param labs: group labs
-        :return: updated group object
+        :param group_id: The UUID4 of the group.
+        :param name: The new name of the group.
+        :param description: The description of the group.
+        :param members: The members of the group.
+        :param labs: The labs associated with the group.
+        :returns: The updated group object.
         """
         data: dict[str, str | list] = {}
         if name is not None:
@@ -115,35 +132,35 @@ def update_group(
             data["members"] = members
         if labs is not UNCHANGED:
             data["labs"] = labs
-        url = self.base_url + "/{}".format(group_id)
+        url = self._url_for("group", group_id=group_id)
         return self._session.patch(url, json=data).json()
 
     def group_members(self, group_id: str) -> list[str]:
         """
-        Gets group members.
+        Get the members of a group.
 
-        :param group_id: group uuid4
-        :return: list of users associated with this group
+        :param group_id: The UUID4 of the group.
+        :returns: A list of users associated with this group.
         """
-        url = self.base_url + "/{}/members".format(group_id)
+        url = self._url_for("members", group_id=group_id)
         return self._session.get(url).json()
 
     def group_labs(self, group_id: str) -> list[str]:
         """
-        Get the list of labs that are associated with this group.
+        Get a list of labs associated with a group.
 
-        :param group_id: group uuid4
-        :return: list of labs associated with this group
+        :param group_id: The UUID4 of the group.
+        :returns: A list of labs associated with this group.
         """
-        url = self.base_url + "/{}/labs".format(group_id)
+        url = self._url_for("labs", group_id=group_id)
         return self._session.get(url).json()
 
     def group_id(self, group_name: str) -> str:
         """
-        Get unique user uuid4.
+        Get the unique UUID4 of a group.
 
-        :param group_name: group name
-        :return: group unique identifier
+        :param group_name: The name of the group.
+        :returns: The unique identifier of the group.
         """
-        url = self.base_url + "/{}/id".format(group_name)
+        url = self._url_for("id", group_name=group_name)
         return self._session.get(url).json()
diff --git a/virl2_client/models/interface.py b/virl2_client/models/interface.py
index 8c4eb54..af93ff0 100644
--- a/virl2_client/models/interface.py
+++ b/virl2_client/models/interface.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -22,10 +22,9 @@
 
 import logging
 import warnings
-from functools import total_ordering
-from typing import TYPE_CHECKING, Optional
+from typing import TYPE_CHECKING
 
-from ..utils import check_stale
+from ..utils import check_stale, get_url_from_template
 from ..utils import property_s as property
 
 if TYPE_CHECKING:
@@ -37,32 +36,38 @@
 _LOGGER = logging.getLogger(__name__)
 
 
-@total_ordering
 class Interface:
+    _URL_TEMPLATES = {
+        "interface": "{lab}/interfaces/{id}",
+        "state": "{lab}/interfaces/{id}/state",
+        "start": "{lab}/interfaces/{id}/state/start",
+        "stop": "{lab}/interfaces/{id}/state/stop",
+    }
+
     def __init__(
         self,
         iid: str,
         node: Node,
         label: str,
-        slot: Optional[int],
+        slot: int | None,
         iface_type: str = "physical",
     ) -> None:
         """
-        A VIRL2 network interface, part of a node.
+        A CML 2 network interface, part of a node.
 
-        :param iid: interface ID
-        :param node: node object
-        :param label: the label of the interface
-        :param slot: the slot of the interface
-        :param iface_type: the type of the interface, defaults to "physical"
+        :param iid: The interface ID.
+        :param node: The node object.
+        :param label: The label of the interface.
+        :param slot: The slot of the interface.
+        :param iface_type: The type of the interface.
         """
         self._id = iid
         self._node = node
         self._type = iface_type
         self._label = label
         self._slot = slot
-        self._state: Optional[str] = None
-        self._session: httpx.Client = node.lab.session
+        self._state: str | None = None
+        self._session: httpx.Client = node.lab._session
         self._stale = False
         self.statistics = {
             "readbytes": 0,
@@ -70,7 +75,7 @@ def __init__(
             "writebytes": 0,
             "writepackets": 0,
         }
-        self.ip_snooped_info: dict[str, Optional[str]] = {
+        self._ip_snooped_info: dict[str, str | None] = {
             "mac_address": None,
             "ipv4": None,
             "ipv6": None,
@@ -81,11 +86,6 @@ def __eq__(self, other):
             return False
         return self._id == other._id
 
-    def __lt__(self, other):
-        if not isinstance(other, Interface):
-            return False
-        return self._id < other._id
-
     def __str__(self):
         return f"Interface: {self._label}{' (STALE)' if self._stale else ''}"
 
@@ -102,56 +102,65 @@ def __repr__(self):
     def __hash__(self):
         return hash(self._id)
 
-    @property
-    def lab_base_url(self) -> str:
-        return self.node.lab_base_url
+    def _url_for(self, endpoint, **kwargs):
+        """
+        Generate the URL for a given API endpoint.
 
-    @property
-    def _base_url(self) -> str:
-        return self.lab_base_url + "/interfaces/{}".format(self.id)
+        :param endpoint: The desired endpoint.
+        :param **kwargs: Keyword arguments used to format the URL.
+        :returns: The formatted URL.
+        """
+        kwargs["lab"] = self.node.lab._url_for("lab")
+        kwargs["id"] = self.id
+        return get_url_from_template(endpoint, self._URL_TEMPLATES, kwargs)
 
     @property
     def id(self) -> str:
+        """Return the ID of the interface."""
         return self._id
 
     @property
     def node(self) -> Node:
+        """Return the node object to which the interface belongs."""
         return self._node
 
     @property
     def type(self) -> str:
+        """Return the type of the interface."""
         return self._type
 
     @property
     def label(self) -> str:
+        """Return the label of the interface."""
         return self._label
 
     @property
-    def slot(self) -> Optional[int]:
+    def slot(self) -> int | None:
+        """Return the slot of the interface."""
         return self._slot
 
     @property
     def physical(self) -> bool:
-        """Whether the interface is physical."""
-        self.node.lab.sync_topology_if_outdated()
+        """Check if the interface is physical."""
         return self.type == "physical"
 
     @property
     def connected(self) -> bool:
-        """Whether the interface is connected to a link."""
+        """Check if the interface is connected to a link."""
         return self.link is not None
 
     @property
-    def state(self) -> Optional[str]:
+    def state(self) -> str | None:
+        """Return the state of the interface."""
         self.node.lab.sync_states_if_outdated()
         if self._state is None:
-            url = self._base_url + "/state"
+            url = self._url_for("state")
             self._state = self._session.get(url).json()["state"]
         return self._state
 
     @property
-    def link(self) -> Optional[Link]:
-        """Is link if connected, otherwise None."""
+    def link(self) -> Link | None:
+        """Get the link if the interface is connected, otherwise None."""
         self.node.lab.sync_topology_if_outdated()
         for link in self.node.lab.links():
             if self in link.interfaces:
@@ -159,7 +168,8 @@ def link(self) -> Optional[Link]:
         return None
 
     @property
-    def peer_interface(self) -> Optional[Interface]:
+    def peer_interface(self) -> Interface | None:
+        """Return the peer interface connected to the interface."""
         link = self.link
         if link is None:
             return None
@@ -169,60 +179,98 @@ def peer_interface(self) -> Optional[Interface]:
         return interfaces[0]
 
     @property
-    def peer_node(self) -> Optional[Node]:
+    def peer_node(self) -> Node | None:
+        """Return the node to which the peer interface belongs."""
         peer_interface = self.peer_interface
         return peer_interface.node if peer_interface is not None else None
 
     @property
     def readbytes(self) -> int:
+        """Return the number of bytes read by the interface."""
         self.node.lab.sync_statistics_if_outdated()
         return int(self.statistics["readbytes"])
 
     @property
     def readpackets(self) -> int:
+        """Return the number of packets read by the interface."""
         self.node.lab.sync_statistics_if_outdated()
         return int(self.statistics["readpackets"])
 
     @property
     def writebytes(self) -> int:
+        """Return the number of bytes written by the interface."""
         self.node.lab.sync_statistics_if_outdated()
         return int(self.statistics["writebytes"])
 
     @property
     def writepackets(self) -> int:
+        """Return the number of packets written by the interface."""
         self.node.lab.sync_statistics_if_outdated()
         return int(self.statistics["writepackets"])
 
     @property
-    def discovered_mac_address(self) -> Optional[str]:
-        self.node.lab.sync_l3_addresses_if_outdated()
-        return self.ip_snooped_info["mac_address"]
+    def ip_snooped_info(self) -> dict[str, str | None]:
+        """
+        Return the discovered MAC, IPv4 and IPv6 addresses
+        of the interface in a dictionary.
+        """
+        self.node.sync_l3_addresses_if_outdated()
+        return self._ip_snooped_info.copy()
 
     @property
-    def discovered_ipv4(self) -> Optional[str]:
-        self.node.lab.sync_l3_addresses_if_outdated()
-        return self.ip_snooped_info["ipv4"]
+    def discovered_mac_address(self) -> str | None:
+        """Return the discovered MAC address of the interface."""
+        self.node.sync_l3_addresses_if_outdated()
+        return self._ip_snooped_info["mac_address"]
 
     @property
-    def discovered_ipv6(self) -> Optional[str]:
-        self.node.lab.sync_l3_addresses_if_outdated()
-        return self.ip_snooped_info["ipv6"]
+    def discovered_ipv4(self) -> str | None:
+        """Return the discovered IPv4 address of the interface."""
+        self.node.sync_l3_addresses_if_outdated()
+        return self._ip_snooped_info["ipv4"]
 
     @property
-    def is_physical(self):
-        warnings.warn("Deprecated, use .physical instead.", DeprecationWarning)
+    def discovered_ipv6(self) -> str | None:
+        """Return the discovered IPv6 address of the interface."""
+        self.node.sync_l3_addresses_if_outdated()
+        return self._ip_snooped_info["ipv6"]
+
+    @property
+    def is_physical(self) -> bool:
+        """
+        DEPRECATED: Use `.physical` instead.
+        (Reason: renamed to match similar parameters)
+
+        Check if the interface is physical.
+        """
+        warnings.warn(
+            "'Interface.is_physical' is deprecated. Use '.physical' instead.",
+            DeprecationWarning,
+        )
         return self.physical
 
     def as_dict(self) -> dict[str, str]:
-        # TODO what should be here in 'data' key?
-        return {"id": self.id, "node": self.node.id, "data": self.id}
+        """Convert the interface to a dictionary representation."""
+        return {
+            "id": self.id,
+            "node": self.node.id,
+            "data": {
+                "lab_id": self.node.lab.id,
+                "label": self.label,
+                "slot": self.slot,
+                "type": self.type,
+                "mac_address": self.discovered_mac_address,
+                "is_connected": self.connected,
+                "state": self.state,
+            },
+        }
 
-    def get_link_to(self, other_interface: Interface) -> Optional[Link]:
+    def get_link_to(self, other_interface: Interface) -> Link | None:
         """
-        Returns the link between this interface and another.
+        Return the link between this interface and another.
 
-        :param other_interface: the other interface
-        :returns: A Link
+        :param other_interface: The other interface.
+        :returns: A Link object if a link exists between the interfaces, None otherwise.
         """
         link = self.link
         if link is not None and other_interface in link.interfaces:
@@ -230,51 +278,110 @@ def get_link_to(self, other_interface: Interface) -> Optional[Link]:
         return None
 
     def remove(self) -> None:
+        """Remove the interface from the node."""
         self.node.lab.remove_interface(self)
 
     @check_stale
     def _remove_on_server(self) -> None:
-        _LOGGER.info("Removing interface %s", self)
-        url = self._base_url
+        """
+        Remove the interface on the server.
+
+        This method is internal and should not be used directly. Use 'remove' instead.
+        """
+        _LOGGER.info(f"Removing interface {self}")
+        url = self._url_for("interface")
         self._session.delete(url)
 
     def remove_on_server(self) -> None:
+        """
+        DEPRECATED: Use `.remove()` instead.
+        (Reason: was never meant to be public, removing only on server is not useful)
+
+        Remove the interface on the server.
+        """
         warnings.warn(
-            "'Interface.remove_on_server()' is deprecated, "
-            "use 'Interface.remove()' instead.",
+            "'Interface.remove_on_server()' is deprecated. Use '.remove()' instead.",
             DeprecationWarning,
         )
         self._remove_on_server()
 
     @check_stale
     def bring_up(self) -> None:
-        url = self._base_url + "/state/start"
+        """Bring up the interface."""
+        url = self._url_for("start")
         self._session.put(url)
 
     @check_stale
     def shutdown(self) -> None:
-        url = self._base_url + "/state/stop"
+        """Shutdown the interface."""
+        url = self._url_for("stop")
         self._session.put(url)
 
     def peer_interfaces(self):
-        warnings.warn("Deprecated, use .peer_interface instead.", DeprecationWarning)
+        """
+        DEPRECATED: Use `.peer_interface` instead.
+        (Reason: pointless plural, could have been a parameter)
+
+        Return the peer interface connected to this interface in a set.
+        """
+        warnings.warn(
+            "'Interface.peer_interfaces()' is deprecated, "
+            "use '.peer_interface' instead.",
+            DeprecationWarning,
+        )
         return {self.peer_interface}
 
     def peer_nodes(self):
-        warnings.warn("Deprecated, use .peer_node instead.", DeprecationWarning)
+        """
+        DEPRECATED: Use `.peer_node` instead.
+        (Reason: pointless plural, could have been a parameter)
+
+        Return the node of the peer interface in a set.
+        """
+        warnings.warn(
+            "'Interface.peer_nodes() is deprecated. Use '.peer_node' instead.",
+            DeprecationWarning,
+        )
         return {self.peer_node}
 
     def links(self):
-        warnings.warn("Deprecated, use .link instead.", DeprecationWarning)
+        """
+        DEPRECATED: Use `.link` instead.
+        (Reason: pointless plural, could have been a parameter)
+
+        Return the link connected to this interface in a list.
+        """
+        warnings.warn(
+            "'Interface.links()' is deprecated. Use '.link' instead.",
+            DeprecationWarning,
+        )
         link = self.link
         if link is None:
             return []
         return [link]
 
     def degree(self):
-        warnings.warn("Deprecated, use .connected instead.", DeprecationWarning)
+        """
+        DEPRECATED: Use `.connected` instead.
+        (Reason: degree always 0 or 1)
+
+        Return the degree of the interface.
+        """
+        warnings.warn(
+            "'Interface.degree()' is deprecated. Use '.connected' instead.",
+            DeprecationWarning,
+        )
         return int(self.connected)
 
     def is_connected(self):
-        warnings.warn("Deprecated, use .connected instead.", DeprecationWarning)
+        """
+        DEPRECATED: Use `.connected` instead.
+        (Reason: should have been a parameter, renamed to match similar parameters)
+
+        Check if the interface is connected to a link.
+        """
+        warnings.warn(
+            "'Interface.is_connected()' is deprecated. Use '.connected' instead.",
+            DeprecationWarning,
+        )
         return self.connected
diff --git a/virl2_client/models/lab.py b/virl2_client/models/lab.py
index 814c6a4..87a679c 100644
--- a/virl2_client/models/lab.py
+++ b/virl2_client/models/lab.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -24,20 +24,29 @@
 import logging
 import time
 import warnings
-from typing import TYPE_CHECKING, Any, Iterable, Optional
+from typing import TYPE_CHECKING, Any, Iterable
 
 from httpx import HTTPStatusError
 
 from ..exceptions import (
+    AnnotationNotFound,
     ElementAlreadyExists,
     InterfaceNotFound,
+    InvalidAnnotationType,
     LabNotFound,
     LinkNotFound,
     NodeNotFound,
     VirlException,
 )
-from ..utils import check_stale, locked
+from ..utils import check_stale, get_url_from_template, locked
 from ..utils import property_s as property
+from .annotation import (
+    Annotation,
+    AnnotationEllipse,
+    AnnotationLine,
+    AnnotationRectangle,
+    AnnotationText,
+)
 from .cl_pyats import ClPyats
 from .interface import Interface
 from .link import Link
@@ -46,6 +55,7 @@
 if TYPE_CHECKING:
     import httpx
 
+    from .annotation import AnnotationType
     from .resource_pools import ResourcePool, ResourcePoolManagement
 
 
@@ -53,37 +63,68 @@
 
 
 class Lab:
+    _URL_TEMPLATES = {
+        "lab": "labs/{lab_id}",
+        "nodes": "labs/{lab_id}/nodes?{CONFIG_MODE}",
+        "nodes_populated": "labs/{lab_id}/nodes?populate_interfaces=true&{CONFIG_MODE}",
+        "nodes_operational": "labs/{lab_id}/nodes?data=true&operational=true"
+        "&exclude_configurations=true",
+        "links": "labs/{lab_id}/links",
+        "interfaces": "labs/{lab_id}/interfaces",
+        "simulation_stats": "labs/{lab_id}/simulation_stats",
+        "lab_element_state": "labs/{lab_id}/lab_element_state",
+        "check_if_converged": "labs/{lab_id}/check_if_converged",
+        "start": "labs/{lab_id}/start",
+        "stop": "labs/{lab_id}/stop",
+        "state": "labs/{lab_id}/state",
+        "wipe": "labs/{lab_id}/wipe",
+        "events": "labs/{lab_id}/events",
+        "build_configurations": "build_configurations?lab_id={lab_id}",
+        "topology": "labs/{lab_id}/topology",
+        "pyats_testbed": "labs/{lab_id}/pyats_testbed",
+        "layer3_addresses": "labs/{lab_id}/layer3_addresses",
+        "download": "labs/{lab_id}/download",
+        "groups": "labs/{lab_id}/groups",
+        "connector_mappings": "labs/{lab_id}/connector_mappings",
+        "resource_pools": "labs/{lab_id}/resource_pools",
+        "annotations": "labs/{lab_id}/annotations",
+    }
+
     def __init__(
         self,
-        title: Optional[str],
+        title: str | None,
         lab_id: str,
         session: httpx.Client,
         username: str,
         password: str,
-        auto_sync=True,
-        auto_sync_interval=1.0,
-        wait=True,
-        wait_max_iterations=500,
-        wait_time=5,
-        hostname: Optional[str] = None,
-        resource_pool_manager: Optional[ResourcePoolManagement] = None,
+        auto_sync: bool = True,
+        auto_sync_interval: float = 1.0,
+        wait: bool = True,
+        wait_max_iterations: int = 500,
+        wait_time: int = 5,
+        hostname: str | None = None,
+        resource_pool_manager: ResourcePoolManagement | None = None,
     ) -> None:
         """
-        A VIRL2 lab network topology. Contains nodes, links and interfaces.
-
-        :param title: Name / title of the lab
-        :param lab_id: A lab ID
-        :param session: httpx Client session
-        :param username: Username of the user to authenticate
-        :param password: Password of the user to authenticate
-        :param auto_sync: Should local changes sync to the server automatically
-        :param auto_sync_interval: Interval to auto sync in seconds
-        :param wait: Wait for convergence on backend
-        :param wait_max_iterations: Maximum number of tries or calls for convergence
-        :param wait_time: Time to sleep between calls for convergence on backend
-        :param hostname: Force hostname/ip and port for pyATS console terminal server
-        :param resource_pool_manager: a ResourcePoolManagement object
-            shared with parent ClientLibrary
+        A VIRL2 lab network topology.
+
+        :param title: Name or title of the lab.
+        :param lab_id: Lab ID.
+        :param session: The httpx-based HTTP client for this session with the server.
+        :param username: Username of the user to authenticate.
+        :param password: Password of the user to authenticate.
+        :param auto_sync: A flag indicating whether local changes should be
+            automatically synced to the server.
+        :param auto_sync_interval: Interval in seconds for automatic syncing.
+        :param wait: A flag indicating whether to wait for convergence on the backend.
+        :param wait_max_iterations: Maximum number of iterations for convergence.
+        :param wait_time: Time in seconds to sleep between convergence calls
+            on the backend.
+        :param hostname: Hostname/IP and port for pyATS console terminal server.
+        :param resource_pool_manager: ResourcePoolManagement object shared
+            with parent ClientLibrary.
+        :raises VirlException: If the lab object is created without
+            a resource pool manager.
         """
 
         self.username = username
@@ -99,17 +140,22 @@ def __init__(
         self._nodes: dict[str, Node] = {}
         """
         Dictionary containing all nodes in the lab.
-        It maps node identifier to `models.Node`
+        It maps node identifier to `models.Node`.
         """
         self._links: dict[str, Link] = {}
         """
         Dictionary containing all links in the lab.
-        It maps link identifier to `models.Link`
+        It maps link identifier to `models.Link`.
         """
         self._interfaces: dict[str, Interface] = {}
         """
         Dictionary containing all interfaces in the lab.
-        It maps interface identifier to `models.Interface`
+        It maps interface identifier to `models.Interface`.
+        """
+        self._annotations: dict[str, Annotation] = {}
+        """
+        Dictionary containing all annotations in the lab.
+        It maps annotation identifier to `models.Annotation`.
         """
         self.events: list = []
         self.pyats = ClPyats(self, hostname)
@@ -136,6 +182,7 @@ def __init__(
         self._resource_pool_manager = resource_pool_manager
         self._resource_pools = []
         self._stale = False
+        self._synced_configs = True
 
     def __len__(self):
         return len(self._nodes)
@@ -154,7 +201,28 @@ def __repr__(self):
             self.wait_for_convergence,
         )
 
-    def need_to_wait(self, local_wait: Optional[bool]) -> bool:
+    def _url_for(self, endpoint, **kwargs):
+        """
+        Generate the URL for a given API endpoint.
+
+        :param endpoint: The desired endpoint.
+        :param **kwargs: Keyword arguments used to format the URL.
+        :returns: The formatted URL.
+        """
+        kwargs["lab_id"] = self._id
+        return get_url_from_template(endpoint, self._URL_TEMPLATES, kwargs)
+
+    def need_to_wait(self, local_wait: bool | None) -> bool:
+        """
+        Check if waiting is required.
+
+        If `local_wait` is `None`, return the value of `wait_for_convergence`.
+        If `local_wait` is not a boolean, raise a `ValueError`.
+
+        :param local_wait: Local wait flag.
+        :returns: True if waiting is required, False otherwise.
+        :raises ValueError: If `local_wait` is not a boolean or None.
+        """
         if local_wait is None:
             return self.wait_for_convergence
         if not isinstance(local_wait, bool):
@@ -164,6 +232,7 @@ def need_to_wait(self, local_wait: Optional[bool]) -> bool:
     @check_stale
     @locked
     def sync_statistics_if_outdated(self) -> None:
+        """Sync statistics if they are outdated."""
         timestamp = time.time()
         if (
             self.auto_sync
@@ -174,6 +243,7 @@ def sync_statistics_if_outdated(self) -> None:
     @check_stale
     @locked
     def sync_states_if_outdated(self) -> None:
+        """Sync states if they are outdated."""
         timestamp = time.time()
         if (
             self.auto_sync
@@ -184,6 +254,7 @@ def sync_states_if_outdated(self) -> None:
     @check_stale
     @locked
     def sync_l3_addresses_if_outdated(self) -> None:
+        """Sync L3 addresses if they are outdated."""
         timestamp = time.time()
         if (
             self.auto_sync
@@ -193,17 +264,22 @@ def sync_l3_addresses_if_outdated(self) -> None:
 
     @check_stale
     @locked
-    def sync_topology_if_outdated(self) -> None:
+    def sync_topology_if_outdated(self, exclude_configurations=True) -> None:
+        """Sync the topology if it is outdated."""
         timestamp = time.time()
-        if (
+        if not (exclude_configurations or self._synced_configs):
+            self._sync_topology(exclude_configurations=False)
+        elif (
             self.auto_sync
             and timestamp - self._last_sync_topology_time > self.auto_sync_interval
         ):
-            self._sync_topology(exclude_configurations=True)
+            self._sync_topology(exclude_configurations=exclude_configurations)
+            self._synced_configs = not exclude_configurations
 
     @check_stale
     @locked
     def sync_operational_if_outdated(self) -> None:
+        """Sync the operational data if it is outdated."""
         timestamp = time.time()
         if (
             self.auto_sync
@@ -213,154 +289,125 @@ def sync_operational_if_outdated(self) -> None:
 
     @property
     def id(self) -> str:
-        """
-        Returns the ID of the lab (a 6 digit hex string).
-
-        :returns: The Lab ID
-        """
-
+        """Return the ID of the lab (a 6 digit hex string)."""
         return self._id
 
     @property
-    def title(self) -> Optional[str]:
-        """
-        Returns the title of the lab.
-
-        :returns: The lab name
-        """
+    def title(self) -> str | None:
+        """Return the title of the lab."""
         self.sync_topology_if_outdated()
         return self._title
 
     @title.setter
     def title(self, value: str) -> None:
-        """
-        Set the title of the lab.
-
-        :param value: The new lab title
-        """
+        """Set the title of the lab."""
         self._set_property("title", value)
 
     @property
     def notes(self) -> str:
-        """
-        Returns the notes of the lab.
-
-        :returns: The lab name
-        """
+        """Return the notes of the lab."""
         self.sync_topology_if_outdated()
         return self._notes
 
     @notes.setter
     def notes(self, value: str) -> None:
-        """
-        Set the notes of the lab.
-
-        :param value: The new lab notes
-        """
+        """Set the notes of the lab."""
         self._set_property("notes", value)
 
     @property
     def description(self) -> str:
-        """
-        Returns the description of the lab.
-
-        :returns: The lab name
-        """
+        """Return the description of the lab."""
         self.sync_topology_if_outdated()
         return self._description
 
     @description.setter
     def description(self, value: str) -> None:
-        """
-        Set the description of the lab.
-
-        :param value: The new lab description
-        """
+        """Set the description of the lab."""
         self._set_property("description", value)
 
     @check_stale
     @locked
     def _set_property(self, prop: str, value: Any):
-        url = self.lab_base_url
-        self._session.patch(url, json={prop: value})
-        setattr(self, f"_{prop}", value)
-
-    @property
-    def session(self) -> httpx.Client:
         """
-        Returns the API client session object.
+        Set the value of a lab property both locally and on the server.
 
-        :returns: The session object
+        :param prop: The name of the property.
+        :param value: The new value of the property.
         """
-        return self._session
+        url = self._url_for("lab")
+        self._session.patch(url, json={prop: value})
+        setattr(self, f"_{prop}", value)
 
     @property
     def owner(self) -> str:
-        """
-        Returns the owner of the lab.
-
-        :returns: A username
-        """
+        """Return the owner of the lab."""
         self.sync_topology_if_outdated()
         return self._owner
 
     @property
     def resource_pools(self) -> list[ResourcePool]:
+        """Return the list of resource pools this lab's nodes belong to."""
         self.sync_operational_if_outdated()
         return self._resource_pools
 
     def nodes(self) -> list[Node]:
         """
-        Returns the list of nodes in the lab.
+        Return the list of nodes in the lab.
 
-        :returns: A list of Node objects
+        :returns: A list of Node objects.
         """
         self.sync_topology_if_outdated()
         return list(self._nodes.values())
 
     def links(self) -> list[Link]:
         """
-        Returns the list of links in the lab.
+        Return the list of links in the lab.
 
-        :returns: A list of Link objects
+        :returns: A list of Link objects.
         """
         self.sync_topology_if_outdated()
         return list(self._links.values())
 
     def interfaces(self) -> list[Interface]:
         """
-        Returns the list of interfaces in the lab.
+        Return the list of interfaces in the lab.
 
-        :returns: A list of Interface objects
+        :returns: A list of Interface objects.
         """
         self.sync_topology_if_outdated()
         return list(self._interfaces.values())
 
-    @property
-    def lab_base_url(self) -> str:
-        return "labs/{}".format(self._id)
+    def annotations(self) -> list[AnnotationType]:
+        """
+        Return the list of annotations in the lab.
+
+        :returns: A list of Annotation objects.
+        """
+        self.sync_topology_if_outdated()
+        return list(self._annotations.values())
 
     @property
     @locked
     def statistics(self) -> dict:
         """
-        Returns some statistics about the lab.
+        Return lab statistics.
 
-        :returns: A dictionary with stats of the lab
+        :returns: A dictionary with stats of the lab.
         """
         return {
             "nodes": len(self._nodes),
             "links": len(self._links),
             "interfaces": len(self._interfaces),
+            "annotations": len(self._annotations),
         }
 
     def get_node_by_id(self, node_id: str) -> Node:
         """
-        Returns the node identified by the ID.
+        Return the node identified by the ID.
 
-        :param node_id: ID of the node to be returned
-        :returns: A Node object
-        :raises NodeNotFound: If node not found
+        :param node_id: ID of the node to be returned.
+        :returns: A Node object.
+        :raises NodeNotFound: If the node is not found.
         """
         self.sync_topology_if_outdated()
         try:
@@ -370,11 +417,11 @@ def get_node_by_id(self, node_id: str) -> Node:
 
     def get_node_by_label(self, label: str) -> Node:
         """
-        Returns the node identified by the label.
+        Return the node identified by the label.
 
-        :param label: label of the node to be returned
-        :returns: A Node object
-        :raises NodeNotFound: If node not found
+        :param label: Label of the node to be returned.
+        :returns: A Node object.
+        :raises NodeNotFound: If the node is not found.
         """
         self.sync_topology_if_outdated()
         for node in self._nodes.values():
@@ -384,11 +431,11 @@ def get_node_by_label(self, label: str) -> Node:
 
     def get_interface_by_id(self, interface_id: str) -> Interface:
         """
-        Returns the interface identified by the ID.
+        Return the interface identified by the ID.
 
-        :param interface_id: ID of the interface to be returned
-        :returns: An Interface object
-        :raises InterfaceNotFound: If interface not found
+        :param interface_id: ID of the interface to be returned.
+        :returns: An Interface object.
+        :raises InterfaceNotFound: If the interface is not found.
         """
         self.sync_topology_if_outdated()
         try:
@@ -398,11 +445,11 @@ def get_interface_by_id(self, interface_id: str) -> Interface:
 
     def get_link_by_id(self, link_id: str) -> Link:
         """
-        Returns the link identified by the ID.
+        Return the link identified by the ID.
 
-        :param link_id: ID of the interface to be returned
-        :returns: A Link object
-        :raises LinkNotFound: If interface not found
+        :param link_id: ID of the link to be returned.
+        :returns: A Link object.
+        :raises LinkNotFound: If the link is not found.
         """
         self.sync_topology_if_outdated()
         try:
@@ -413,20 +460,20 @@ def get_link_by_id(self, link_id: str) -> Link:
     @staticmethod
     def get_link_by_nodes(node1: Node, node2: Node) -> Link:
         """
-        DEPRECATED
+        DEPRECATED: Use `Node.get_link_to()` to get one link
+        or `Node.get_links_to()` to get all links.
+        (Reason: redundancy)
 
-        Returns ONE of the links identified by two node objects.
+        Return ONE of the links identified by two node objects.
 
-        Deprecated. Use `Node.get_link_to` to get one link
-        or `Node.get_links_to` to get all links.
-
-        :param node1: the first node
-        :param node2: the second node
-        :returns: one of links between the nodes
-        :raises LinkNotFound: If no such link exists
+        :param node1: The first node.
+        :param node2: The second node.
+        :returns: One of links between the nodes.
+        :raises LinkNotFound: If no such link exists.
         """
         warnings.warn(
-            "Deprecated, use Node.get_link_to or Node.get_links_to instead.",
+            "'Lab.get_link_by_nodes()' is deprecated. "
+            "Use 'Node.get_link_to()' or 'Node.get_links_to()' instead.",
             DeprecationWarning,
         )
         if not (links := node1.get_links_to(node2)):
@@ -434,32 +481,47 @@ def get_link_by_nodes(node1: Node, node2: Node) -> Link:
         return links[0]
 
     @staticmethod
-    def get_link_by_interfaces(iface1: Interface, iface2: Interface) -> Optional[Link]:
+    def get_link_by_interfaces(iface1: Interface, iface2: Interface) -> Link | None:
         """
-        DEPRECATED
-
-        Returns the link identified by two interface objects.
+        DEPRECATED: Use `Interface.get_link_to()` instead.
+        (Reason: redundancy)
 
-        Deprecated. Use `Interface.get_link_to` to get link.
+        Return the link identified by two interface objects.
 
-        :param iface1: the first interface
-        :param iface2: the second interface
-        :returns: the link between the interfaces
-        :raises LinkNotFound: If no such link exists
+        :param iface1: The first interface.
+        :param iface2: The second interface.
+        :returns: The link between the interfaces.
+        :raises LinkNotFound: If no such link exists.
         """
         warnings.warn(
-            "Deprecated, use Interface.get_link_to instead.", DeprecationWarning
+            "'Lab.get_link_by_interfaces()' is deprecated. "
+            "Use 'Interface.get_link_to()' instead.",
+            DeprecationWarning,
         )
         if (link := iface1.link) is not None and iface2 in link.interfaces:
             return link
         raise LinkNotFound
 
+    def get_annotation_by_id(self, annotation_id: str) -> AnnotationType:
+        """
+        Return the annotation identified by the ID.
+
+        :param annotation_id: ID of the annotation to be returned
+        :returns: An Annotation object.
+        :raises AnnotationNotFound: If annotation is not found.
+        """
+        self.sync_topology_if_outdated()
+        try:
+            return self._annotations[annotation_id]
+        except KeyError:
+            raise AnnotationNotFound(annotation_id)
+
     def find_nodes_by_tag(self, tag: str) -> list[Node]:
         """
-        Returns the nodes identified by the given tag.
+        Return the nodes identified by the given tag.
 
-        :param tag: tag of the nodes to be returned
-        :returns: a list of nodes
+        :param tag: The tag by which to search.
+        :returns: A list of nodes.
         """
         self.sync_topology_if_outdated()
         return [node for node in self.nodes() if tag in node.tags()]
@@ -472,27 +534,34 @@ def create_node(
         node_definition: str,
         x: int = 0,
         y: int = 0,
-        wait: Optional[bool] = None,
+        wait: bool | None = None,
         populate_interfaces: bool = False,
         **kwargs,
     ) -> Node:
         """
-        Creates a node in the lab with the given parameters.
+        Create a node in the lab with the given parameters.
 
-        :param label: Label
-        :param node_definition: Node definition to use
-        :param x: x coordinate
-        :param y: y coordinate
-        :param wait: Wait for convergence (if left at default,
-            the lab wait property takes precedence)
-        :param populate_interfaces: automatically create pre-defined number
-            of interfaces on node creation
-        :returns: a Node object
+        :param label: Label.
+        :param node_definition: Node definition.
+        :param x: The X coordinate.
+        :param y: The Y coordinate.
+        :param wait: A flag indicating whether to wait for convergence.
+            If left at the default value, the lab's wait property is used instead.
+        :param populate_interfaces: Automatically create a pre-defined number
+            of interfaces on node creation.
+        :returns: A Node object.
         """
-        # TODO: warn locally if label in use already?
-        url = self.lab_base_url + "/nodes"
+        try:
+            self.get_node_by_label(label)
+            _LOGGER.warning(f"Node with label {label} already exists.")
+        except NodeNotFound:
+            pass
+
         if populate_interfaces:
-            url += "?populate_interfaces=true"
+            url = self._url_for("nodes_populated")
+        else:
+            url = self._url_for("nodes")
+
         kwargs["label"] = label
         kwargs["node_definition"] = node_definition
         kwargs["x"] = x
@@ -510,18 +579,23 @@ def create_node(
         for key in ("image_definition", "configuration"):
             if key not in kwargs:
                 kwargs[key] = None
-
-        if "compute_id" in kwargs:
-            kwargs.pop("compute_id")
         kwargs["resource_pool"] = None
+        kwargs.pop("compute_id", None)
         node = self._create_node_local(node_id, **kwargs)
         return node
 
     def add_node_local(self, *args, **kwargs):
+        """
+        DEPRECATED: Use `.create_node()` instead.
+        (Reason: only creates a node in the client, which is not useful;
+        if really needed, use `._create_node_local()`)
+
+        Creates a node in the client, but not on the server.
+        """
         warnings.warn(
-            "Deprecated. You probably want .create_node instead; "
-            "if you really want to create a node locally only, "
-            "use ._create_node_local.",
+            "'Lab.add_node_local' is deprecated. You probably want .create_node "
+            "instead. (If you really want to create a node locally only, "
+            "use '._create_node_local()'.)",
             DeprecationWarning,
         )
         return self._create_node_local(*args, **kwargs)
@@ -532,24 +606,24 @@ def _create_node_local(
         node_id: str,
         label: str,
         node_definition: str,
-        image_definition: Optional[str],
-        configuration: Optional[str],
+        image_definition: str | None,
+        configuration: list[dict[str, str]] | str | None,
         x: int,
         y: int,
-        ram: Optional[int] = None,
-        cpus: Optional[int] = None,
-        cpu_limit: Optional[int] = None,
-        data_volume: Optional[int] = None,
-        boot_disk_size: Optional[int] = None,
+        ram: int | None = None,
+        cpus: int | None = None,
+        cpu_limit: int | None = None,
+        data_volume: int | None = None,
+        boot_disk_size: int | None = None,
         hide_links: bool = False,
-        tags: Optional[list] = None,
-        compute_id: Optional[str] = None,
-        resource_pool: Optional[ResourcePool] = None,
+        tags: list | None = None,
+        compute_id: str | None = None,
+        resource_pool: ResourcePool | None = None,
+        parameters: dict = {},
+        pinned_compute_id: str | None = None,
     ) -> Node:
         """Helper function to add a node to the client library."""
         if tags is None:
-            # TODO: see if can deprecate now tags set automatically
-            # on server at creation
             tags = []
 
         node = Node(
@@ -569,6 +643,8 @@ def _create_node_local(
             hide_links,
             tags,
             resource_pool,
+            parameters,
+            pinned_compute_id,
         )
         if compute_id is not None:
             node._compute_id = compute_id
@@ -577,18 +653,18 @@ def _create_node_local(
 
     @check_stale
     @locked
-    def remove_node(self, node: Node | str, wait: Optional[bool] = None) -> None:
+    def remove_node(self, node: Node | str, wait: bool | None = None) -> None:
         """
-        Removes a node from the lab.
+        Remove a node from the lab.
 
         If you have a node object, you can also simply do::
 
             node.remove()
 
 
-        :param node: the node object or ID
-        :param wait: Wait for convergence (if left at default,
-            the lab wait property takes precedence)
+        :param node: The node object or ID.
+        :param wait: A flag indicating whether to wait for convergence.
+            If left at the default value, the lab's wait property is used instead.
         """
         if isinstance(node, str):
             node = self.get_node_by_id(node)
@@ -597,7 +673,7 @@ def remove_node(self, node: Node | str, wait: Optional[bool] = None) -> None:
 
         if self.need_to_wait(wait):
             self.wait_until_lab_converged()
-        _LOGGER.debug("%s node removed from lab %s", node._id, self._id)
+        _LOGGER.debug(f"{node._id} node removed from lab {self._id}")
 
     @locked
     def _remove_node_local(self, node: Node) -> None:
@@ -614,34 +690,35 @@ def _remove_node_local(self, node: Node) -> None:
             pass
 
     @locked
-    def remove_nodes(self, wait: Optional[bool] = None) -> None:
+    def remove_nodes(self, wait: bool | None = None) -> None:
         """
         Remove all nodes from the lab.
 
-        :param wait: Wait for convergence (if left at default,
-            the lab wait property takes precedence)
+        :param wait: A flag indicating whether to wait for convergence.
+            If left at the default value, the lab's wait property is used instead.
         """
-        # TODO: see if this is used - in testing?
+        # Use case - user was assigned one lab, wants to reset work;
+        # can't delete lab, so removing all nodes is the only option
         for node in list(self._nodes.values()):
             self.remove_node(node, wait=False)
 
         if self.need_to_wait(wait):
             self.wait_until_lab_converged()
-        _LOGGER.debug("all nodes removed from lab %s", self._id)
+        _LOGGER.debug(f"all nodes removed from lab {self._id}")
 
     @check_stale
     @locked
-    def remove_link(self, link: Link | str, wait: Optional[bool] = None) -> None:
+    def remove_link(self, link: Link | str, wait: bool | None = None) -> None:
         """
-        Removes a link from the lab.
+        Remove a link from the lab.
 
         If you have a link object, you can also simply do::
 
             link.remove()
 
-        :param link: the link object or ID
-        :param wait: Wait for convergence (if left at default,
-            the lab wait property takes precedence)
+        :param link: The link object or ID.
+        :param wait: A flag indicating whether to wait for convergence.
+            If left at the default value, the lab's wait property is used instead.
         """
         if isinstance(link, str):
             link = self.get_link_by_id(link)
@@ -650,7 +727,7 @@ def remove_link(self, link: Link | str, wait: Optional[bool] = None) -> None:
 
         if self.need_to_wait(wait):
             self.wait_until_lab_converged()
-        _LOGGER.debug("link %s removed from lab %s", link._id, self._id)
+        _LOGGER.debug(f"link {link._id} removed from lab {self._id}")
 
     @locked
     def _remove_link_local(self, link: Link) -> None:
@@ -666,18 +743,18 @@ def _remove_link_local(self, link: Link) -> None:
     @check_stale
     @locked
     def remove_interface(
-        self, iface: Interface | str, wait: Optional[bool] = None
+        self, iface: Interface | str, wait: bool | None = None
     ) -> None:
         """
-        Removes an interface from the lab.
+        Remove an interface from the lab.
 
         If you have an interface object, you can also simply do::
 
             interface.remove()
 
-        :param iface: the interface object or ID
-        :param wait: Wait for convergence (if left at default,
-            the lab wait property takes precedence)
+        :param iface: The interface object or ID.
+        :param wait: A flag indicating whether to wait for convergence.
+            If left at the default value, the lab's wait property is used instead.
         """
         if isinstance(iface, str):
             iface = self.get_interface_by_id(iface)
@@ -687,7 +764,7 @@ def remove_interface(
 
         if self.need_to_wait(wait):
             self.wait_until_lab_converged()
-        _LOGGER.debug("interface %s removed from lab %s", iface._id, self._id)
+        _LOGGER.debug(f"interface {iface._id} removed from lab {self._id}")
 
     @locked
     def _remove_interface_local(self, iface: Interface) -> None:
@@ -704,25 +781,65 @@ def _remove_interface_local(self, iface: Interface) -> None:
             # locally due to auto-sync
             pass
 
+    @check_stale
+    @locked
+    def remove_annotation(
+        self,
+        annotation: Annotation | str,
+    ) -> None:
+        """
+        Remove an annotation from the lab.
+
+        If you have an annotation object, you can also simply do::
+
+            annotation.remove()
+
+        :param annotation: The annotation object or ID.
+        """
+        if isinstance(annotation, str):
+            annotation = self.get_annotation_by_id(annotation)
+        annotation._remove_on_server()
+        self._remove_annotation_local(annotation)
+
+        _LOGGER.debug("%s annotation removed from lab %s", annotation._id, self._id)
+
+    @locked
+    def _remove_annotation_local(self, annotation: Annotation) -> None:
+        """Helper function to remove an annotation from the client library."""
+        try:
+            del self._annotations[annotation._id]
+            annotation._stale = True
+        except KeyError:
+            # element may already have been deleted on server,
+            # and removed locally due to auto-sync
+            pass
+
+    @locked
+    def remove_annotations(self) -> None:
+        """Remove all annotations from the lab."""
+        for ann in list(self._annotations.values()):
+            self.remove_annotation(ann)
+        _LOGGER.debug("all nodes removed from lab %s", self._id)
+
     @check_stale
     @locked
     def create_link(
-        self, i1: Interface | str, i2: Interface | str, wait: Optional[bool] = None
+        self, i1: Interface | str, i2: Interface | str, wait: bool | None = None
     ) -> Link:
         """
-        Creates a link between two interfaces
+        Create a link between two interfaces.
 
-        :param i1: the first interface object or ID
-        :param i2: the second interface object or ID
-        :param wait: Wait for convergence (if left at default,
-            the lab wait property takes precedence)
-        :returns: the created link
+        :param i1: The first interface object or ID.
+        :param i2: The second interface object or ID.
+        :param wait: A flag indicating whether to wait for convergence.
+            If left at the default value, the lab's wait property is used instead.
+        :returns: The created link.
         """
         if isinstance(i1, str):
             i1 = self.get_interface_by_id(i1)
         if isinstance(i2, str):
             i2 = self.get_interface_by_id(i2)
-        url = self.lab_base_url + "/links"
+        url = self._url_for("links")
         data = {
             "src_int": i1.id,
             "dst_int": i2.id,
@@ -741,7 +858,7 @@ def create_link(
     @check_stale
     @locked
     def _create_link_local(
-        self, i1: Interface, i2: Interface, link_id: str, label: Optional[str] = None
+        self, i1: Interface, i2: Interface, link_id: str, label: str | None = None
     ) -> Link:
         """Helper function to create a link in the client library."""
         link = Link(self, link_id, i1, i2, label)
@@ -752,11 +869,11 @@ def _create_link_local(
     @locked
     def connect_two_nodes(self, node1: Node, node2: Node) -> Link:
         """
-        Convenience method to connect two nodes within a lab.
+        Connect two nodes within a lab.
 
-        :param node1: the first node object
-        :param node2: the second node object
-        :returns: the created link
+        :param node1: The first node object.
+        :param node2: The second node object.
+        :returns: The created link.
         """
         iface1 = node1.next_available_interface() or node1.create_interface()
         iface2 = node2.next_available_interface() or node2.create_interface()
@@ -765,34 +882,33 @@ def connect_two_nodes(self, node1: Node, node2: Node) -> Link:
     @check_stale
     @locked
     def create_interface(
-        self, node: Node | str, slot: Optional[int] = None, wait: Optional[bool] = None
+        self, node: Node | str, slot: int | None = None, wait: bool | None = None
     ) -> Interface:
         """
         Create an interface in the next available slot, or, if a slot is specified,
         in all available slots up to and including the specified slot.
 
-        :param node: The node on which the interface is created
-        :param slot: (optional)
-        :param wait: Wait for convergence (if left at default,
-            the lab wait property takes precedence)
-        :returns: The newly created interface
+        :param node: The node on which the interface is created.
+        :param slot: The slot number to create the interface in.
+        :param wait: A flag indicating whether to wait for convergence.
+            If left at the default value, the lab's wait property is used instead.
+        :returns: The newly created interface.
         """
         if isinstance(node, str):
             node = self.get_node_by_id(node)
-        url = self.lab_base_url + "/interfaces"
+        url = self._url_for("interfaces")
         payload: dict[str, str | int] = {"node": node.id}
         if slot is not None:
             payload["slot"] = slot
         result = self._session.post(url, json=payload).json()
         if isinstance(result, dict):
-            # in case API returned just one interface, pack it into the list:
+            # in case API returned just one interface, pack it into a list:
             result = [result]
 
         if self.need_to_wait(wait):
             self.wait_until_lab_converged()
 
-        # TODO: need to import the topology then
-        desired_interface: Optional[Interface] = None
+        desired_interface: Interface | None = None
         for iface in result:
             lab_interface = self._create_interface_local(
                 iface_id=iface["id"],
@@ -817,7 +933,7 @@ def _create_interface_local(
         iface_id: str,
         label: str,
         node: Node,
-        slot: Optional[int],
+        slot: int | None,
         iface_type: str = "physical",
     ) -> Interface:
         """Helper function to create an interface in the client library."""
@@ -832,26 +948,77 @@ def _create_interface_local(
             iface._type = iface_type
         return iface
 
-    @staticmethod
-    def _get_element_from_data(data: dict, element: str) -> int:
-        try:
-            return int(data[element])
-        except (TypeError, KeyError):
-            return 0
+    @check_stale
+    @locked
+    def create_annotation(self, annotation_type: str, **kwargs) -> AnnotationType:
+        """
+        Create a lab annotation.
+
+        :param type: Type of the annotation (rectangle, ellipse, line or text).
+        :returns: The created annotation.
+        """
+        url = self._url_for("annotations")
+
+        # create POST json with default annotation property values
+        # override some values by new, expected ones
+        annotation_data = Annotation.get_default_property_values(annotation_type)
+        for ppty, value in kwargs.items():
+            annotation_data[ppty] = value
+        annotation_data["type"] = annotation_type
+
+        response = self._session.post(url, json=annotation_data)
+        res_annotation = response.json()
+
+        # after adding an annotation to an empty lab, consider it "initialized"
+        if not self._initialized:
+            self._initialized = True
+
+        annotation = self._create_annotation_local(
+            res_annotation["id"],
+            annotation_type,
+            **res_annotation,
+        )
+        return annotation
+
+    @check_stale
+    @locked
+    def _create_annotation_local(
+        self, annotation_id: str, _type: str, **kwargs
+    ) -> AnnotationType:
+        """Helper function to create a link in the client library."""
+        if _type == "rectangle":
+            annotation_class = AnnotationRectangle
+        elif _type == "ellipse":
+            annotation_class = AnnotationEllipse
+        elif _type == "line":
+            annotation_class = AnnotationLine
+        elif _type == "text":
+            annotation_class = AnnotationText
+        else:
+            raise InvalidAnnotationType
+
+        annotation = annotation_class(self, annotation_id, annotation_data=kwargs)
+        self._annotations[annotation_id] = annotation
+        return annotation
 
     @check_stale
     @locked
     def sync_statistics(self) -> None:
         """Retrieve the simulation statistic data from the back end server."""
-        url = self.lab_base_url + "/simulation_stats"
+
+        def _get_element_from_data(data: dict, element: str) -> int:
+            try:
+                return int(data[element])
+            except (TypeError, KeyError):
+                return 0
+
+        url = self._url_for("simulation_stats")
         states: dict[str, dict[str, dict]] = self._session.get(url).json()
         node_statistics = states.get("nodes", {})
         link_statistics = states.get("links", {})
         for node_id, node_data in node_statistics.items():
-            # TODO: standardise so if shutdown, then these are set to 0 on
-            # server side
-            disk_read = self._get_element_from_data(node_data, "block0_rd_bytes")
-            disk_write = self._get_element_from_data(node_data, "block0_wr_bytes")
+            disk_read = _get_element_from_data(node_data, "block0_rd_bytes")
+            disk_write = _get_element_from_data(node_data, "block0_wr_bytes")
 
             self._nodes[node_id].statistics = {
                 "cpu_usage": float(node_data.get("cpu_usage", 0)),
@@ -860,12 +1027,10 @@ def sync_statistics(self) -> None:
             }
 
         for link_id, link_data in link_statistics.items():
-            # TODO: standardise so if shutdown, then these are set to 0 on
-            # server side
-            readbytes = self._get_element_from_data(link_data, "readbytes")
-            readpackets = self._get_element_from_data(link_data, "readpackets")
-            writebytes = self._get_element_from_data(link_data, "writebytes")
-            writepackets = self._get_element_from_data(link_data, "writepackets")
+            readbytes = _get_element_from_data(link_data, "readbytes")
+            readpackets = _get_element_from_data(link_data, "readpackets")
+            writebytes = _get_element_from_data(link_data, "writebytes")
+            writepackets = _get_element_from_data(link_data, "writepackets")
 
             link = self._links[link_id]
             link.statistics = {
@@ -890,10 +1055,8 @@ def sync_statistics(self) -> None:
     @check_stale
     @locked
     def sync_states(self) -> None:
-        """
-        Sync all the states of the various elements with the back end server.
-        """
-        url = self.lab_base_url + "/lab_element_state"
+        """Sync all the states of the various elements with the backend server."""
+        url = self._url_for("lab_element_state")
         states: dict[str, dict[str, str]] = self._session.get(url).json()
         for node_id, node_state in states["nodes"].items():
             self._nodes[node_id]._state = node_state
@@ -902,9 +1065,6 @@ def sync_states(self) -> None:
             try:
                 iface = ifaces.pop(interface_id)
             except KeyError:
-                # TODO: handle loopbacks created server-side
-                # check how the UI handles these created today
-                # - extra call after node created?
                 pass
             else:
                 iface._state = interface_state
@@ -917,146 +1077,144 @@ def sync_states(self) -> None:
 
     @check_stale
     def wait_until_lab_converged(
-        self, max_iterations: Optional[int] = None, wait_time: Optional[int] = None
+        self, max_iterations: int | None = None, wait_time: int | None = None
     ) -> None:
-        """Wait until lab converges."""
+        """
+        Wait until the lab converges.
+
+        :param max_iterations: The maximum number of iterations to wait.
+        :param wait_time: The time to wait between iterations.
+        """
         max_iter = (
             self.wait_max_iterations if max_iterations is None else max_iterations
         )
         wait_time = self.wait_time if wait_time is None else wait_time
-        _LOGGER.info("Waiting for lab %s to converge", self._id)
+        _LOGGER.info(f"Waiting for lab {self._id} to converge.")
         for index in range(max_iter):
             converged = self.has_converged()
             if converged:
-                _LOGGER.info("Lab %s has booted", self._id)
+                _LOGGER.info(f"Lab {self._id} has booted.")
                 return
 
             if index % 10 == 0:
                 _LOGGER.info(
-                    "Lab has not converged, attempt %s/%s, waiting...",
-                    index,
-                    max_iter,
+                    f"Lab has not converged, attempt {index}/{max_iter}, waiting..."
                 )
             time.sleep(wait_time)
 
-        msg = "Lab %s has not converged, maximum tries %s exceeded" % (
-            self.id,
-            max_iter,
-        )
+        msg = f"Lab {self.id} has not converged, maximum tries {max_iter} exceeded."
         _LOGGER.error(msg)
-        # after maximum retries are exceeded and lab has not converged
-        # error must be raised - it makes no sense to just log info
-        # and let client fail with something else if wait is explicitly
-        # specified
+        # After maximum retries are exceeded and lab has not converged,
+        # an error must be raised - it makes no sense to just log info
+        # and let the client fail with something else if wait is explicitly
+        # specified.
         raise RuntimeError(msg)
 
     @check_stale
     def has_converged(self) -> bool:
-        url = self.lab_base_url + "/check_if_converged"
+        """
+        Check whether the lab has converged.
+
+        :returns: True if the lab has converged, False otherwise.
+        """
+        url = self._url_for("check_if_converged")
         converged = self._session.get(url).json()
         return converged
 
     @check_stale
-    def start(self, wait: Optional[bool] = None) -> None:
+    def start(self, wait: bool | None = None) -> None:
         """
         Start all the nodes and links in the lab.
 
-        :param wait: Wait for convergence (if left at default,
-            the lab wait property takes precedence)
+        :param wait: A flag indicating whether to wait for convergence.
+            If left at the default value, the lab's wait property is used instead.
         """
-        url = self.lab_base_url + "/start"
+        url = self._url_for("start")
         self._session.put(url)
         if self.need_to_wait(wait):
             self.wait_until_lab_converged()
-        _LOGGER.debug("started lab: %s", self._id)
+        _LOGGER.debug(f"Started lab: {self._id}")
+
+    @check_stale
+    def stop(self, wait: bool | None = None) -> None:
+        """
+        Stop all the nodes and links in the lab.
+
+        :param wait: A flag indicating whether to wait for convergence.
+            If left at the default value, the lab's wait property is used instead.
+        """
+        url = self._url_for("stop")
+        self._session.put(url)
+        if self.need_to_wait(wait):
+            self.wait_until_lab_converged()
+        _LOGGER.debug(f"Stopped lab: {self._id}")
 
     @check_stale
     def state(self) -> str:
         """
-        Returns the state of the lab.
+        Return the state of the lab.
 
-        :returns: The state as text
+        :returns: The state as text.
         """
         if self._state is None or getattr(self._session, "lock", None) is None:
             # no lock == event listening not enabled
-            url = self.lab_base_url + "/state"
+            url = self._url_for("state")
             response = self._session.get(url)
             self._state = response.json()
 
-        _LOGGER.debug("lab state: %s -> %s", self._id, self._state)
+        _LOGGER.debug(f"lab state: {self._id} -> {self._state}")
         return self._state
 
     def is_active(self) -> bool:
         """
-        Returns whether the lab is started.
+        Check if the lab is active.
 
-        :returns: Whether the lab is started
+        :return: True if the lab is active, False otherwise
         """
         return self.state() == "STARTED"
 
     @check_stale
     def details(self) -> dict[str, str | list | int]:
         """
-        Returns the lab details (including state) of the lab.
+        Retrieve the details of the lab, including its state.
 
-        :returns: The detailed lab state
+        :return: A dictionary containing the detailed lab state
         """
-        url = self.lab_base_url
+        url = self._url_for("lab")
         response = self._session.get(url)
-        _LOGGER.debug("lab state: %s -> %s", self._id, response.text)
+        _LOGGER.debug(f"lab state: {self._id} -> {response.text}")
         return response.json()
 
     @check_stale
-    def stop(self, wait: Optional[bool] = None) -> None:
-        """
-        Stops all the nodes and links in the lab.
-
-        :param wait: Wait for convergence (if left at default,
-            the lab wait property takes precedence)
-        """
-        url = self.lab_base_url + "/stop"
-        self._session.put(url)
-        if self.need_to_wait(wait):
-            self.wait_until_lab_converged()
-        _LOGGER.debug("stopped lab: %s", self._id)
-
-    @check_stale
-    def wipe(self, wait: Optional[bool] = None) -> None:
+    def wipe(self, wait: bool | None = None) -> None:
         """
         Wipe all the nodes and links in the lab.
 
-        :param wait: Wait for convergence (if left at default,
-            the lab wait property takes precedence)
+        :param wait: A flag indicating whether to wait for convergence.
+            If left at the default value, the lab's wait property is used instead.
         """
-        url = self.lab_base_url + "/wipe"
+        url = self._url_for("wipe")
         self._session.put(url)
         if self.need_to_wait(wait):
             self.wait_until_lab_converged()
-        _LOGGER.debug("wiped lab: %s", self._id)
+        _LOGGER.debug(f"wiped lab: {self._id}")
 
     def remove(self) -> None:
-        """
-        Removes the lab from the server. The lab has to
-        be stopped and wiped for this to work.
-
-        Use carefully, it removes current lab::
-
-            lab.remove()
-
-        """
-        self._remove_on_server()
-        self._stale = True
-
-    @check_stale
-    def _remove_on_server(self):
-        url = self.lab_base_url
+        """Remove the lab from the server. The lab must be stopped and wiped first."""
+        url = self._url_for("lab")
         response = self._session.delete(url)
-        _LOGGER.debug("removed lab: %s", response.text)
+        _LOGGER.debug(f"Removed lab: {response.text}")
+        self._stale = True
 
     @check_stale
     @locked
     def sync_events(self) -> bool:
-        url = self.lab_base_url + "/events"
+        """
+        Synchronize the events in the lab.
+
+        :returns: True if the events have changed, False otherwise
+        """
+        url = self._url_for("events")
         result = self._session.get(url).json()
         changed = self.events != result
         self.events = result
@@ -1065,11 +1223,10 @@ def sync_events(self) -> bool:
     @check_stale
     def build_configurations(self) -> None:
         """
-        Build basic configurations for all nodes in the lab which
-        do not already have a configuration and also do support
-        configuration building.
+        Build basic configurations for all nodes in the lab that do not
+        already have a configuration and support configuration building.
         """
-        url = "build_configurations?lab_id=" + self._id
+        url = self._url_for("build_configurations")
         self._session.get(url)
         # sync to get the updated configs
         self.sync_topology_if_outdated()
@@ -1079,25 +1236,24 @@ def build_configurations(self) -> None:
     def sync(
         self,
         topology_only=True,
-        with_node_configurations: Optional[bool] = None,
-        exclude_configurations=False,
+        with_node_configurations: bool | None = None,
+        exclude_configurations: bool | None = False,
     ) -> None:
         """
-        Synchronize current lab applying the changes that
-        were done in UI or in another ClientLibrary session::
-
-            lab.sync()
+        Synchronize the current lab, locally applying changes made to the server.
 
-        :param topology_only: do not sync statistics and IP addresses
-        :param with_node_configurations: DEPRECATED, does the opposite of
-            what is expected: disables syncing node configuration when True
-        :param exclude_configurations: disable syncing node configuration
+        :param topology_only: Only sync the topology without statistics and IP
+            addresses.
+        :param with_node_configurations: DEPRECATED: does the opposite of what
+            is expected. Use exclude_configurations instead.
+        :param exclude_configurations: Whether to exclude configurations
+            from synchronization.
         """
-
         if with_node_configurations is not None:
             warnings.warn(
-                'The argument "with_node_configurations" is deprecated, as it does '
-                "the opposite of what is expected. Use exclude_configurations instead.",
+                "Lab.sync(): The argument 'with_node_configurations' is deprecated, "
+                "as it does the opposite of what is expected. "
+                "Use exclude_configurations instead.",
                 DeprecationWarning,
             )
             exclude_configurations = with_node_configurations
@@ -1110,10 +1266,9 @@ def sync(
             self.sync_operational()
 
     @locked
-    def _sync_topology(self, exclude_configurations=False) -> None:
+    def _sync_topology(self, exclude_configurations: bool | None = False) -> None:
         """Helper function to sync topologies from the backend server."""
-        # TODO: check what happens if call twice
-        url = self.lab_base_url + "/topology"
+        url = self._url_for("topology")
         params = {"exclude_configurations": exclude_configurations}
         try:
             result = self._session.get(url, params=params)
@@ -1131,34 +1286,50 @@ def _sync_topology(self, exclude_configurations=False) -> None:
                 and f"Lab not found: {self._id}" in exc.response.text
             ):
                 self._stale = True
-            raise LabNotFound("Error syncing lab: {}".format(error_msg))
-            # TODO: get the error message from response/headers also?
+            raise LabNotFound(f"Error syncing lab: {error_msg}")
+
         topology = result.json()
+
         if self._initialized:
-            self.update_lab(topology, exclude_configurations)
+            self.update_lab(topology, exclude_configurations or False)
         else:
             self.import_lab(topology)
             self._initialized = True
+
         self._last_sync_topology_time = time.time()
 
     @locked
     def import_lab(self, topology: dict) -> None:
-        self._import_lab(topology)
+        """
+        Import a lab from a given topology.
 
+        :param topology: The topology to import.
+        """
+        self._import_lab(topology)
         self._handle_import_nodes(topology)
         self._handle_import_interfaces(topology)
         self._handle_import_links(topology)
+        self._handle_import_annotations(topology)
 
     @locked
-    def _import_lab(self, topology: dict[str, Any]) -> None:
-        """Replaces lab properties. Will raise KeyError if any property is missing."""
+    def _import_lab(self, topology: dict) -> None:
+        """
+        Replace lab properties with the given topology.
+
+        :param topology: The topology to import.
+        :raises KeyError: If any property is missing in the topology.
+        """
         lab_dict = topology.get("lab")
+
         if lab_dict is None:
-            warnings.warn(
-                "Labs created in older CML releases (schema version 0.0.5 or lower) "
-                "are deprecated. Use labs with schema version 0.1.0 or higher instead.",
-                DeprecationWarning,
-            )
+            # If we just created the lab, we skip the warning, since the
+            # lab post endpoint returns data in the old format
+            if not topology.pop("_created", False):
+                warnings.warn(
+                    "Labs created in older CML releases (schema version 0.0.5 or lower)"
+                    " are deprecated. Use labs with schema version 0.1.0 or higher.",
+                    DeprecationWarning,
+                )
             self._title = topology["lab_title"]
             self._description = topology["lab_description"]
             self._notes = topology["lab_notes"]
@@ -1171,10 +1342,17 @@ def _import_lab(self, topology: dict[str, Any]) -> None:
 
     @locked
     def _handle_import_nodes(self, topology: dict) -> None:
+        """
+        Handle the import of nodes from the given topology.
+
+        :param topology: The topology to import nodes from.
+        """
         for node in topology["nodes"]:
             node_id = node["id"]
+
             if node_id in self._nodes:
                 raise ElementAlreadyExists("Node already exists")
+
             interfaces = node.pop("interfaces", [])
             self._import_node(node_id, node)
 
@@ -1183,39 +1361,85 @@ def _handle_import_nodes(self, topology: dict) -> None:
 
             for iface in interfaces:
                 iface_id = iface["id"]
+
                 if iface_id in self._interfaces:
                     raise ElementAlreadyExists("Interface already exists")
+
                 self._import_interface(iface_id, node_id, iface)
 
     @locked
     def _handle_import_interfaces(self, topology: dict) -> None:
-        if "interfaces" in topology:
-            for iface in topology["interfaces"]:
-                iface_id = iface["id"]
-                node_id = iface["node"]
-                if iface_id in self._interfaces:
-                    raise ElementAlreadyExists("Interface already exists")
-                self._import_interface(iface_id, node_id, iface)
+        """
+        Handle the import of interfaces from the given topology.
+
+        :param topology: The topology to import interfaces from.
+        """
+        if "interfaces" not in topology:
+            return
+
+        for iface in topology["interfaces"]:
+            iface_id = iface["id"]
+            node_id = iface["node"]
+
+            if iface_id in self._interfaces:
+                raise ElementAlreadyExists("Interface already exists")
+
+            self._import_interface(iface_id, node_id, iface)
 
     @locked
     def _handle_import_links(self, topology: dict) -> None:
+        """
+        Handle the import of links from the given topology.
+
+        :param topology: The topology to import links from.
+        """
         for link in topology["links"]:
             link_id = link["id"]
+
             if link_id in self._links:
                 raise ElementAlreadyExists("Link already exists")
+
             iface_a_id = link["interface_a"]
             iface_b_id = link["interface_b"]
             label = link.get("label")
+
             self._import_link(link_id, iface_b_id, iface_a_id, label)
 
+    @locked
+    def _handle_import_annotations(self, topology: dict) -> None:
+        """
+        Handle the import of annotations from the given topology.
+
+        :param topology: The topology to import annotations from.
+        """
+        if "annotations" not in topology:
+            return
+
+        for annotation in topology["annotations"]:
+            annotation_id = annotation["id"]
+
+            if annotation_id in self._annotations:
+                raise ElementAlreadyExists("Annotation already exists")
+
+            self._import_annotation(annotation_id, annotation)
+
     @locked
     def _import_link(
         self,
         link_id: str,
         iface_b_id: str,
         iface_a_id: str,
-        label: Optional[str] = None,
+        label: str | None = None,
     ) -> Link:
+        """
+        Import a link with the given parameters.
+
+        :param link_id: The ID of the link.
+        :param iface_b_id: The ID of the second interface.
+        :param iface_a_id: The ID of the first interface.
+        :param label: The label of the link.
+        :returns: The imported Link object.
+        """
         iface_a = self._interfaces[iface_a_id]
         iface_b = self._interfaces[iface_b_id]
         return self._create_link_local(iface_a, iface_b, link_id, label)
@@ -1224,8 +1448,17 @@ def _import_link(
     def _import_interface(
         self, iface_id: str, node_id: str, iface_data: dict
     ) -> Interface:
+        """
+        Import an interface with the given parameters.
+
+        :param iface_id: The ID of the interface.
+        :param node_id: The ID of the node the interface belongs to.
+        :param iface_data: The data of the interface.
+        :returns: The imported Interface object.
+        """
         if "data" in iface_data:
             iface_data = iface_data["data"]
+
         label = iface_data["label"]
         slot = iface_data.get("slot")
         iface_type = iface_data["type"]
@@ -1234,8 +1467,16 @@ def _import_interface(
 
     @locked
     def _import_node(self, node_id: str, node_data: dict) -> Node:
+        """
+        Import a node with the given parameters.
+
+        :param node_id: The ID of the node.
+        :param node_data: The data of the node.
+        :returns: The imported Node object.
+        """
         if "data" in node_data:
             node_data = node_data["data"]
+
         node_data.pop("id", None)
         state = node_data.pop("state", None)
         node_data.pop("lab_id", None)
@@ -1244,51 +1485,99 @@ def _import_node(self, node_id: str, node_data: dict) -> Node:
         for key in ("image_definition", "configuration"):
             if key not in node_data:
                 node_data[key] = None
+
         node = self._create_node_local(node_id, **node_data)
         node._state = state
         return node
 
+    @locked
+    def _import_annotation(
+        self,
+        annotation_id: str,
+        annotation_data: dict,
+    ) -> AnnotationType:
+        """
+        Import an annotation with the given parameters.
+
+        :param annotation_id: The ID of the annotation.
+        :param annotation_data: The data of the annotation.
+        :returns: The imported Annotation object.
+        """
+        annotation_data.pop("id", None)
+        ann_type = annotation_data.pop("type")
+
+        annotation = self._create_annotation_local(
+            annotation_id, ann_type, **annotation_data
+        )
+        return annotation
+
     @locked
     def update_lab(self, topology: dict, exclude_configurations: bool) -> None:
+        """
+        Update the lab with the given topology.
+
+        :param topology: The updated topology.
+        :param exclude_configurations: Whether to exclude configurations from updating.
+        """
         self._import_lab(topology)
 
-        # add in order: node -> interface -> link
-        # remove in reverse, eg link -> interface -> node
-        existing_node_keys = set(self._nodes)
-        existing_link_keys = set(self._links)
-        existing_interface_keys = set(self._interfaces)
+        # add in order: node -> interface -> link -> annotation
+        # remove in reverse: annotation -> link -> interface -> node
+        existing_node_ids = set(self._nodes)
+        existing_link_ids = set(self._links)
+        existing_interface_ids = set(self._interfaces)
+        existing_annotation_ids = set(self._annotations)
 
-        update_node_keys = set(node["id"] for node in topology["nodes"])
-        update_link_keys = set(links["id"] for links in topology["links"])
+        update_node_ids = set(node["id"] for node in topology["nodes"])
+        update_link_ids = set(link["id"] for link in topology["links"])
         if "interfaces" in topology:
-            update_interface_keys = set(iface["id"] for iface in topology["interfaces"])
+            update_interface_ids = set(iface["id"] for iface in topology["interfaces"])
         else:
-            update_interface_keys = set(
+            update_interface_ids = set(
                 interface["id"]
                 for node in topology["nodes"]
                 for interface in node["interfaces"]
             )
+        update_annotation_ids = set(
+            ann["id"] for ann in topology.get("annotations", [])
+        )
 
         # removed elements
-        removed_nodes = existing_node_keys - update_node_keys
-        removed_links = existing_link_keys - update_link_keys
-        removed_interfaces = existing_interface_keys - update_interface_keys
-
-        self._remove_elements(removed_nodes, removed_links, removed_interfaces)
+        removed_nodes = existing_node_ids - update_node_ids
+        removed_links = existing_link_ids - update_link_ids
+        removed_interfaces = existing_interface_ids - update_interface_ids
+        removed_annotations = existing_annotation_ids - update_annotation_ids
+        self._remove_elements(
+            removed_nodes,
+            removed_links,
+            removed_interfaces,
+            removed_annotations,
+        )
 
         # new elements
-        new_nodes = update_node_keys - existing_node_keys
-        new_links = update_link_keys - existing_link_keys
-        new_interfaces = update_interface_keys - existing_interface_keys
-
-        self._add_elements(topology, new_nodes, new_links, new_interfaces)
+        new_nodes = update_node_ids - existing_node_ids
+        new_links = update_link_ids - existing_link_ids
+        new_interfaces = update_interface_ids - existing_interface_ids
+        new_annotations = update_annotation_ids - existing_annotation_ids
+        self._add_elements(
+            topology,
+            new_nodes,
+            new_links,
+            new_interfaces,
+            new_annotations,
+        )
 
         # kept elements
-        kept_nodes = update_node_keys.intersection(existing_node_keys)
-        # kept_links = update_link_keys.intersection(existing_link_keys)
-        # kept_interfaces = update_interface_keys.intersection(existing_interface_keys)
-
-        self._update_elements(topology, kept_nodes, exclude_configurations)
+        kept_nodes = update_node_ids.intersection(existing_node_ids)
+        # kept_links = update_link_ids.intersection(existing_link_ids)
+        # kept_interfaces = update_interface_ids.intersection(existing_interface_ids)
+        kept_annotations = update_annotation_ids.intersection(existing_annotation_ids)
+        self._update_elements(
+            topology=topology,
+            kept_nodes=kept_nodes,
+            kept_annotations=kept_annotations,
+            exclude_configurations=exclude_configurations,
+        )
 
     @locked
     def _remove_elements(
@@ -1296,22 +1585,36 @@ def _remove_elements(
         removed_nodes: Iterable[str],
         removed_links: Iterable[str],
         removed_interfaces: Iterable[str],
+        removed_annotations: Iterable[str],
     ) -> None:
+        """
+        Remove elements from the lab.
+
+        :param removed_nodes: Iterable of node IDs to be removed.
+        :param removed_links: Iterable of link IDs to be removed.
+        :param removed_interfaces: Iterable of interface IDs to be removed.
+        :param removed_annotations: Iterable of annotation IDs to be removed.
+        """
         for link_id in removed_links:
             link = self._links.pop(link_id)
-            _LOGGER.info("Removed link %s", link)
+            _LOGGER.info(f"Removed link {link}")
             link._stale = True
 
         for interface_id in removed_interfaces:
             interface = self._interfaces.pop(interface_id)
-            _LOGGER.info("Removed interface %s", interface)
+            _LOGGER.info(f"Removed interface {interface}")
             interface._stale = True
 
         for node_id in removed_nodes:
             node = self._nodes.pop(node_id)
-            _LOGGER.info("Removed node %s", node)
+            _LOGGER.info(f"Removed node {node}")
             node._stale = True
 
+        for ann_id in removed_annotations:
+            annotation = self._annotations.pop(ann_id)
+            _LOGGER.info(f"Removed annotation {annotation}")
+            annotation._stale = True
+
     @locked
     def _add_elements(
         self,
@@ -1319,13 +1622,23 @@ def _add_elements(
         new_nodes: Iterable[str],
         new_links: Iterable[str],
         new_interfaces: Iterable[str],
+        new_annotations: Iterable[str],
     ) -> None:
+        """
+        Add elements to the lab.
+
+        :param topology: Dictionary containing the lab topology.
+        :param new_nodes: Iterable of node IDs to be added.
+        :param new_links: Iterable of link IDs to be added.
+        :param new_interfaces: Iterable of interface IDs to be added.
+        :param new_annotations: Iterable of annotation IDs to be added.
+        """
         for node in topology["nodes"]:
             node_id = node["id"]
             interfaces = node.pop("interfaces", [])
             if node_id in new_nodes:
                 node = self._import_node(node_id, node)
-                _LOGGER.info("Added node %s", node)
+                _LOGGER.info(f"Added node {node}")
 
             if not interfaces:
                 continue
@@ -1334,7 +1647,7 @@ def _add_elements(
                 interface_id = interface["id"]
                 if interface_id in new_interfaces:
                     interface = self._import_interface(interface_id, node_id, interface)
-                    _LOGGER.info("Added interface %s", interface)
+                    _LOGGER.info(f"Added interface {interface}")
 
         if "interfaces" in topology:
             for iface in topology["interfaces"]:
@@ -1349,16 +1662,34 @@ def _add_elements(
             iface_b_id = link_data["interface_b"]
             label = link_data.get("label")
             link = self._import_link(link_id, iface_b_id, iface_a_id, label)
-            _LOGGER.info("Added link %s", link)
+            _LOGGER.info(f"Added link {link}")
+
+        for ann_id in new_annotations:
+            annotation_data = self._find_annotation_in_topology(ann_id, topology)
+            annotation = self._import_annotation(ann_id, annotation_data)
+            _LOGGER.info(f"Added annotation {annotation}")
 
     @locked
     def _update_elements(
-        self, topology: dict, kept_nodes: Iterable[str], exclude_configurations: bool
+        self,
+        topology: dict,
+        kept_nodes: Iterable[str] | None = None,
+        kept_annotations: Iterable[str] | None = None,
+        exclude_configurations: bool = False,
     ) -> None:
-        for node_id in kept_nodes:
-            node = self._find_node_in_topology(node_id, topology)
-            lab_node = self._nodes[node_id]
-            lab_node.update(node, exclude_configurations)
+        """
+        Update elements in the lab.
+
+         :param topology: Dictionary containing the lab topology.
+         :param kept_nodes: Iterable of node IDs to be updated.
+         :param exclude_configurations: Boolean indicating whether to exclude
+            configurations during update.
+        """
+        if kept_nodes:
+            for node_id in kept_nodes:
+                node = self._find_node_in_topology(node_id, topology)
+                lab_node = self._nodes[node_id]
+                lab_node.update(node, exclude_configurations, push_to_server=False)
 
         # For now, can't update interface data server-side, this will change with tags
         # for interface_id in kept_interfaces:
@@ -1368,11 +1699,20 @@ def _update_elements(
         # for link_id in kept_links:
         #     link_data = self._find_link_in_topology(link_id, topology)
 
+        if kept_annotations:
+            for ann_id in kept_annotations:
+                annotation = self._find_annotation_in_topology(ann_id, topology)
+                lab_annotation = self._annotations[ann_id]
+                lab_annotation.update(annotation, push_to_server=False)
+
     @locked
     def update_lab_properties(self, properties: dict[str, Any]):
-        """Updates lab properties. Will not modify unspecified properties.
-        Is not compatible with schema version 0.0.5."""
-        # TODO: Should it be?
+        """
+        Update lab properties. Will not modify unspecified properties.
+        Is not compatible with schema version 0.0.5.
+
+        :param properties: Dictionary containing the updated lab properties.
+        """
         self._title = properties.get("title", self._title)
         self._description = properties.get("description", self._description)
         self._notes = properties.get("notes", self._notes)
@@ -1380,6 +1720,15 @@ def update_lab_properties(self, properties: dict[str, Any]):
 
     @staticmethod
     def _find_link_in_topology(link_id: str, topology: dict) -> dict:
+        """
+        Find a link in the given topology.
+
+        :param link_id: The ID of the link to find.
+        :param topology: Dictionary containing the lab topology.
+        :returns: The link with the specified ID.
+        :raises LinkNotFound: If the link cannot be found in the topology.
+        """
+
         for link in topology["links"]:
             if link["id"] == link_id:
                 return link
@@ -1397,14 +1746,43 @@ def _find_link_in_topology(link_id: str, topology: dict) -> dict:
 
     @staticmethod
     def _find_node_in_topology(node_id: str, topology: dict) -> dict:
+        """
+        Find a node in the given topology.
+
+        :param node_id: The ID of the node to find.
+        :param topology: Dictionary containing the lab topology.
+        :returns: The node with the specified ID.
+        :raises NodeNotFound: If the node cannot be found in the topology.
+        """
+
         for node in topology["nodes"]:
             if node["id"] == node_id:
                 return node
         # if it cannot be found, it is an internal structure error
         raise NodeNotFound
 
+    @staticmethod
+    def _find_annotation_in_topology(
+        annotation_id: str,
+        topology: dict,
+    ) -> dict[str, Any]:
+        """
+        Find an annotation in the given topology.
+
+        :param annotation_id: The ID of the annotation to find.
+        :param topology: Dictionary containing the lab topology.
+        :returns: The annotation with the specified ID.
+        :raises AnnotationNotFound: If the annotation cannot be found in the topology.
+        """
+
+        for annotation in topology["annotations"]:
+            if annotation["id"] == annotation_id:
+                return annotation
+        # if it cannot be found, it is an internal structure error
+        raise AnnotationNotFound
+
     @check_stale
-    def get_pyats_testbed(self, hostname: Optional[str] = None) -> str:
+    def get_pyats_testbed(self, hostname: str | None = None) -> str:
         """
         Return lab's pyATS YAML testbed. Example usage::
 
@@ -1420,10 +1798,10 @@ def get_pyats_testbed(self, hostname: Optional[str] = None) -> str:
 
             aetest.main(testbed=testbed)
 
-        :param hostname: Force hostname/ip and port for console terminal server
-        :returns: The pyATS testbed for the lab in YAML format
+        :param hostname: Force hostname/ip and port for console terminal server.
+        :returns: The pyATS testbed for the lab in YAML format.
         """
-        url = self.lab_base_url + "/pyats_testbed"
+        url = self._url_for("pyats_testbed")
         params = {}
         if hostname is not None:
             params["hostname"] = hostname
@@ -1432,17 +1810,18 @@ def get_pyats_testbed(self, hostname: Optional[str] = None) -> str:
 
     @check_stale
     def sync_pyats(self) -> None:
+        """Sync the pyATS testbed."""
         self.pyats.sync_testbed(self.username, self.password)
 
     def cleanup_pyats_connections(self) -> None:
-        """Closes and cleans up connection that pyATS might still hold."""
+        """Close and clean up connection that pyATS might still hold."""
         self.pyats.cleanup()
 
     @check_stale
     @locked
     def sync_layer3_addresses(self) -> None:
-        """Syncs all layer 3 IP addresses from the backend server."""
-        url = self.lab_base_url + "/layer3_addresses"
+        """Sync all layer 3 IP addresses from the backend server."""
+        url = self._url_for("layer3_addresses")
         result: dict[str, dict] = self._session.get(url).json()
         for node_id, node_data in result.items():
             node = self.get_node_by_id(node_id)
@@ -1455,19 +1834,15 @@ def download(self) -> str:
         """
         Download the lab from the server in YAML format.
 
-        :returns: The lab in YAML format
+        :returns: The lab in YAML format.
         """
-        url = self.lab_base_url + "/download"
+        url = self._url_for("download")
         return self._session.get(url).text
 
     @property
     def groups(self) -> list[dict[str, str]]:
-        """
-        Returns the groups this lab is associated with.
-
-        :return: associated groups
-        """
-        url = self.lab_base_url + "/groups"
+        """Return the groups this lab is associated with."""
+        url = self._url_for("groups")
         return self._session.get(url).json()
 
     @check_stale
@@ -1475,30 +1850,32 @@ def update_lab_groups(
         self, group_list: list[dict[str, str]]
     ) -> list[dict[str, str]]:
         """
-        Modifies lab / group association
+        Modify lab/group association.
 
-        :param group_list: list of objects consisting of group id and permission
-        :return: updated objects consisting of group id and permission
+        :param group_list: List of objects consisting of group ID and permission.
+        :returns: Updated objects consisting of group ID and permission.
         """
-        url = self.lab_base_url + "/groups"
+        url = self._url_for("groups")
         return self._session.put(url, json=group_list).json()
 
     @property
     def connector_mappings(self) -> list[dict[str, Any]]:
-        """Retrieve lab's external connector mappings
+        """
+        Retrieve lab's external connector mappings.
 
         Returns a list of mappings; each mapping has a key, which is used
         as the configuration of external connector nodes, and a device name,
         used to uniquely identify the controller host's bridge to use.
         If unset, the mapping is invalid and nodes using it cannot start.
         """
-        url = self.lab_base_url + "/connector_mappings"
+        url = self._url_for("connector_mappings")
         return self._session.get(url).json()
 
     def update_connector_mappings(
         self, updates: list[dict[str, str]]
     ) -> list[dict[str, str]]:
-        """Update lab's external connector mappings
+        """
+        Update lab's external connector mappings.
 
         Accepts a list of mappings, each with a key to add or modify,
         and the associated device name (bridge) of the controller host.
@@ -1506,19 +1883,24 @@ def update_connector_mappings(
         value may be None to disassociate the bridge from the key; if no node
         uses this key in its configuration, the mapping entry is removed.
 
-        Returns all connector mappings after updates were applied.
+        :returns: All connector mappings after updates were applied.
         """
-        url = self.lab_base_url + "/connector_mappings"
+        url = self._url_for("connector_mappings")
         return self._session.patch(url, json=updates).json()
 
     @check_stale
     @locked
     def sync_operational(self) -> None:
-        url = self.lab_base_url + "/resource_pools"
+        """Sync the operational status of the lab."""
+        url = self._url_for("resource_pools")
         response = self._session.get(url).json()
         res_pools = self._resource_pool_manager.get_resource_pools_by_ids(response)
         self._resource_pools = list(res_pools.values())
-        self._last_sync_operational_time = time.time()
 
-        for node in self._nodes.values():
-            node.sync_operational()
+        url = self._url_for("nodes_operational")
+        response: list[dict] = self._session.get(url).json()
+        for node_data in response:
+            if node := self._nodes.get(node_data["id"]):
+                node.sync_operational(node_data)
+
+        self._last_sync_operational_time = time.time()
diff --git a/virl2_client/models/licensing.py b/virl2_client/models/licensing.py
index f90e1a4..1f1f43f 100644
--- a/virl2_client/models/licensing.py
+++ b/virl2_client/models/licensing.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -22,7 +22,10 @@
 
 import logging
 import time
-from typing import TYPE_CHECKING, Any, Optional
+import warnings
+from typing import TYPE_CHECKING, Any
+
+from ..utils import get_url_from_template
 
 if TYPE_CHECKING:
     import httpx
@@ -34,34 +37,55 @@
 
 
 class Licensing:
+    _URL_TEMPLATES = {
+        "licensing": "licensing",
+        "tech_support": "licensing/tech_support",
+        "authorization_renew": "licensing/authorization/renew",
+        "transport": "licensing/transport",
+        "product_license": "licensing/product_license",
+        "certificate": "licensing/certificate",
+        "registration": "licensing/registration",
+        "registration_renew": "licensing/registration/renew",
+        "deregistration": "licensing/deregistration",
+        "features": "licensing/features",
+        "reservation_action": "licensing/reservation/{action}",
+    }
     max_wait = 30
     wait_interval = 1.5
 
-    def __init__(self, session: httpx.Client) -> None:
-        self._session = session
+    def __init__(self, session: httpx.Client, is_cert_deprecated: bool) -> None:
+        """
+        Manage licensing.
 
-    @property
-    def base_url(self) -> str:
-        return "licensing"
+        :param session: The httpx-based HTTP client for this session with the server.
+        :param is_cert_deprecated: Whether the certificate supported is deprecated.
+        """
+        self._session = session
+        self._is_cert_deprecated = is_cert_deprecated
 
-    def status(self) -> dict[str, Any]:
+    def _url_for(self, endpoint, **kwargs):
         """
-        Get current licensing configuration and status.
+        Generate the URL for a given API endpoint.
+
+        :param endpoint: The desired endpoint.
+        :param **kwargs: Keyword arguments used to format the URL.
+        :returns: The formatted URL.
         """
-        return self._session.get(self.base_url).json()
+        return get_url_from_template(endpoint, self._URL_TEMPLATES, kwargs)
+
+    def status(self) -> dict[str, Any]:
+        """Return current licensing configuration and status."""
+        url = self._url_for("licensing")
+        return self._session.get(url).json()
 
     def tech_support(self) -> str:
-        """
-        Get current licensing tech support.
-        """
-        url = self.base_url + "/tech_support"
+        """Return current licensing tech support."""
+        url = self._url_for("tech_support")
         return self._session.get(url).text
 
     def renew_authorization(self) -> bool:
-        """
-        Renew licensing authorization with the backend.
-        """
-        url = self.base_url + "/authorization/renew"
+        """Renew licensing authorization with the backend."""
+        url = self._url_for("authorization_renew")
         response = self._session.put(url)
         _LOGGER.info("The agent has scheduled an authorization renewal.")
         return response.status_code == 204
@@ -69,22 +93,18 @@ def renew_authorization(self) -> bool:
     def set_transport(
         self,
         ssms: str,
-        proxy_server: Optional[str] = None,
-        proxy_port: Optional[int] = None,
+        proxy_server: str | None = None,
+        proxy_port: int | None = None,
     ) -> bool:
-        """
-        Setup licensing transport configuration.
-        """
-        url = self.base_url + "/transport"
+        """Setup licensing transport configuration."""
+        url = self._url_for("transport")
         data = {"ssms": ssms, "proxy": {"server": proxy_server, "port": proxy_port}}
         response = self._session.put(url, json=data)
-        _LOGGER.info("The transport configuration has been accepted. Config: %s.", data)
+        _LOGGER.info(f"The transport configuration has been accepted. Config: {data}.")
         return response.status_code == 204
 
     def set_default_transport(self) -> bool:
-        """
-        Setup licensing transport configuration to default values.
-        """
+        """Setup licensing transport configuration to default values."""
         default_ssms = self.status()["transport"]["default_ssms"]
         return self.set_transport(
             ssms=default_ssms,
@@ -93,19 +113,25 @@ def set_default_transport(self) -> bool:
         )
 
     def set_product_license(self, product_license: str) -> bool:
-        """
-        Setup a product license.
-        """
-        url = self.base_url + "/product_license"
+        """Setup a product license."""
+        url = self._url_for("product_license")
         response = self._session.put(url, json=product_license)
         _LOGGER.info("Product license was accepted by the agent.")
         return response.status_code == 204
 
-    def get_certificate(self) -> Optional[str]:
+    def get_certificate(self) -> str | None:
         """
+        DEPRECATED: There is no replacement as the certificate support was dropped.
+        (Reason: the certificate support was dropped in CML 2.7.0)
+
         Get the currently installed licensing public certificate.
         """
-        url = self.base_url + "/certificate"
+        if self._is_cert_deprecated:
+            warnings.warn(
+                "'Licensing.get_certificate' is deprecated.", DeprecationWarning
+            )
+            return None
+        url = self._url_for("certificate")
         response = self._session.get(url)
         if response.is_success:
             _LOGGER.info("Certificate received.")
@@ -114,29 +140,43 @@ def get_certificate(self) -> Optional[str]:
 
     def install_certificate(self, cert: str) -> bool:
         """
+        DEPRECATED: There is no replacement as the certificate support was dropped.
+        (Reason: the certificate support was dropped in CML 2.7.0)
+
         Set up a licensing public certificate for internal deployment
         of an unregistered product instance.
         """
-        url = self.base_url + "/certificate"
+        if self._is_cert_deprecated:
+            warnings.warn(
+                "'Licensing.install_certificate' is deprecated.", DeprecationWarning
+            )
+            return False
+        url = self._url_for("certificate")
         response = self._session.post(url, content=cert)
         _LOGGER.info("Certificate was accepted by the agent.")
         return response.status_code == 204
 
     def remove_certificate(self) -> bool:
         """
+        DEPRECATED: There is no replacement as the certificate support was dropped.
+        (Reason: the certificate support was dropped in CML 2.7.0)
+
         Clear any licensing public certificate for internal deployment
         of an unregistered product instance.
         """
-        url = self.base_url + "/certificate"
+        if self._is_cert_deprecated:
+            warnings.warn(
+                "'Licensing.remove_certificate' is deprecated.", DeprecationWarning
+            )
+            return False
+        url = self._url_for("certificate")
         response = self._session.delete(url)
         _LOGGER.info("Certificate was removed.")
         return response.status_code == 204
 
     def register(self, token: str, reregister=False) -> bool:
-        """
-        Setup licensing registration.
-        """
-        url = self.base_url + "/registration"
+        """Setup licensing registration."""
+        url = self._url_for("registration")
         response = self._session.post(
             url, json={"token": token, "reregister": reregister}
         )
@@ -144,10 +184,8 @@ def register(self, token: str, reregister=False) -> bool:
         return response.status_code == 204
 
     def register_renew(self) -> bool:
-        """
-        Request a renewal of licensing registration against current SSMS.
-        """
-        url = self.base_url + "/registration/renew"
+        """Request a renewal of licensing registration against current SSMS."""
+        url = self._url_for("registration_renew")
         response = self._session.put(url)
         _LOGGER.info("The renewal request was accepted by the agent.")
         return response.status_code == 204
@@ -163,10 +201,8 @@ def register_wait(self, token: str, reregister=False) -> bool:
         return res
 
     def deregister(self) -> int:
-        """
-        Request deregistration from the current SSMS.
-        """
-        url = self.base_url + "/deregistration"
+        """Request deregistration from the current SSMS."""
+        url = self._url_for("deregistration")
         response = self._session.delete(url)
         if response.status_code == 202:
             _LOGGER.warning(
@@ -174,7 +210,6 @@ def deregister(self) -> int:
                 "unable to deregister from Smart Software Licensing due to a "
                 "communication timeout."
             )
-            # TODO try to register again and unregister
         if response.status_code == 204:
             _LOGGER.info(
                 "The Product Instance was successfully deregistered from Smart "
@@ -183,74 +218,54 @@ def deregister(self) -> int:
         return response.status_code
 
     def features(self) -> list[dict[str, str | int]]:
-        """
-        Get current licensing features.
-        """
-        url = self.base_url + "/features"
+        """Get current licensing features."""
+        url = self._url_for("features")
         return self._session.get(url).json()
 
     def update_features(self, features: dict[str, int] | list[dict[str, int]]) -> None:
-        """
-        Update licensing feature's explicit count in reservation mode.
-        """
-        url = self.base_url + "/features"
+        """Update licensing feature's explicit count in reservation mode."""
+        url = self._url_for("features")
         self._session.patch(url, json=features)
 
     def reservation_mode(self, data: bool) -> None:
-        """
-        Enable or disable reservation mode in unregistered agent.
-        """
-        url = self.base_url + "/reservation/mode"
+        """Enable or disable reservation mode in unregistered agent."""
+        url = self._url_for("reservation_action", action="mode")
         self._session.put(url, json=data)
         msg = "enabled" if data else "disabled"
-        _LOGGER.info("The reservation mode has been %s.", msg)
+        _LOGGER.info(f"The reservation mode has been {msg}.")
 
     def enable_reservation_mode(self) -> None:
-        """
-        Enable reservation mode in unregistered agent.
-        """
+        """Enable reservation mode in unregistered agent."""
         return self.reservation_mode(data=True)
 
     def disable_reservation_mode(self) -> None:
-        """
-        Disable reservation mode in unregistered agent.
-        """
+        """Disable reservation mode in unregistered agent."""
         return self.reservation_mode(data=False)
 
     def request_reservation(self) -> str:
-        """
-        Initiate reservation by generating request code and message to the user.
-        """
-        url = self.base_url + "/reservation/request"
+        """Initiate reservation by generating request code and message to the user."""
+        url = self._url_for("reservation_action", action="request")
         response = self._session.post(url)
         _LOGGER.info("Reservation request code received.")
         return response.json()
 
     def complete_reservation(self, authorization_code: str) -> str:
-        """
-        Complete reservation by installing authorization code from SSMS.
-        """
-        # TODO
-        url = self.base_url + "/reservation/complete"
+        """Complete reservation by installing authorization code from SSMS."""
+        url = self._url_for("reservation_action", action="complete")
         response = self._session.post(url, json=authorization_code)
         _LOGGER.info("The confirmation code of completed reservation received.")
         return response.json()
 
     def cancel_reservation(self) -> bool:
-        """
-        Cancel reservation request without completing it.
-        """
-        url = self.base_url + "/reservation/cancel"
+        """Cancel reservation request without completing it."""
+        url = self._url_for("reservation_action", action="cancel")
         response = self._session.delete(url)
         _LOGGER.info("The reservation request has been cancelled.")
         return response.status_code == 204
 
     def release_reservation(self) -> str:
-        """
-        Return a completed reservation.
-        """
-        # TODO
-        url = self.base_url + "/reservation/release"
+        """Return a completed reservation."""
+        url = self._url_for("reservation_action", action="release")
         response = self._session.delete(url)
         _LOGGER.info("The return code of the released reservation received.")
         return response.json()
@@ -260,8 +275,7 @@ def discard_reservation(self, data: str) -> str:
         Discard a reservation authorization code for an already cancelled
         reservation request.
         """
-        # TODO
-        url = self.base_url + "/reservation/discard"
+        url = self._url_for("reservation_action", action="discard")
         response = self._session.post(url, json=data)
         _LOGGER.info(
             "The discard code for an already cancelled reservation request received."
@@ -269,37 +283,29 @@ def discard_reservation(self, data: str) -> str:
         return response.json()
 
     def get_reservation_confirmation_code(self) -> str:
-        """
-        Get the confirmation code.
-        """
-        url = self.base_url + "/reservation/confirmation_code"
+        """Get the reservation confirmation code."""
+        url = self._url_for("reservation_action", action="confirmation_code")
         response = self._session.get(url)
         _LOGGER.info("The confirmation code of the completed reservation received.")
         return response.json()
 
     def delete_reservation_confirmation_code(self) -> bool:
-        """
-        Remove the confirmation code.
-        """
-        url = self.base_url + "/reservation/confirmation_code"
+        """Remove the reservation confirmation code."""
+        url = self._url_for("reservation_action", action="confirmation_code")
         response = self._session.delete(url)
         _LOGGER.info("The confirmation code has been removed.")
         return response.status_code == 204
 
     def get_reservation_return_code(self) -> str:
-        """
-        Get the return code.
-        """
-        url = self.base_url + "/reservation/return_code"
+        """Get the reservation return code."""
+        url = self._url_for("reservation_action", action="return_code")
         response = self._session.get(url)
         _LOGGER.info("The return code of the released reservation received.")
         return response.json()
 
     def delete_reservation_return_code(self) -> bool:
-        """
-        Remove the return code.
-        """
-        url = self.base_url + "/reservation/return_code"
+        """Remove the reservation return code."""
+        url = self._url_for("reservation_action", action="return_code")
         response = self._session.delete(url)
         _LOGGER.info("The return code has been removed.")
         return response.status_code == 204
@@ -319,9 +325,9 @@ def wait_for_status(self, what: str, *target_status: str) -> None:
             if count > self.max_wait:
                 timeout = self.max_wait * self.wait_interval
                 raise RuntimeError(
-                    "Timeout: licensing {} did not reach {} status after {} secs. "
-                    "Last status was {}".format(what, target_status, timeout, status)
+                    f"Timeout: licensing {what} did not reach {target_status} status "
+                    f"after {timeout} secs. Last status was {status}"
                 )
             status = self.status()[what]["status"]
-            _LOGGER.debug("%s status: %s", what, status)
+            _LOGGER.debug(f"{what} status: {status}")
             count += 1
diff --git a/virl2_client/models/link.py b/virl2_client/models/link.py
index a5eb188..30cfa41 100644
--- a/virl2_client/models/link.py
+++ b/virl2_client/models/link.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -23,10 +23,9 @@
 import logging
 import time
 import warnings
-from functools import total_ordering
-from typing import TYPE_CHECKING, Optional
+from typing import TYPE_CHECKING
 
-from ..utils import check_stale, locked
+from ..utils import check_stale, get_url_from_template, locked
 from ..utils import property_s as property
 
 if TYPE_CHECKING:
@@ -39,33 +38,43 @@
 _LOGGER = logging.getLogger(__name__)
 
 
-@total_ordering
 class Link:
+    _URL_TEMPLATES = {
+        "link": "{lab}/links/{id}",
+        "check_if_converged": "{lab}/links/{id}/check_if_converged",
+        "state": "{lab}/links/{id}/state",
+        "start": "{lab}/links/{id}/state/start",
+        "stop": "{lab}/links/{id}/state/stop",
+        "condition": "{lab}/links/{id}/condition",
+    }
+
     def __init__(
         self,
         lab: Lab,
         lid: str,
         iface_a: Interface,
         iface_b: Interface,
-        label: Optional[str] = None,
+        label: str | None = None,
     ) -> None:
         """
         A VIRL2 network link between two nodes, connecting
         to two interfaces on these nodes.
 
-        :param lab: the lab object
-        :param lid: the link ID
-        :param iface_a: the first interface of the link
-        :param iface_b: the second interface of the link
-        :param label: the link label
+        :param lab: The lab object to which the link belongs.
+        :param lid: The ID of the link.
+        :param iface_a: The first interface of the link.
+        :param iface_b: The second interface of the link.
+        :param label: The label of the link.
         """
         self._id = lid
         self._interface_a = iface_a
         self._interface_b = iface_b
         self._label = label
         self.lab = lab
-        self._session: httpx.Client = lab.session
-        self._state: Optional[str] = None
+        self._session: httpx.Client = lab._session
+        self._state: str | None = None
+        # When the link is removed on the server, this link object is marked stale
+        # and can no longer be interacted with - the user should discard it
         self._stale = False
         self.statistics = {
             "readbytes": 0,
@@ -92,119 +101,145 @@ def __eq__(self, other: object):
             return False
         return self._id == other._id
 
-    def __lt__(self, other: object):
-        if not isinstance(other, Link):
-            return False
-        return self._id < other._id
-
     def __hash__(self):
         return hash(self._id)
 
+    def _url_for(self, endpoint, **kwargs):
+        """
+        Generate the URL for a given API endpoint.
+
+        :param endpoint: The desired endpoint.
+        :param **kwargs: Keyword arguments used to format the URL.
+        :returns: The formatted URL.
+        """
+        kwargs["lab"] = self.lab._url_for("lab")
+        kwargs["id"] = self.id
+        return get_url_from_template(endpoint, self._URL_TEMPLATES, kwargs)
+
     @property
-    def id(self):
+    def id(self) -> str:
+        """Return the ID of the link."""
         return self._id
 
     @property
-    def interface_a(self):
+    def interface_a(self) -> Interface:
+        """Return the first interface of the link."""
         return self._interface_a
 
     @property
-    def interface_b(self):
+    def interface_b(self) -> Interface:
+        """Return the second interface of the link."""
         return self._interface_b
 
     @property
     @locked
-    def state(self) -> Optional[str]:
+    def state(self) -> str | None:
+        """Return the current state of the link."""
         self.lab.sync_states_if_outdated()
         if self._state is None:
-            url = self.base_url
+            url = self._url_for("link")
             self._state = self._session.get(url).json()["state"]
         return self._state
 
     @property
     def readbytes(self) -> int:
+        """Return the number of read bytes on the link."""
         self.lab.sync_statistics_if_outdated()
         return self.statistics["readbytes"]
 
     @property
     def readpackets(self) -> int:
+        """Return the number of read packets on the link."""
         self.lab.sync_statistics_if_outdated()
         return self.statistics["readpackets"]
 
     @property
     def writebytes(self) -> int:
+        """Return the number of written bytes on the link."""
         self.lab.sync_statistics_if_outdated()
         return self.statistics["writebytes"]
 
     @property
     def writepackets(self) -> int:
+        """Return the number of written packets on the link."""
         self.lab.sync_statistics_if_outdated()
         return self.statistics["writepackets"]
 
     @property
     def node_a(self) -> Node:
-        self.lab.sync_topology_if_outdated()
+        """Return the first node connected to the link."""
         return self.interface_a.node
 
     @property
     def node_b(self) -> Node:
-        self.lab.sync_topology_if_outdated()
+        """Return the second node connected to the link."""
         return self.interface_b.node
 
     @property
     @locked
     def nodes(self) -> tuple[Node, Node]:
-        """Return nodes this link connects."""
-        self.lab.sync_topology_if_outdated()
+        """Return the nodes connected by the link."""
         return self.node_a, self.node_b
 
     @property
     @locked
     def interfaces(self) -> tuple[Interface, Interface]:
-        self.lab.sync_topology_if_outdated()
+        """Return the interfaces connected by the link."""
         return self.interface_a, self.interface_b
 
     @property
-    def label(self) -> Optional[str]:
-        self.lab.sync_topology_if_outdated()
+    def label(self) -> str | None:
+        """Return the label of the link."""
         return self._label
 
     @locked
     def as_dict(self) -> dict[str, str]:
+        """
+        Convert the link object to a dictionary representation.
+
+        :returns: A dictionary representation of the link object.
+        """
         return {
             "id": self.id,
             "interface_a": self.interface_a.id,
             "interface_b": self.interface_b.id,
         }
 
-    @property
-    def lab_base_url(self) -> str:
-        return self.lab.lab_base_url
-
-    @property
-    def base_url(self) -> str:
-        return self.lab_base_url + "/links/{}".format(self.id)
-
     def remove(self):
+        """Remove the link from the lab."""
         self.lab.remove_link(self)
 
     @check_stale
     def _remove_on_server(self) -> None:
-        _LOGGER.info("Removing link %s", self)
-        url = self.base_url
+        _LOGGER.info(f"Removing link {self}")
+        url = self._url_for("link")
         self._session.delete(url)
 
     def remove_on_server(self) -> None:
+        """
+        DEPRECATED: Use `.remove()` instead.
+        (Reason: was never meant to be public, removing only on server is not useful)
+
+        Remove the link on the server.
+        """
         warnings.warn(
-            "'Link.remove_on_server()' is deprecated, use 'Link.remove()' instead.",
+            "'Link.remove_on_server()' is deprecated. Use '.remove()' instead.",
             DeprecationWarning,
         )
         self._remove_on_server()
 
     def wait_until_converged(
-        self, max_iterations: Optional[int] = None, wait_time: Optional[int] = None
+        self, max_iterations: int | None = None, wait_time: int | None = None
     ) -> None:
-        _LOGGER.info("Waiting for link %s to converge", self.id)
+        """
+        Wait until the link has converged.
+
+        :param max_iterations: The maximum number of iterations to wait for convergence.
+        :param wait_time: The time to wait between iterations.
+        :raises RuntimeError: If the link does not converge within the specified number
+            of iterations.
+        """
+        _LOGGER.info(f"Waiting for link {self.id} to converge")
         max_iter = (
             self.lab.wait_max_iterations if max_iterations is None else max_iterations
         )
@@ -212,21 +247,16 @@ def wait_until_converged(
         for index in range(max_iter):
             converged = self.has_converged()
             if converged:
-                _LOGGER.info("Link %s has converged", self.id)
+                _LOGGER.info(f"Link {self.id} has converged")
                 return
 
             if index % 10 == 0:
                 _LOGGER.info(
-                    "Link has not converged, attempt %s/%s, waiting...",
-                    index,
-                    max_iter,
+                    f"Link has not converged, attempt {index}/{max_iter}, waiting..."
                 )
             time.sleep(wait_time)
 
-        msg = "Link %s has not converged, maximum tries %s exceeded" % (
-            self.id,
-            max_iter,
-        )
+        msg = f"Link {self.id} has not converged, maximum tries {max_iter} exceeded"
         _LOGGER.error(msg)
         # after maximum retries are exceeded and link has not converged
         # error must be raised - it makes no sense to just log info
@@ -236,20 +266,34 @@ def wait_until_converged(
 
     @check_stale
     def has_converged(self) -> bool:
-        url = self.base_url + "/check_if_converged"
-        converged = self._session.get(url).json()
-        return converged
+        """
+        Check if the link has converged.
+
+        :returns: True if the link has converged, False otherwise.
+        """
+        url = self._url_for("check_if_converged")
+        return self._session.get(url).json()
 
     @check_stale
-    def start(self, wait: Optional[bool] = None) -> None:
-        url = self.base_url + "/state/start"
+    def start(self, wait: bool | None = None) -> None:
+        """
+        Start the link.
+
+        :param wait: Whether to wait for convergence after starting the link.
+        """
+        url = self._url_for("start")
         self._session.put(url)
         if self.lab.need_to_wait(wait):
             self.wait_until_converged()
 
     @check_stale
-    def stop(self, wait: Optional[bool] = None) -> None:
-        url = self.base_url + "/state/stop"
+    def stop(self, wait: bool | None = None) -> None:
+        """
+        Stop the link.
+
+        :param wait: Whether to wait for convergence after stopping the link.
+        """
+        url = self._url_for("stop")
         self._session.put(url)
         if self.lab.need_to_wait(wait):
             self.wait_until_converged()
@@ -259,14 +303,14 @@ def set_condition(
         self, bandwidth: int, latency: int, jitter: int, loss: float
     ) -> None:
         """
-        Applies conditioning to this link.
+        Set the conditioning parameters for the link.
 
-        :param bandwidth: desired bandwidth, 0-10000000 kbps
-        :param latency: desired latency, 0-10000 ms
-        :param jitter: desired jitter, 0-10000 ms
-        :param loss: desired loss, 0-100%
+        :param bandwidth: The desired bandwidth in kbps (0-10000000).
+        :param latency: The desired latency in ms (0-10000).
+        :param jitter: The desired jitter in ms (0-10000).
+        :param loss: The desired packet loss percentage (0-100).
         """
-        url = self.base_url + "/condition"
+        url = self._url_for("condition")
         data = {
             "bandwidth": bandwidth,
             "latency": latency,
@@ -278,27 +322,19 @@ def set_condition(
     @check_stale
     def get_condition(self) -> dict:
         """
-        Retrieves the current condition on this link.
-        If there is no link condition applied, an empty dictionary is returned.
-
-        (Note: this used to (erroneously) say None would be returned
-        when no condition is applied, but that was never the case.)
+        Get the current conditioning parameters for the link.
 
-        :return: the applied link condition
+        :returns: A dictionary containing the current conditioning parameters.
         """
-        url = self.base_url + "/condition"
+        url = self._url_for("condition")
         condition = self._session.get(url).json()
         keys = ["bandwidth", "latency", "jitter", "loss"]
-        result = {k: v for (k, v) in condition.items() if k in keys}
-        return result
+        return {k: v for k, v in condition.items() if k in keys}
 
     @check_stale
     def remove_condition(self) -> None:
-        """
-        Removes link conditioning.
-        If there's no condition applied then this is a no-op for the controller.
-        """
-        url = self.base_url + "/condition"
+        """Remove the link conditioning."""
+        url = self._url_for("condition")
         self._session.delete(url)
 
     def set_condition_by_name(self, name: str) -> None:
@@ -323,8 +359,8 @@ def set_condition_by_name(self, name: str) -> None:
         satellite         1500    1 mbps       0.2
         ========= ============ =========  ========
 
-        :param name: the predefined condition name as outlined in the table above
-        :raises ValueError: if the given name isn't known
+        :param name: The name of the predefined link condition.
+        :raises ValueError: If the given name is not a known predefined condition.
         """
         options = {
             "gprs": (500, 50, 2.0),
@@ -340,9 +376,9 @@ def set_condition_by_name(self, name: str) -> None:
         }
 
         if name not in options:
-            msg = "unknown condition name '{}', known values: '{}'".format(
-                name,
-                ", ".join(sorted(options)),
+            msg = (
+                f"Unknown condition name: '{name}', "
+                f"known values: '{', '.join(sorted(options))}'"
             )
             _LOGGER.error(msg)
             raise ValueError(msg)
diff --git a/virl2_client/models/node.py b/virl2_client/models/node.py
index 5aed0ab..5e16e14 100644
--- a/virl2_client/models/node.py
+++ b/virl2_client/models/node.py
@@ -1,7 +1,7 @@
 #
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -24,11 +24,11 @@
 import logging
 import time
 import warnings
-from functools import total_ordering
-from typing import TYPE_CHECKING, Any, Optional
+from copy import deepcopy
+from typing import TYPE_CHECKING, Any
 
 from ..exceptions import InterfaceNotFound
-from ..utils import check_stale, locked
+from ..utils import check_stale, get_url_from_template, locked
 from ..utils import property_s as property
 
 if TYPE_CHECKING:
@@ -40,50 +40,70 @@
 
 _LOGGER = logging.getLogger(__name__)
 
-CONFIG_WARNING = 'The property "config" is deprecated in favor of "configuration"'
 
-
-@total_ordering
 class Node:
+    _URL_TEMPLATES = {
+        "node": "{lab}/nodes/{id}?{CONFIG_MODE}",
+        "state": "{lab}/nodes/{id}/state",
+        "check_if_converged": "{lab}/nodes/{id}/check_if_converged",
+        "start": "{lab}/nodes/{id}/state/start",
+        "stop": "{lab}/nodes/{id}/state/stop",
+        "wipe_disks": "{lab}/nodes/{id}/wipe_disks",
+        "extract_configuration": "{lab}/nodes/{id}/extract_configuration",
+        "console_log": "{lab}/nodes/{id}/consoles/{console_id}/log",
+        "console_log_lines": "{lab}/nodes/{id}/consoles/{console_id}/log?lines={lines}",
+        "console_key": "{lab}/nodes/{id}/keys/console",
+        "vnc_key": "{lab}/nodes/{id}/keys/vnc",
+        "layer3_addresses": "{lab}/nodes/{id}/layer3_addresses",
+        "operational": "{lab}/nodes/{id}?operational=true&exclude_configurations=true",
+    }
+
     def __init__(
         self,
         lab: Lab,
         nid: str,
         label: str,
         node_definition: str,
-        image_definition: Optional[str],
-        configuration: Optional[str],
+        image_definition: str | None,
+        configuration: list[dict[str, str]] | str | None,
         x: int,
         y: int,
-        ram: Optional[int],
-        cpus: Optional[int],
-        cpu_limit: Optional[int],
-        data_volume: Optional[int],
-        boot_disk_size: Optional[int],
+        ram: int | None,
+        cpus: int | None,
+        cpu_limit: int | None,
+        data_volume: int | None,
+        boot_disk_size: int | None,
         hide_links: bool,
         tags: list[str],
-        resource_pool: Optional[str],
+        resource_pool: str | None,
+        parameters: dict,
+        pinned_compute_id: str | None,
     ) -> None:
         """
-        A VIRL2 Node object. Typically, a virtual machine representing a router,
-        switch or server.
-
-        :param lab: the Lab this node belongs to
-        :param nid: the Node ID
-        :param label: node label
-        :param node_definition: The node definition of this node
-        :param image_definition: The image definition of this node
-        :param configuration: The initial configuration of this node
-        :param x: X coordinate on topology canvas
-        :param y: Y coordinate on topology canvas
-        :param ram: memory of node in MiB (if applicable)
-        :param cpus: Amount of CPUs in this node (if applicable)
-        :param cpu_limit: CPU limit (default at 100%)
-        :param data_volume: Size in GiB of 2nd HDD (if > 0)
-        :param boot_disk_size: Size in GiB of boot disk (will expand to this size)
-        :param hide_links: Whether node's links should be hidden in UI visualization
-        :param tags: List of tags
-        :param resource_pool: A resource pool ID if the node is in a resource pool
+        A VIRL2 node object representing a virtual machine that serves
+        as a router, switch, or server.
+
+        :param lab: The Lab instance to which this node belongs.
+        :param nid: The ID of the node.
+        :param label: The label of the node.
+        :param node_definition: The definition of this node.
+        :param image_definition: The definition of the image used by this node.
+        :param configuration: The initial configuration of this node.
+        :param x: The X coordinate of the node on the topology canvas.
+        :param y: The Y coordinate of the node on the topology canvas.
+        :param ram: The memory of the node in MiB (if applicable).
+        :param cpus: The number of CPUs in this node (if applicable).
+        :param cpu_limit: The CPU limit of the node (default is 100%).
+        :param data_volume: The size in GiB of the second HDD (if > 0).
+        :param boot_disk_size: The size in GiB of the boot disk
+            (will expand to this size).
+        :param hide_links: A flag indicating whether the node's links should be hidden
+            in UI visualization.
+        :param tags: A list of tags associated with the node.
+        :param resource_pool: The ID of the resource pool if the node is part
+            of a resource pool.
+        :param pinned_compute_id: The ID of the compute this node is pinned to.
+            The node will not run on any other compute.
         """
         self.lab = lab
         self._id = nid
@@ -91,20 +111,26 @@ def __init__(
         self._node_definition = node_definition
         self._x = x
         self._y = y
-        self._state: Optional[str] = None
-        self._session: httpx.Client = lab.session
+        self._state: str | None = None
+        self._session: httpx.Client = lab._session
         self._image_definition = image_definition
         self._ram = ram
-        self._configuration = configuration
+        if isinstance(configuration, str):
+            self._configuration = [{"name": "Main", "content": configuration}]
+        else:
+            self._configuration = configuration
         self._cpus = cpus
         self._cpu_limit = cpu_limit
         self._data_volume = data_volume
         self._boot_disk_size = boot_disk_size
         self._hide_links = hide_links
         self._tags = tags
-        self._compute_id: Optional[str] = None
+        self._compute_id: str | None = None
         self._resource_pool = resource_pool
+        self._pinned_compute_id = pinned_compute_id
         self._stale = False
+        self._last_sync_l3_address_time = 0.0
+        self._parameters = parameters
 
         self.statistics: dict[str, int | float] = {
             "cpu_usage": 0,
@@ -143,59 +169,81 @@ def __eq__(self, other):
             return False
         return self._id == other._id
 
-    def __lt__(self, other):
-        if not isinstance(other, Node):
-            return False
-        return self._id < other._id
-
     def __hash__(self):
         return hash(self._id)
 
+    def _url_for(self, endpoint, **kwargs):
+        """
+        Generate the URL for a given API endpoint.
+
+        :param endpoint: The desired endpoint.
+        :param **kwargs: Keyword arguments used to format the URL.
+        :returns: The formatted URL.
+        """
+        kwargs["lab"] = self.lab._url_for("lab")
+        kwargs["id"] = self.id
+        return get_url_from_template(endpoint, self._URL_TEMPLATES, kwargs)
+
+    @check_stale
+    @locked
+    def sync_l3_addresses_if_outdated(self) -> None:
+        timestamp = time.time()
+        if (
+            self.lab.auto_sync
+            and timestamp - self._last_sync_l3_address_time
+            > self.lab.auto_sync_interval
+        ):
+            self.sync_layer3_addresses()
+
     @property
     @locked
-    def state(self) -> Optional[str]:
+    def state(self) -> str | None:
+        """Return the state of the node."""
         self.lab.sync_states_if_outdated()
         if self._state is None:
-            url = self._base_url + "/state"
+            url = self._url_for("state")
             self._state = self._session.get(url).json()["state"]
         return self._state
 
     @check_stale
     @locked
     def interfaces(self) -> list[Interface]:
-        # self.lab.sync_topology_if_outdated()
+        """Return a list of interfaces on the node."""
+        self.lab.sync_topology_if_outdated()
         return [iface for iface in self.lab.interfaces() if iface.node is self]
 
     @locked
     def physical_interfaces(self) -> list[Interface]:
-        # self.lab.sync_topology_if_outdated()
+        """Return a list of physical interfaces on the node."""
+        self.lab.sync_topology_if_outdated()
         return [iface for iface in self.interfaces() if iface.physical]
 
     @check_stale
     @locked
     def create_interface(
-        self, slot: Optional[int] = None, wait: bool = False
+        self, slot: int | None = None, wait: bool = False
     ) -> Interface:
         """
         Create an interface in the specified slot or, if no slot is given, in the
         next available slot.
 
-        :param slot: (optional)
-        :param wait: Wait for the creation
-        :returns: The newly created interface
+        :param slot: The slot in which the interface will be created.
+        :param wait: Wait for the creation to complete.
+        :returns: The newly created interface.
         """
         return self.lab.create_interface(self, slot, wait=wait)
 
     @locked
-    def next_available_interface(self) -> Optional[Interface]:
+    def next_available_interface(self) -> Interface | None:
         """
-        Returns the next available physical interface on this node.
+        Return the next available physical interface on this node.
 
         Note: On XR 9000v, the first two physical interfaces are marked
         as "do not use"... Only the third physical interface can be used
         to connect to other nodes!
 
-        :returns: an interface or None, if all existing ones are connected
+        :returns: An available physical interface or None if all existing
+            ones are connected.
         """
         for iface in self.interfaces():
             if not iface.connected and iface.physical:
@@ -204,6 +252,7 @@ def next_available_interface(self) -> Optional[Interface]:
 
     @locked
     def peer_interfaces(self) -> list[Interface]:
+        """Return a list of interfaces connected to this node."""
         peer_ifaces = []
         for iface in self.interfaces():
             peer_iface = iface.peer_interface
@@ -213,212 +262,344 @@ def peer_interfaces(self) -> list[Interface]:
 
     @locked
     def peer_nodes(self) -> list[Node]:
+        """Return a list of nodes connected to this node."""
         return list({iface.node for iface in self.peer_interfaces()})
 
     @locked
     def links(self) -> list[Link]:
+        """Return a list of links connected to this node."""
         return list(
             {link for iface in self.interfaces() if (link := iface.link) is not None}
         )
 
     @locked
     def degree(self) -> int:
+        """Return the degree of the node."""
         self.lab.sync_topology_if_outdated()
         return len(self.links())
 
     @property
     def id(self) -> str:
+        """Return the ID of the node."""
         return self._id
 
     @property
     def label(self) -> str:
+        """Return the label of the node."""
         self.lab.sync_topology_if_outdated()
         return self._label
 
     @label.setter
     @locked
     def label(self, value: str) -> None:
+        """Set the label of the node to the given value."""
         self._set_node_property("label", value)
         self._label = value
 
     @property
     def x(self) -> int:
+        """Return the X coordinate of the node."""
         self.lab.sync_topology_if_outdated()
         return self._x
 
     @x.setter
     @locked
     def x(self, value: int) -> None:
+        """Set the X coordinate of the node to the given value."""
         self._set_node_property("x", value)
         self._x = value
 
     @property
     def y(self) -> int:
+        """Return the Y coordinate of the node."""
         self.lab.sync_topology_if_outdated()
         return self._y
 
     @y.setter
     @locked
     def y(self, value: int) -> None:
+        """Set the Y coordinate of the node to the given value."""
         self._set_node_property("y", value)
         self._y = value
 
     @property
     def ram(self) -> int:
+        """Return the RAM size of the node in bytes."""
         self.lab.sync_topology_if_outdated()
         return self._ram
 
     @ram.setter
     @locked
     def ram(self, value: int) -> None:
+        """Set the RAM size of the node to the given value in bytes."""
         self._set_node_property("ram", value)
         self._ram = value
 
     @property
     def cpus(self) -> int:
+        """Return the number of CPUs assigned to the node."""
         self.lab.sync_topology_if_outdated()
         return self._cpus
 
     @cpus.setter
     @locked
     def cpus(self, value: int) -> None:
+        """Set the number of CPUs assigned to the node."""
         self._set_node_property("cpus", value)
         self._cpus = value
 
     @property
     def cpu_limit(self) -> int:
+        """Return the CPU limit of the node."""
         self.lab.sync_topology_if_outdated()
         return self._cpu_limit
 
     @cpu_limit.setter
     @locked
     def cpu_limit(self, value: int) -> None:
+        """Set the CPU limit of the node."""
         self._set_node_property("cpu_limit", value)
         self._cpu_limit = value
 
     @property
     def data_volume(self) -> int:
+        """Return the size (in GiB) of the second HDD."""
         self.lab.sync_topology_if_outdated()
         return self._data_volume
 
     @data_volume.setter
     @locked
     def data_volume(self, value: int) -> None:
+        """Set the size (in GiB) of the second HDD."""
         self._set_node_property("data_volume", value)
         self._data_volume = value
 
     @property
     def hide_links(self) -> bool:
+        """
+        Return a flag indicating whether the node's links should be hidden
+        in UI visualization.
+        """
         self.lab.sync_topology_if_outdated()
         return self._hide_links
 
     @hide_links.setter
     def hide_links(self, value: bool) -> None:
+        """
+        Set the flag indicating whether the node's links should be hidden
+        in UI visualization.
+        """
         self._set_node_property("hide_links", value)
         self._hide_links = value
 
     @property
     def boot_disk_size(self) -> int:
+        """Return the size of the boot disk in GiB."""
         self.lab.sync_topology_if_outdated()
         return self._boot_disk_size
 
     @boot_disk_size.setter
     @locked
     def boot_disk_size(self, value: int) -> None:
+        """Set the size of the boot disk in GiB (will expand to this size)."""
         self._set_node_property("boot_disk_size", value)
         self._boot_disk_size = value
 
     @property
-    def configuration(self) -> Optional[str]:
-        # TODO: auto sync if out of date
-        return self._configuration
+    def configuration(self) -> str | None:
+        """Return the contents of the main configuration file."""
+        self.lab.sync_topology_if_outdated(exclude_configurations=False)
+        return self._configuration[0].get("content") if self._configuration else None
 
     @configuration.setter
-    def configuration(self, value) -> None:
+    def configuration(self, value: str | list | dict | None) -> None:
+        """Set the configuration."""
         self._set_node_property("configuration", value)
-        self._configuration = value
+        self._set_configuration(value)
+
+    def _set_configuration(self, value: str | list | dict | None) -> None:
+        """
+        Set the content of:
+         - the main configuration file if passed a string,
+         - one configuration file if passed a dictionary in the format of
+        `{"name": "filename.txt", "content": "<file content>"}`,
+         - or multiple configuration files if passed a list of above dictionaries.
+        Can also use "Main" in place of the filename of the main configuration file.
+
+        :param value: The configuration data in one of three formats.
+        """
+        if self._configuration is None:
+            self._configuration = []
+        if isinstance(value, str):
+            if self._configuration:
+                self._configuration[0]["content"] = value
+            else:
+                self._configuration.append({"name": "Main", "content": value})
+            return
+        if not value:
+            self._configuration = []
+            return
+        new_configs = value if isinstance(value, list) else [value]
+        current_configs = {
+            config["name"]: idx for idx, config in enumerate(self._configuration)
+        }
+        for config in new_configs:
+            if config["name"] in current_configs:
+                self._configuration[current_configs[config["name"]]] = config
+            else:
+                self._configuration.append(config)
 
     @property
-    def config(self) -> Optional[str]:
-        warnings.warn(CONFIG_WARNING, DeprecationWarning)
+    def configuration_files(self) -> list[dict[str, str]] | None:
+        """
+        Return all configuration files, in a list in the following format:
+        `[{"name": "filename.txt", "content": "<file content>"}]`
+        """
+        self.lab.sync_topology_if_outdated(exclude_configurations=False)
+        return deepcopy(self._configuration)
+
+    @property
+    def config(self) -> str | None:
+        """
+        DEPRECATED: Use `.configuration` instead.
+        (Reason: consistency with API)
+
+        Return the initial configuration of this node.
+        """
+        warnings.warn(
+            "'Node.config' is deprecated. Use '.configuration' instead.",
+            DeprecationWarning,
+        )
         return self.configuration
 
     @config.setter
     @locked
     def config(self, value: str) -> None:
-        warnings.warn(CONFIG_WARNING, DeprecationWarning)
+        """
+        DEPRECATED: Use `.configuration` instead.
+        (Reason: consistency with API)
+
+        Set the initial configuration of this node.
+        """
+        warnings.warn(
+            "'Node.config' is deprecated. Use '.configuration' instead.",
+            DeprecationWarning,
+        )
         self.configuration = value
 
     @property
-    def image_definition(self) -> Optional[str]:
+    def parameters(self) -> dict:
+        """Return node parameters."""
+        self.lab.sync_topology_if_outdated()
+        return self._parameters
+
+    def update_parameters(self, new_params: dict) -> None:
+        """
+        Update node parameters.
+        If parameter doesn't exist it will be created. Existing nodes will be updated.
+        To delete parameter set its value to None.
+        """
+        self._session.patch(self._url_for("node"), json={"parameters": new_params})
+        self._parameters.update(new_params)
+        for key, value in new_params.items():
+            if value is None:
+                self._parameters.pop(key, None)
+
+    @property
+    def image_definition(self) -> str | None:
+        """Return the definition of the image used by this node."""
         self.lab.sync_topology_if_outdated()
         return self._image_definition
 
     @image_definition.setter
     @locked
     def image_definition(self, value: str) -> None:
-        self.lab.sync_topology_if_outdated()
+        """Set the definition of the image used by this node."""
         self._set_node_property("image_definition", value)
         self._image_definition = value
 
     @property
     def node_definition(self) -> str:
+        """Return the definition of this node."""
         self.lab.sync_topology_if_outdated()
         return self._node_definition
 
     @property
     def compute_id(self):
+        """Return the ID of the compute this node is assigned to."""
         self.lab.sync_operational_if_outdated()
         return self._compute_id
 
     @property
     def resource_pool(self) -> str:
+        """Return the ID of the resource pool if the node is part of a resource pool."""
         self.lab.sync_operational_if_outdated()
         return self._resource_pool
 
     @property
-    def lab_base_url(self) -> str:
-        return self.lab.lab_base_url
+    def pinned_compute_id(self) -> str | None:
+        """Return the ID of the compute this node is pinned to."""
+        self.lab.sync_operational_if_outdated()
+        return self._pinned_compute_id
 
-    @property
-    def _base_url(self) -> str:
-        return self.lab_base_url + "/nodes/{}".format(self.id)
+    @pinned_compute_id.setter
+    def pinned_compute_id(self, value) -> None:
+        """Set the ID of the compute this node should be pinned to."""
+        self._set_node_property("pinned_compute_id", value)
+        self._pinned_compute_id = value
 
     @property
     def cpu_usage(self) -> int | float:
+        """Return the CPU usage of this node."""
         self.lab.sync_statistics_if_outdated()
         return min(self.statistics["cpu_usage"], 100)
 
     @property
     def disk_read(self) -> int:
+        """Return the amount of disk read by this node."""
         self.lab.sync_statistics_if_outdated()
         return round(self.statistics["disk_read"] / 1048576)
 
     @property
     def disk_write(self) -> int:
+        """Return the amount of disk write by this node."""
         self.lab.sync_statistics_if_outdated()
         return round(self.statistics["disk_write"] / 1048576)
 
     @locked
     def get_interface_by_label(self, label: str) -> Interface:
+        """
+        Get the interface of the node with the specified label.
+
+        :param label: The label of the interface.
+        :returns: The interface with the specified label.
+        :raises InterfaceNotFound: If no interface with the specified label is found.
+        """
         for iface in self.interfaces():
             if iface.label == label:
                 return iface
-        raise InterfaceNotFound("{}:{}".format(label, self))
+        raise InterfaceNotFound(f"{label}:{self}")
 
     @locked
     def get_interface_by_slot(self, slot: int) -> Interface:
+        """
+        Get the interface of the node with the specified slot.
+
+        :param slot: The slot number of the interface.
+        :returns: The interface with the specified slot.
+        :raises InterfaceNotFound: If no interface with the specified slot is found.
+        """
         for iface in self.interfaces():
             if iface.slot == slot:
                 return iface
-        raise InterfaceNotFound("{}:{}".format(slot, self))
+        raise InterfaceNotFound(f"{slot}:{self}")
 
     def get_links_to(self, other_node: Node) -> list[Link]:
         """
-        Returns all links between this node and another.
+        Return all links between this node and another.
 
-        :param other_node: the other node
-        :returns: a list of links
+        :param other_node: The other node.
+        :returns: A list of links between this node and the other node.
         """
         links = []
         for link in self.links():
@@ -426,12 +607,12 @@ def get_links_to(self, other_node: Node) -> list[Link]:
                 links.append(link)
         return links
 
-    def get_link_to(self, other_node: Node) -> Optional[Link]:
+    def get_link_to(self, other_node: Node) -> Link | None:
         """
-        Returns one link between this node and another.
+        Return one link between this node and another.
 
-        :param other_node: the other node
-        :returns: a link, if one exists
+        :param other_node: The other node.
+        :returns: A link between this node and the other node, if one exists.
         """
         for link in self.links():
             if other_node in link.nodes:
@@ -440,9 +621,17 @@ def get_link_to(self, other_node: Node) -> Optional[Link]:
 
     @check_stale
     def wait_until_converged(
-        self, max_iterations: Optional[int] = None, wait_time: Optional[int] = None
+        self, max_iterations: int | None = None, wait_time: int | None = None
     ) -> None:
-        _LOGGER.info("Waiting for node %s to converge", self.id)
+        """
+        Wait until the node has converged.
+
+        :param max_iterations: The maximum number of iterations to wait for convergence.
+        :param wait_time: The time to wait between iterations.
+        :raises RuntimeError: If the node does not converge within the specified number
+            of iterations.
+        """
+        _LOGGER.info(f"Waiting for node {self.id} to converge.")
         max_iter = (
             self.lab.wait_max_iterations if max_iterations is None else max_iterations
         )
@@ -450,21 +639,16 @@ def wait_until_converged(
         for index in range(max_iter):
             converged = self.has_converged()
             if converged:
-                _LOGGER.info("Node %s has converged", self.id)
+                _LOGGER.info(f"Node {self.id} has converged.")
                 return
 
             if index % 10 == 0:
                 _LOGGER.info(
-                    "Node has not converged, attempt %s/%s, waiting...",
-                    index,
-                    max_iter,
+                    f"Node has not converged, attempt {index}/{max_iter}, waiting..."
                 )
             time.sleep(wait_time)
 
-        msg = "Node %s has not converged, maximum tries %s exceeded" % (
-            self.id,
-            max_iter,
-        )
+        msg = f"Node {self.id} has not converged, maximum tries {max_iter} exceeded."
         _LOGGER.error(msg)
         # after maximum retries are exceeded and node has not converged
         # error must be raised - it makes no sense to just log info
@@ -474,75 +658,133 @@ def wait_until_converged(
 
     @check_stale
     def has_converged(self) -> bool:
-        url = self._base_url + "/check_if_converged"
+        """
+        Check if the node has converged.
+
+        :returns: True if the node has converged, False otherwise.
+        """
+        url = self._url_for("check_if_converged")
         return self._session.get(url).json()
 
     @check_stale
     def start(self, wait=False) -> None:
-        url = self._base_url + "/state/start"
+        """
+        Start the node.
+
+        :param wait: Whether to wait until the node has converged.
+        """
+        url = self._url_for("start")
         self._session.put(url)
         if self.lab.need_to_wait(wait):
             self.wait_until_converged()
 
     @check_stale
     def stop(self, wait=False) -> None:
-        url = self._base_url + "/state/stop"
+        """
+        Stop the node.
+
+        :param wait: Whether to wait until the node has converged.
+        """
+        url = self._url_for("stop")
         self._session.put(url)
         if self.lab.need_to_wait(wait):
             self.wait_until_converged()
 
     @check_stale
     def wipe(self, wait=False) -> None:
-        url = self._base_url + "/wipe_disks"
+        """
+        Wipe the node's disks.
+
+        :param wait: Whether to wait until the node has converged.
+        """
+        url = self._url_for("wipe_disks")
         self._session.put(url)
         if self.lab.need_to_wait(wait):
             self.wait_until_converged()
 
     @check_stale
     def extract_configuration(self) -> None:
-        url = self._base_url + "/extract_configuration"
+        """Update the configuration from the running node."""
+        url = self._url_for("extract_configuration")
         self._session.put(url)
 
     @check_stale
-    def console_logs(self, console_id: int, lines: Optional[int] = None) -> dict:
-        query = "?lines=%d" % lines if lines else ""
-        url = self._base_url + "/consoles/%d/log%s" % (console_id, query)
+    def console_logs(self, console_id: int, lines: int | None = None) -> dict:
+        """
+        Get the console logs of the node.
+
+        :param console_id: The ID of the console.
+        :param lines: Limit the number of lines to retrieve.
+        :returns: A dictionary containing the console logs.
+        """
+        if lines:
+            url = self._url_for("console_log_lines", console_id=console_id, lines=lines)
+        else:
+            url = self._url_for("console_log", console_id=console_id)
         return self._session.get(url).json()
 
     @check_stale
     def console_key(self) -> str:
-        url = self._base_url + "/keys/console"
+        """
+        Get the console key of the node.
+
+        :returns: The console key.
+        """
+        url = self._url_for("console_key")
         return self._session.get(url).json()
 
     @check_stale
     def vnc_key(self) -> str:
-        url = self._base_url + "/keys/vnc"
+        """
+        Get the VNC key of the node.
+
+        :returns: The VNC key.
+        """
+        url = self._url_for("vnc_key")
         return self._session.get(url).json()
 
     def remove(self) -> None:
+        """Remove the node from the system."""
         self.lab.remove_node(self)
 
     @check_stale
     def _remove_on_server(self) -> None:
-        _LOGGER.info("Removing node %s", self)
-        url = self._base_url
+        """Helper function to remove the node from the server."""
+        _LOGGER.info(f"Removing node {self}")
+        url = self._url_for("node")
         self._session.delete(url)
 
     def remove_on_server(self) -> None:
+        """
+        DEPRECATED: Use `.remove()` instead.
+        (Reason: was never meant to be public, removing only on server is not useful)
+
+        Remove the node on the server.
+        """
         warnings.warn(
-            "'Node.remove_on_server()' is deprecated, use 'Node.remove()' instead.",
+            "'Node.remove_on_server()' is deprecated. Use '.remove()' instead.",
             DeprecationWarning,
         )
+        # To not change behavior of scripts, this will still remove on server only.
         self._remove_on_server()
 
     @check_stale
     def tags(self) -> list[str]:
-        """Returns the tags set on this node"""
+        """
+        Get the tags set on this node.
+
+        :returns: A list of tags.
+        """
         self.lab.sync_topology_if_outdated()
         return self._tags
 
     @locked
     def add_tag(self, tag: str) -> None:
+        """
+        Add a tag to this node.
+
+        :param tag: The tag to add.
+        """
         current = self.tags()
         if tag not in current:
             current.append(tag)
@@ -550,24 +792,31 @@ def add_tag(self, tag: str) -> None:
 
     @locked
     def remove_tag(self, tag: str) -> None:
+        """
+        Remove a tag from this node.
+
+        :param tag: The tag to remove.
+        """
         current = self.tags()
         current.remove(tag)
         self._set_node_property("tags", current)
 
     def run_pyats_command(self, command: str) -> str:
-        """Run a pyATS command in exec mode.
+        """
+        Run a pyATS command in exec mode on the node.
 
-        :param command: the command (like "show version")
-        :returns: the output from the device
+        :param command: The command to run (e.g. "show version").
+        :returns: The output from the device.
         """
         label = self.label
         return self.lab.pyats.run_command(label, command)
 
     def run_pyats_config_command(self, command: str) -> str:
-        """Run a pyATS command in config mode.
+        """
+        Run a pyATS command in config mode on the node.
 
-        :param command: the command (like "interface gi0")
-        :returns: the output from the device
+        :param command: The command to run (e.g. "interface gi0").
+        :returns: The output from the device.
         """
         label = self.label
         return self.lab.pyats.run_config_command(label, command)
@@ -575,13 +824,13 @@ def run_pyats_config_command(self, command: str) -> str:
     @check_stale
     @locked
     def sync_layer3_addresses(self) -> None:
-        """Acquire all L3 addresses from the controller. For this
-        to work, the device has to be attached to the external network
+        """
+        Acquire all layer 3 addresses from the controller.
+
+        For this to work, the device has to be attached to the external network
         in bridge mode and must run DHCP to acquire an IP address.
         """
-        # TODO: can optimise the sync of l3 to only be for the node
-        # rather than whole lab
-        url = self._base_url + "/layer3_addresses"
+        url = self._url_for("layer3_addresses")
         result = self._session.get(url).json()
         interfaces = result.get("interfaces", {})
         self.map_l3_addresses_to_interfaces(interfaces)
@@ -589,9 +838,15 @@ def sync_layer3_addresses(self) -> None:
     @check_stale
     @locked
     def sync_operational(self, response: dict[str, Any] = None):
+        """
+        Synchronize the operational state of the node.
+
+        :param response: The response from the server.
+        """
         if response is None:
-            url = self._base_url + "?operational=true"
+            url = self._url_for("operational")
             response = self._session.get(url).json()
+        self._pinned_compute_id = response.get("pinned_compute_id")
         operational = response.get("operational", {})
         self._compute_id = operational.get("compute_id")
         self._resource_pool = operational.get("resource_pool")
@@ -601,21 +856,26 @@ def sync_operational(self, response: dict[str, Any] = None):
     def map_l3_addresses_to_interfaces(
         self, mapping: dict[str, dict[str, str]]
     ) -> None:
+        """
+        Map layer 3 addresses to interfaces.
+
+        :param mapping: A dictionary mapping MAC addresses to interface information.
+        """
         for mac_address, entry in mapping.items():
-            label = entry.get("label")
-            if not label:
+            if not (label := entry.get("label")):
                 continue
-            ipv4 = entry.get("ip4")
-            ipv6 = entry.get("ip6")
             try:
                 iface = self.get_interface_by_label(label)
             except InterfaceNotFound:
                 continue
-            iface.ip_snooped_info = {
+            ipv4 = entry.get("ip4")
+            ipv6 = entry.get("ip6")
+            iface._ip_snooped_info = {
                 "mac_address": mac_address,
                 "ipv4": ipv4,
                 "ipv6": ipv6,
             }
+        self._last_sync_l3_address_time = time.time()
 
     @check_stale
     @locked
@@ -623,15 +883,25 @@ def update(
         self,
         node_data: dict[str, Any],
         exclude_configurations: bool,
-        push_to_server: bool = False,
+        push_to_server: bool = True,
     ) -> None:
+        """
+        Update the node with the provided data.
+
+        :param node_data: The data to update the node with.
+        :param exclude_configurations: Whether to exclude configuration updates.
+        :param push_to_server: Whether to push the changes to the server.
+            Defaults to True; should only be False when used by internal methods.
+        """
         if push_to_server:
             self._set_node_properties(node_data)
         if "data" in node_data:
             node_data = node_data["data"]
 
         for key, value in node_data.items():
-            if key == "configuration" and exclude_configurations:
+            if key == "configuration":
+                if not exclude_configurations:
+                    self._set_configuration(value)
                 continue
             if key == "operational":
                 self.sync_operational(node_data)
@@ -639,16 +909,38 @@ def update(
             setattr(self, f"_{key}", value)
 
     def is_active(self) -> bool:
+        """
+        Check if the node is in an active state.
+
+        :returns: True if the node is in an active state, False otherwise.
+        """
         active_states = {"STARTED", "QUEUED", "BOOTED"}
         return self.state in active_states
 
     def is_booted(self) -> bool:
+        """
+        Check if the node is booted.
+
+        :returns: True if the node is booted, False otherwise.
+        """
         return self.state == "BOOTED"
 
     def _set_node_property(self, key: str, val: Any) -> None:
-        _LOGGER.debug("Setting node property %s %s: %s", self, key, val)
+        """
+        Set a property of the node.
+
+        :param key: The key of the property to set.
+        :param val: The value to set.
+        """
+        _LOGGER.debug(f"Setting node property {self} {key}: {val}")
         self._set_node_properties({key: val})
 
     @check_stale
     def _set_node_properties(self, node_data: dict[str, Any]) -> None:
-        self._session.patch(url=self._base_url, json=node_data)
+        """
+        Set multiple properties of the node.
+
+        :param node_data: A dictionary containing the properties to set.
+        """
+        url = self._url_for("node")
+        self._session.patch(url, json=node_data)
diff --git a/virl2_client/models/node_image_definitions.py b/virl2_client/models/node_image_definitions.py
index 5daf2c2..5075431 100644
--- a/virl2_client/models/node_image_definitions.py
+++ b/virl2_client/models/node_image_definitions.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -24,62 +24,75 @@
 import pathlib
 import time
 import warnings
-from typing import TYPE_CHECKING, BinaryIO, Callable, Optional
+from typing import TYPE_CHECKING, BinaryIO, Callable
 
-from virl2_client.exceptions import InvalidContentType, InvalidImageFile
+from ..exceptions import InvalidContentType, InvalidImageFile
+from ..utils import get_url_from_template
 
 if TYPE_CHECKING:
     import httpx
 
 
 class NodeImageDefinitions:
+    _URL_TEMPLATES = {
+        "node_defs": "node_definitions/",
+        "image_defs": "image_definitions/",
+        "node_def": "node_definitions/{definition_id}",
+        "image_def": "image_definitions/{definition_id}",
+        "node_image_defs": "node_definitions/{definition_id}/image_definitions",
+        "upload": "images/upload",
+        "image_list": "list_image_definition_drop_folder/",
+        "image_manage": "images/manage/{filename}",
+    }
+
     def __init__(self, session: httpx.Client) -> None:
         """
-        VIRL2 Definition classes to specify a node VM and associated disk images.
+        Manage node and image definitions.
+
+        Node definitions define the properties of a virtual network node.
+        Image definitions define disk images that are required to boot a network node.
+        Together, they define a complete virtual network node.
 
-        :param session: httpx Client session
+        :param session: The httpx-based HTTP client for this session with the server.
         """
         self._session = session
 
-    @property
-    def session(self) -> httpx.Client:
+    def _url_for(self, endpoint, **kwargs):
         """
-        Returns the used httpx client session object.
+        Generate the URL for a given API endpoint.
 
-        :returns: The session object
+        :param endpoint: The desired endpoint.
+        :param **kwargs: Keyword arguments used to format the URL.
+        :returns: The formatted URL.
         """
-        return self._session
+        return get_url_from_template(endpoint, self._URL_TEMPLATES, kwargs)
 
     def node_definitions(self) -> list[dict]:
         """
-        Returns all node definitions.
+        Return all node definitions.
 
-        :return: list of node definitions
+        :returns: A list of node definitions.
         """
-        url = "node_definitions/"
+        url = self._url_for("node_defs")
         return self._session.get(url).json()
 
     def image_definitions(self) -> list[dict]:
         """
-        Returns all image definitions.
+        Return all image definitions.
 
-        :return: list of image definitions
+        :returns: A list of image definitions.
         """
-        url = "image_definitions/"
+        url = self._url_for("image_defs")
         return self._session.get(url).json()
 
     def image_definitions_for_node_definition(self, definition_id: str) -> list[dict]:
         """
-        Returns all image definitions for a given node definition
-
-        example::
+        Return all image definitions for a given node definition.
 
-            client_library.definitions.image_definitions_for_node_definition("iosv")
-
-        :param definition_id: node definition id
-        :returns: list of image definition objects
+        :param definition_id: The ID of the node definition.
+        :returns: A list of image definition objects.
         """
-        url = "node_definitions/" + definition_id + "/image_definitions"
+        url = self._url_for("node_image_defs", definition_id=definition_id)
         return self._session.get(url).json()
 
     def set_image_definition_read_only(
@@ -108,89 +121,103 @@ def set_node_definition_read_only(
         url = f"node_definitions/{definition_id}/read_only"
         return self._session.put(url, json=read_only).json()
 
-    def upload_node_definition(self, body: str | dict, json: bool | None = None) -> str:
+    def upload_node_definition(
+        self, body: str | dict, update: bool = False, json: bool | None = None
+    ) -> str:
         """
-        Uploads a new node definition.
+        Upload a new node definition.
 
-        :param body: node definition (yaml or json)
-        :param json: DEPRECATED, replaced with type check
-        :return: "Success"
+        :param body: The node definition (yaml or json).
+        :param update: If creating a new node definition or updating an existing one.
+        :param json: DEPRECATED: Replaced with type check.
+        :returns: "Success".
         """
         is_json = _is_json_content(body)
         if json is not None:
             warnings.warn(
-                'The argument "json" is deprecated as the content type is determined '
-                "from the provided body",
+                "'NodeImageDefinitions.upload_node_definition()': "
+                "The argument 'json' is deprecated as the content type "
+                "is determined from the provided 'body'.",
                 DeprecationWarning,
             )
             is_json = True
-        url = "node_definitions/"
+        url = self._url_for("node_defs")
+        method = "PUT" if update else "POST"
         if is_json:
-            return self._session.post(url, json=body).json()
+            return self._session.request(method, url, json=body).json()
         else:
             # YAML
-            return self._session.post(url, content=body).json()
+            return self._session.request(method, url, content=body).json()
 
     def upload_image_definition(
-        self, body: str | dict, json: bool | None = None
+        self, body: str | dict, update: bool = False, json: bool | None = None
     ) -> str:
         """
-        Uploads a new image definition.
+        Upload a new image definition.
 
-        :param body: image definition (yaml or json)
-        :param json: DEPRECATED, replaced with type check
-        :return: "Success"
+        :param body: The image definition (yaml or json).
+        :param update: If creating a new image definition or updating an existing one.
+        :param json: DEPRECATED: Replaced with type check.
+        :returns: "Success".
         """
         is_json = _is_json_content(body)
         if json is not None:
             warnings.warn(
-                'The argument "json" is deprecated as the content type is determined '
-                "from the provided body",
+                "'NodeImageDefinitions.upload_image_definition()': "
+                "The argument 'json' is deprecated as the content type "
+                "is determined from the provided 'body'.",
                 DeprecationWarning,
             )
             is_json = True
-        url = "image_definitions/"
+        url = self._url_for("image_defs")
+        method = "PUT" if update else "POST"
         if is_json:
-            return self._session.post(url, json=body).json()
+            return self._session.request(method, url, json=body).json()
         else:
             # YAML
-            return self._session.post(url, content=body).json()
+            return self._session.request(method, url, content=body).json()
 
     def download_node_definition(self, definition_id: str) -> str:
         """
-        Returns the node definition for a given definition ID
+        Return the node definition for a given definition ID.
 
         Example::
 
             client_library.definitions.download_node_definition("iosv")
 
-        :param definition_id: the node definition ID
-        :returns: the node definition as YAML
+        :param definition_id: The ID of the node definition.
+        :returns: The node definition as YAML.
         """
-        url = "node_definitions/" + definition_id
+        url = self._url_for("node_def", definition_id=definition_id)
         return self._session.get(url).json()
 
     def download_image_definition(self, definition_id: str) -> str:
         """
+        Return the image definition for a given definition ID.
+
         Example::
 
             client_library.definitions.download_image_definition("iosv-158-3")
 
-        :param definition_id: the image definition ID
-        :returns: the image definition as YAML
+        :param definition_id: The ID of the image definition.
+        :returns: The image definition as YAML.
         """
-
-        url = "image_definitions/" + definition_id
+        url = self._url_for("image_def", definition_id=definition_id)
         return self._session.get(url).json()
 
-    def upload_image_file(self, filename: str, rename: Optional[str] = None) -> None:
-        """
-        :param filename: the path of the image to upload
-        :param rename:  Optional filename to rename to
+    def upload_image_file(
+        self,
+        filename: str,
+        rename: str | None = None,
+    ) -> None:
         """
+        Upload an image file.
 
-        extension_list = [".qcow", ".qcow2"]
-        url = "images/upload"
+        :param filename: The path of the image to upload.
+        :param rename: Optional filename to rename to.
+        """
+        extension_list = [".qcow", ".qcow2", ".iol"]
+        url = self._url_for("upload")
 
         path = pathlib.Path(filename)
         extension = "".join(path.suffixes)
@@ -233,55 +260,69 @@ def callback_read(__n):
 
             return callback_read
 
-        file = open(filename, "rb")
-        file.read = callback_read_factory(file, print_progress_bar)
-        files = {"field0": (name, file)}
+        _file = open(filename, "rb")
+        _file.read = callback_read_factory(_file, print_progress_bar)
+        files = {"field0": (name, _file)}
 
         self._session.post(url, files=files, headers=headers)
         print("Upload completed")
 
     def download_image_file_list(self) -> list[str]:
-        url = "list_image_definition_drop_folder/"
+        """
+        Return a list of image files.
+
+        :returns: A list of image file names.
+        """
+        url = self._url_for("image_list")
         return self._session.get(url).json()
 
     def remove_dropfolder_image(self, filename: str) -> str:
         """
-        :returns: "Success"
+        Remove an image file from the drop folder.
+
+        :param filename: The name of the image file to remove.
+        :returns: "Success".
         """
-        url = "images/manage/" + filename
+        url = self._url_for("image_manage", filename=filename)
         return self._session.delete(url).json()
 
     def remove_node_definition(self, definition_id: str) -> None:
         """
-        Removes the node definition with the given ID.
+        Remove the node definition with the given ID.
 
         Example::
 
             client_library.definitions.remove_node_definition("iosv-custom")
 
-        :param definition_id: the definition ID to delete
+        :param definition_id: The ID of the node definition to remove.
         """
-
-        url = "node_definitions/" + definition_id
+        url = self._url_for("node_def", definition_id=definition_id)
         self._session.delete(url)
 
     def remove_image_definition(self, definition_id: str) -> None:
         """
-        Removes the image definition with the given ID.
+        Remove the image definition with the given ID.
 
         Example::
 
             client_library.definitions.remove_image_definition("iosv-158-3-custom")
 
-        :param definition_id: the image definition ID to remove
+        :param definition_id: The ID of the image definition to remove.
         """
-
-        url = "image_definitions/" + definition_id
+        url = self._url_for("image_def", definition_id=definition_id)
         self._session.delete(url)
 
 
 def print_progress_bar(cur: int, total: int, start_time: float, length=50) -> None:
-    percent = "{0:.1f}".format(100 * (cur / float(total)))
+    """
+    Print a progress bar.
+
+    :param cur: The current progress value.
+    :param total: The total progress value.
+    :param start_time: The start time of the progress.
+    :param length: The length of the progress bar.
+    """
+    percent = f"{100 * (cur / float(total)):.1f}"
     filled_len = int(round(length * cur / float(total)))
     bar = "#" * filled_len + "-" * (length - filled_len)
     raw_elapsed = time.time() - start_time
@@ -296,6 +337,13 @@ def print_progress_bar(cur: int, total: int, start_time: float, length=50) -> No
 
 
 def _is_json_content(content: dict | str) -> bool:
+    """
+    Check if the content is JSON.
+
+    :param content: The content to check.
+    :returns: True if the content is JSON, False otherwise.
+    :raises InvalidContentType: If the content type is invalid.
+    """
     if isinstance(content, dict):
         return True
     elif isinstance(content, str):
diff --git a/virl2_client/models/resource_pools.py b/virl2_client/models/resource_pools.py
index 27a2d7f..4366cd6 100644
--- a/virl2_client/models/resource_pools.py
+++ b/virl2_client/models/resource_pools.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -23,9 +23,10 @@
 import logging
 import time
 from dataclasses import dataclass
-from typing import TYPE_CHECKING, Any, Dict, Iterable, Optional
+from typing import TYPE_CHECKING, Any, Dict, Iterable
 
-from virl2_client.exceptions import InvalidProperty
+from ..exceptions import InvalidProperty
+from ..utils import get_url_from_template
 
 if TYPE_CHECKING:
     import httpx
@@ -38,13 +39,18 @@ class ResourcePoolManagement:
     # updatable in both the client itself and its lab objects, as otherwise
     # the lab might receive the ID of a resource pool that doesn't exist yet
     # in the client, because the client wasn't updated recently
+    _URL_TEMPLATES = {
+        "resource_pools": "resource_pools",
+        "resource_pools_data": "resource_pools?data=true",
+    }
+
     def __init__(self, session: httpx.Client, auto_sync=True, auto_sync_interval=1.0):
         """
-        Sync and modify resource pools.
+        Manage and synchronize resource pools.
 
-        :param session: parent client's httpx.Client object
-        :param auto_sync: whether to automatically synchronize resource pools
-        :param auto_sync_interval: how often to synchronize resource pools in seconds
+        :param session: The httpx-based HTTP client for this session with the server.
+        :param auto_sync: Whether to automatically synchronize resource pools.
+        :param auto_sync_interval: How often to synchronize resource pools in seconds.
         """
         self._session = session
         self.auto_sync = auto_sync
@@ -52,16 +58,24 @@ def __init__(self, session: httpx.Client, auto_sync=True, auto_sync_interval=1.0
         self._last_sync_resource_pool_time = 0.0
         self._resource_pools: ResourcePools = {}
 
-    @property
-    def session(self) -> httpx.Client:
-        return self._session
+    def _url_for(self, endpoint, **kwargs):
+        """
+        Generate the URL for a given API endpoint.
+
+        :param endpoint: The desired endpoint.
+        :param **kwargs: Keyword arguments used to format the URL.
+        :returns: The formatted URL.
+        """
+        return get_url_from_template(endpoint, self._URL_TEMPLATES, kwargs)
 
     @property
     def resource_pools(self) -> ResourcePools:
+        """Return all resource pools."""
         self.sync_resource_pools_if_outdated()
         return self._resource_pools
 
     def sync_resource_pools_if_outdated(self) -> None:
+        """Synchronize resource pools if they are outdated."""
         timestamp = time.time()
         if (
             self.auto_sync
@@ -70,7 +84,8 @@ def sync_resource_pools_if_outdated(self) -> None:
             self.sync_resource_pools()
 
     def sync_resource_pools(self) -> None:
-        url = "resource_pools?data=true"
+        """Synchronize the resource pools with the server."""
+        url = self._url_for("resource_pools_data")
         res_pools = self._session.get(url).json()
         res_pool_ids = []
         # update existing pools and add entries for new pools
@@ -78,7 +93,7 @@ def sync_resource_pools(self) -> None:
             pool_id = res_pool.pop("id")
             res_pool["pool_id"] = pool_id
             if pool_id in self._resource_pools:
-                self._resource_pools[pool_id].update(res_pool)
+                self._resource_pools[pool_id].update(res_pool, push_to_server=False)
             else:
                 self._add_resource_pool_local(**res_pool)
             res_pool_ids.append(pool_id)
@@ -92,14 +107,12 @@ def get_resource_pools_by_ids(
         self, resource_pool_ids: str | Iterable[str]
     ) -> ResourcePool | ResourcePools:
         """
-        Converts either one resource pool ID into a `ResourcePool` object
-        or multiple IDs in an iterable into a dict of IDs and `ResourcePool` objects.
-
-        :param resource_pool_ids: a resource pool ID or an iterable of IDs
-        :returns: a `ResourcePool` when one ID is passed or, when an iterable of pools
-            is passed, a dictionary of IDs to `ResourcePool`s or `None`s when
-            resource pool doesn't exist
-        :raises KeyError: when one ID is passed and it doesn't exist
+        Get resource pools by their IDs.
+
+        :param resource_pool_ids: A resource pool ID or an iterable of IDs.
+        :returns: A ResourcePool object when one ID is passed, or a dictionary of IDs to
+            either ResourcePools when a resource pool exists or None when it doesn't.
+        :raises KeyError: When one ID is passed and it doesn't exist.
         """
         self.sync_resource_pools_if_outdated()
         if isinstance(resource_pool_ids, str):
@@ -118,14 +131,15 @@ def create_resource_pool(
         **kwargs,
     ) -> ResourcePool:
         """
-        Creates a resource pool with the given parameters.
+        Create a resource pool with the given parameters.
 
-        :param label: required pool label
-        :param kwargs: other resource pool parameters as accepted by ResourcePool object
-        :returns: resource pool object
+        :param label: The label for the resource pools.
+        :param kwargs: Additional resource pool parameters as accepted by the
+            ResourcePool object.
+        :returns: The created ResourcePool object.
         """
         kwargs["label"] = label
-        url = "resource_pools"
+        url = self._url_for("resource_pools")
         response: dict = self._session.post(url, json=kwargs).json()
         response["pool_id"] = response.pop("id")
         return self._add_resource_pool_local(**response)
@@ -137,20 +151,22 @@ def create_resource_pools(
         **kwargs,
     ) -> list[ResourcePool]:
         """
-        Creates a list of resource pools with the given parameters.
+        Create a list of resource pools with the given parameters.
         If no template is supplied, a new template pool is created with the specified
         parameters, and each user is assigned a new pool with no additional limits.
         If a template pool is supplied, then parameters are applied to each user pool.
 
-        :param label: pool label
-        :param users: list of user IDs for which to create resource pools
-        :param kwargs: other resource pool parameters as accepted by ResourcePool object
-        :returns: resource pool objects, with template pool first if created
+        :param label: The label for the resource pools.
+        :param users: The list of user IDs for which to create resource pools.
+        :param kwargs: Additional resource pool parameters as accepted by the
+            ResourcePool object.
+        :returns: A list of created resource pool objects, with the template pool first
+            if created.
         """
         kwargs["label"] = label
         kwargs["users"] = users
         kwargs["shared"] = False
-        url = "resource_pools"
+        url = self._url_for("resource_pools")
         response: dict = self._session.post(url, json=kwargs).json()
 
         result = []
@@ -163,16 +179,17 @@ def _add_resource_pool_local(
         self,
         pool_id: str,
         label: str,
-        description: Optional[str] = None,
-        template: Optional[str] = None,
-        licenses: Optional[int] = None,
-        ram: Optional[int] = None,
-        cpus: Optional[int] = None,
-        disk_space: Optional[int] = None,
-        external_connectors: Optional[list[str]] = None,
-        users: Optional[list[str]] = None,
-        user_pools: Optional[list[str]] = None,
+        description: str | None = None,
+        template: str | None = None,
+        licenses: int | None = None,
+        ram: int | None = None,
+        cpus: int | None = None,
+        disk_space: int | None = None,
+        external_connectors: list[str] | None = None,
+        users: list[str] | None = None,
+        user_pools: list[str] | None = None,
     ) -> ResourcePool:
+        """Helper method to add resource pool locally."""
         new_resource_pool = ResourcePool(
             self,
             pool_id,
@@ -192,23 +209,44 @@ def _add_resource_pool_local(
 
 
 class ResourcePool:
+    _URL_TEMPLATES = {
+        "resource_pool": "resource_pools/{pool_id}",
+        "resource_pool_usage": "resource_pool_usage/{pool_id}",
+    }
+
     def __init__(
         self,
         resource_pools: ResourcePoolManagement,
         pool_id: str,
         label: str,
-        description: Optional[str],
-        template: Optional[str],
-        licenses: Optional[int],
-        ram: Optional[int],
-        cpus: Optional[int],
-        disk_space: Optional[int],
-        external_connectors: Optional[list[str]],
-        users: Optional[list[str]],
-        user_pools: Optional[list[str]],
+        description: str | None,
+        template: str | None,
+        licenses: int | None,
+        ram: int | None,
+        cpus: int | None,
+        disk_space: int | None,
+        external_connectors: list[str] | None,
+        users: list[str] | None,
+        user_pools: list[str] | None,
     ):
+        """
+        Initialize a resource pool.
+
+        :param resource_pools: The parent ResourcePoolManagement object.
+        :param pool_id: The ID of the resource pool.
+        :param label: The label of the resource pool.
+        :param description: The description of the resource pool.
+        :param template: The template of the resource pool.
+        :param licenses: The number of licenses in the resource pool.
+        :param ram: The amount of RAM in the resource pool.
+        :param cpus: The number of CPUs in the resource pool.
+        :param disk_space: The amount of disk space in the resource pool.
+        :param external_connectors: A list of external connectors in the resource pool.
+        :param users: A list of users in the resource pool.
+        :param user_pools: A list of user pools in the resource pool.
+        """
         self._resource_pools = resource_pools
-        self._session: httpx.Client = resource_pools.session
+        self._session: httpx.Client = resource_pools._session
         self._id = pool_id
         self._label = label
         self._description = description
@@ -220,8 +258,6 @@ def __init__(
         self._external_connectors = external_connectors
         self._users = users if users is not None else []
         self._user_pools = user_pools if user_pools is not None else []
-        self._base_url = f"{self._session.base_url}/resource_pools/{pool_id}"
-        self._usage_base_url = f"{self._session.base_url}/resource_pool_usage/{pool_id}"
 
     def __str__(self):
         return f"Resource pool: {self._label}"
@@ -235,113 +271,160 @@ def __repr__(self):
             self._template,
         )
 
+    def _url_for(self, endpoint, **kwargs):
+        """
+        Generate the URL for a given API endpoint.
+
+        :param endpoint: The desired endpoint.
+        :param **kwargs: Keyword arguments used to format the URL.
+        :returns: The formatted URL.
+        """
+        kwargs["pool_id"] = self.id
+        return get_url_from_template(endpoint, self._URL_TEMPLATES, kwargs)
+
     @property
     def id(self) -> str:
+        """Return the ID of the resource pool."""
         return self._id
 
     @property
     def label(self) -> str:
+        """Set the label of the resource pool."""
         self._resource_pools.sync_resource_pools_if_outdated()
         return self._label
 
     @label.setter
     def label(self, value: str):
+        """Set the label of the resource pool."""
         self._set_resource_pool_property("label", value)
         self._label = value
 
     @property
     def description(self) -> str:
+        """Return the description of the resource pool."""
         self._resource_pools.sync_resource_pools_if_outdated()
         return self._description
 
     @description.setter
     def description(self, value: str):
+        """Set the description of the resource pool."""
         self._set_resource_pool_property("description", value)
         self._description = value
 
     @property
     def template(self) -> str:
+        """Return the template of the resource pool."""
         return self._template
 
     @property
     def is_template(self) -> bool:
+        """Return whether the resource pool is a template."""
         return self._template is None
 
     @property
     def licenses(self) -> int:
+        """Return the number of licenses in the resource pool."""
         self._resource_pools.sync_resource_pools_if_outdated()
         return self._licenses
 
     @licenses.setter
     def licenses(self, value: int):
+        """Set the number of licenses in the resource pool."""
         self._set_resource_pool_property("licenses", value)
         self._licenses = value
 
     @property
     def ram(self) -> int:
+        """Return the amount of RAM in the resource pool."""
         self._resource_pools.sync_resource_pools_if_outdated()
         return self._ram
 
     @ram.setter
     def ram(self, value: int):
+        """Set the amount of RAM in the resource pool."""
         self._set_resource_pool_property("ram", value)
         self._ram = value
 
     @property
     def cpus(self) -> int:
+        """Return the number of CPUs in the resource pool."""
         self._resource_pools.sync_resource_pools_if_outdated()
         return self._cpus
 
     @cpus.setter
     def cpus(self, value: int):
+        """Set the number of CPUs in the resource pool."""
         self._set_resource_pool_property("cpus", value)
         self._cpus = value
 
     @property
     def disk_space(self) -> int:
+        """Return the amount of disk space in the resource pool."""
         self._resource_pools.sync_resource_pools_if_outdated()
         return self._disk_space
 
     @disk_space.setter
     def disk_space(self, value: int):
+        """Set the amount of disk space in the resource pool."""
         self._set_resource_pool_property("disk_space", value)
         self._disk_space = value
 
     @property
-    def external_connectors(self) -> Optional[list[str]]:
+    def external_connectors(self) -> list[str] | None:
+        """Return a list of external connectors in the resource pool."""
         self._resource_pools.sync_resource_pools_if_outdated()
-        return self._external_connectors
+        return (
+            self._external_connectors.copy()
+            if self._external_connectors is not None
+            else None
+        )
 
     @external_connectors.setter
-    def external_connectors(self, value: Optional[list[str]]):
+    def external_connectors(self, value: list[str] | None):
+        """Set the external connectors in the resource pool."""
         self._set_resource_pool_property("external_connectors", value)
         self._external_connectors = value
 
     @property
     def users(self) -> list[str]:
+        """Return the list of users in the resource pool."""
         if self.is_template:
-            raise InvalidProperty("A template does not have 'users' property")
+            raise InvalidProperty("A template does not have the 'users' property.")
         self._resource_pools.sync_resource_pools_if_outdated()
         return self._users
 
     @property
     def user_pools(self) -> list[str]:
+        """Return the list of user pools in the template."""
         if not self.is_template:
-            raise InvalidProperty("A resource pool does not have 'user_pools' property")
+            raise InvalidProperty(
+                "A resource pool does not have the 'user_pools' property."
+            )
         self._resource_pools.sync_resource_pools_if_outdated()
         return self._user_pools
 
     def get_usage(self) -> ResourcePoolUsage:
-        result = self._session.get(self._usage_base_url).json()
+        """Get the usage stats of the resource pool."""
+        url = self._url_for("resource_pool_usage")
+        result = self._session.get(url).json()
         limit = ResourcePoolUsageBase(**result["limit"])
         usage = ResourcePoolUsageBase(**result["usage"])
         return ResourcePoolUsage(limit, usage)
 
     def remove(self) -> None:
-        _LOGGER.info("Removing resource pool %s", self)
-        self._session.delete(self._base_url)
+        """Remove the resource pool."""
+        _LOGGER.info(f"Removing resource pool {self}")
+        url = self._url_for("resource_pool")
+        self._session.delete(url)
+
+    def update(self, pool_data: dict[str, Any], push_to_server: bool = True):
+        """
+        Update multiple properties of the pool at once.
 
-    def update(self, pool_data: dict[str, Any], push_to_server: bool = False):
+        :param pool_data: A dictionary of the properties to update.
+        :param push_to_server: Whether to push the changes to the server.
+            Defaults to True; should only be False when used by internal methods.
+        """
         if push_to_server:
             self._set_resource_pool_properties(pool_data)
 
@@ -349,15 +432,18 @@ def update(self, pool_data: dict[str, Any], push_to_server: bool = False):
             setattr(self, f"_{key}", value)
 
     def _set_resource_pool_property(self, key: str, val: Any) -> None:
-        _LOGGER.debug("Setting resource pool property %s %s: %s", self, key, val)
+        """Helper method to set a property on the server."""
+        _LOGGER.debug(f"Setting resource pool property {self} {key}: {val}")
         self._set_resource_pool_properties({key: val})
 
     def _set_resource_pool_properties(self, resource_pool_data: dict[str, Any]) -> None:
+        """Helper method to set multiple properties on the server."""
         for key in list(resource_pool_data):
             # drop unmodifiable properties
             if key in ("id", "template", "users", "user_pools"):
                 resource_pool_data.pop(key)
-        self._session.patch(url=self._base_url, json=resource_pool_data)
+        url = self._url_for("resource_pool")
+        self._session.patch(url, json=resource_pool_data)
 
 
 ResourcePools = Dict[str, ResourcePool]
diff --git a/virl2_client/models/system.py b/virl2_client/models/system.py
index 1d5fe83..ef7fcac 100644
--- a/virl2_client/models/system.py
+++ b/virl2_client/models/system.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -22,7 +22,11 @@
 
 import logging
 import time
-from typing import TYPE_CHECKING, Any, Optional
+from typing import TYPE_CHECKING, Any
+
+from virl2_client.exceptions import ControllerNotFound, InvalidMacAddressBlock
+
+from ..utils import get_url_from_template
 
 if TYPE_CHECKING:
     import httpx
@@ -31,7 +35,31 @@
 
 
 class SystemManagement:
-    def __init__(self, session: httpx.Client, auto_sync=True, auto_sync_interval=1.0):
+    _URL_TEMPLATES = {
+        "maintenance_mode": "system/maintenance_mode",
+        "compute_hosts": "system/compute_hosts",
+        "notices": "system/notices",
+        "external_connectors": "system/external_connectors",
+        "external_connector": "system/external_connectors/{connector_id}",
+        "web_session_timeout": "web_session_timeout/{timeout}",
+        "mac_address_block": "mac_address_block/{block}",
+        "host_configuration": "system/compute_hosts/configuration",
+    }
+
+    def __init__(
+        self,
+        session: httpx.Client,
+        auto_sync: bool = True,
+        auto_sync_interval: float = 1.0,
+    ):
+        """
+        Manage the underlying controller software and the host system where it runs.
+
+        :param session: The httpx-based HTTP client for this session with the server.
+        :param auto_sync: A boolean indicating whether auto synchronization is enabled.
+        :param auto_sync_interval: The interval in seconds between auto
+            synchronizations.
+        """
         self._session = session
         self.auto_sync = auto_sync
         self.auto_sync_interval = auto_sync_interval
@@ -40,53 +68,79 @@ def __init__(self, session: httpx.Client, auto_sync=True, auto_sync_interval=1.0
         self._compute_hosts: dict[str, ComputeHost] = {}
         self._system_notices: dict[str, SystemNotice] = {}
         self._maintenance_mode = False
-        self._maintenance_notice: Optional[SystemNotice] = None
+        self._maintenance_notice: SystemNotice | None = None
 
-    @property
-    def session(self) -> httpx.Client:
-        return self._session
+    def _url_for(self, endpoint, **kwargs):
+        """
+        Generate the URL for a given API endpoint.
+
+        :param endpoint: The desired endpoint.
+        :param **kwargs: Keyword arguments used to format the URL.
+        :returns: The formatted URL.
+        """
+        return get_url_from_template(endpoint, self._URL_TEMPLATES, kwargs)
 
     @property
     def compute_hosts(self) -> dict[str, ComputeHost]:
+        """Return a dictionary of compute hosts."""
         self.sync_compute_hosts_if_outdated()
-        return self._compute_hosts
+        return self._compute_hosts.copy()
+
+    @property
+    def controller(self) -> ComputeHost:
+        """Return the controller
+
+        :raises ControllerNotFound: If no controller has been found
+            (should never be the case).
+        :returns: The controller object.
+        """
+        for compute_host in self._compute_hosts.values():
+            if compute_host.is_connector:
+                return compute_host
+        raise ControllerNotFound
 
     @property
     def system_notices(self) -> dict[str, SystemNotice]:
+        """Return a dictionary of system notices."""
         self.sync_system_notices_if_outdated()
-        return self._system_notices
+        return self._system_notices.copy()
 
     @property
     def maintenance_mode(self) -> bool:
+        """Return the maintenance mode status."""
         self.sync_system_notices_if_outdated()
         return self._maintenance_mode
 
     @maintenance_mode.setter
     def maintenance_mode(self, value: bool) -> None:
-        url = "system/maintenance_mode"
+        """Set the maintenance mode status."""
+        url = self._url_for("maintenance_mode")
         self._session.patch(url, json={"maintenance_mode": value})
         self._maintenance_mode = value
 
     @property
-    def maintenance_notice(self) -> Optional[SystemNotice]:
+    def maintenance_notice(self) -> SystemNotice | None:
+        """Return the current maintenance notice."""
         self.sync_system_notices_if_outdated()
         return self._maintenance_notice
 
     @maintenance_notice.setter
-    def maintenance_notice(self, notice: Optional[SystemNotice]) -> None:
-        url = "system/maintenance_mode"
+    def maintenance_notice(self, notice: SystemNotice | None) -> None:
+        """Set the maintenance notice."""
+        url = self._url_for("maintenance_mode")
         notice_id = None if notice is None else notice.id
-        result = self._session.patch(url, json={"notice": notice_id})
+        result: dict = self._session.patch(url, json={"notice": notice_id}).json()
         resolved = result["resolved_notice"]
         if resolved is None:
             notice = None
         else:
             notice = self._system_notices.get(resolved["id"])
         if notice is not None and resolved is not None:
-            notice.update(resolved)
+            notice.update(resolved, push_to_server=False)
         self._maintenance_notice = notice
 
     def sync_compute_hosts_if_outdated(self) -> None:
+        """Synchronize compute hosts if they are outdated."""
         timestamp = time.time()
         if (
             self.auto_sync
@@ -95,6 +149,7 @@ def sync_compute_hosts_if_outdated(self) -> None:
             self.sync_compute_hosts()
 
     def sync_system_notices_if_outdated(self) -> None:
+        """Synchronize system notices if they are outdated."""
         timestamp = time.time()
         if (
             self.auto_sync
@@ -103,7 +158,8 @@ def sync_system_notices_if_outdated(self) -> None:
             self.sync_system_notices()
 
     def sync_compute_hosts(self) -> None:
-        url = "system/compute_hosts"
+        """Synchronize compute hosts from the server."""
+        url = self._url_for("compute_hosts")
         compute_hosts = self._session.get(url).json()
         compute_host_ids = []
 
@@ -111,7 +167,9 @@ def sync_compute_hosts(self) -> None:
             compute_id = compute_host.pop("id")
             compute_host["compute_id"] = compute_id
             if compute_id in self._compute_hosts:
-                self._compute_hosts[compute_id].update(compute_host)
+                self._compute_hosts[compute_id].update(
+                    compute_host, push_to_server=False
+                )
             else:
                 self.add_compute_host_local(**compute_host)
             compute_host_ids.append(compute_id)
@@ -122,14 +180,17 @@ def sync_compute_hosts(self) -> None:
         self._last_sync_compute_host_time = time.time()
 
     def sync_system_notices(self) -> None:
-        url = "system/notices"
+        """Synchronize system notices from the server."""
+        url = self._url_for("notices")
         system_notices = self._session.get(url).json()
         system_notice_ids = []
 
         for system_notice in system_notices:
             notice_id = system_notice.get("id")
             if notice_id in self._system_notices:
-                self._system_notices[notice_id].update(system_notice)
+                self._system_notices[notice_id].update(
+                    system_notice, push_to_server=False
+                )
             else:
                 self.add_system_notice_local(**system_notice)
             system_notice_ids.append(notice_id)
@@ -138,8 +199,8 @@ def sync_system_notices(self) -> None:
             if notice_id not in system_notice_ids:
                 self._system_notices.pop(notice_id)
 
-        url = "system/maintenance_mode"
-        maintenance = self.session.get(url).json()
+        url = self._url_for("maintenance_mode")
+        maintenance = self._session.get(url).json()
         self._maintenance_mode = maintenance["maintenance_mode"]
         notice_id = maintenance["notice"]
         if notice_id is None:
@@ -151,14 +212,14 @@ def sync_system_notices(self) -> None:
     def get_external_connectors(self, sync: bool | None = None) -> list[dict[str, str]]:
         """
         Get the list of external connectors present on the controller.
-        Admin users may enable sync to refresh the cached list from host state.
-        If sync is False, the state is retrieved; if True, configuration is applied
-        back into the controller host.
-
         Device names or tags are used as External Connector nodes' configuration.
-        Returns a list of objects with the device name and label.
+
+        :param sync: Admin only. A boolean indicating whether to refresh the cached list
+            from host state. If sync is False, the state is retrieved;
+            if True, configuration is applied back into the controller host.
+        :returns: A list of objects with the device name and label.
         """
-        url = "/system/external_connectors"
+        url = self._url_for("external_connectors")
         if sync is None:
             return self._session.get(url).json()
         else:
@@ -168,61 +229,87 @@ def get_external_connectors(self, sync: bool | None = None) -> list[dict[str, st
     def update_external_connector(
         self, connector_id: str, data: dict[str, Any]
     ) -> dict[str, Any]:
-        url = f"/system/external_connectors/{connector_id}"
+        """
+        Update an external connector.
+
+        :param connector_id: The ID of the connector to update.
+        :param data: The data to update.
+        :returns: The updated data.
+        """
+        url = self._url_for("external_connector", connector_id=connector_id)
         return self._session.patch(url, json=data).json()
 
-    def delete_external_connector(self, connector_id: str):
-        url = f"/system/external_connectors/{connector_id}"
+    def delete_external_connector(self, connector_id: str) -> None:
+        """
+        Delete an external connector.
+
+        :param connector_id: The ID of the connector to delete.
+        """
+        url = self._url_for("external_connector", connector_id=connector_id)
         return self._session.delete(url)
 
     def get_web_session_timeout(self) -> int:
         """
         Get the web session timeout in seconds.
 
-        :return: web session timeout
+        :returns: The web session timeout.
         """
-        url = "/web_session_timeout"
+        url = self._url_for("web_session_timeout", timeout="")
         return self._session.get(url).json()
 
     def set_web_session_timeout(self, timeout: int) -> str:
         """
         Set the web session timeout in seconds.
 
-        :return: 'OK'
+        :param timeout: The timeout value in seconds.
+        :returns: 'OK'
         """
-
-        url = "/web_session_timeout/{}".format(timeout)
+        url = self._url_for("web_session_timeout", timeout=timeout)
         return self._session.patch(url).json()
 
     def get_mac_address_block(self) -> int:
         """
-        Get mac address block.
+        Get the MAC address block.
 
-        :return: mac address block
+        :returns: The MAC address block.
         """
-        url = "/mac_address_block"
+        url = self._url_for("mac_address_block", block="")
         return self._session.get(url).json()
 
-    def _set_mac_address_block(self, block: int) -> str:
-        url = "/mac_address_block/{}".format(block)
-        return self._session.patch(url).json()
-
     def set_mac_address_block(self, block: int) -> str:
         """
-        Set mac address block.
+        Set the MAC address block.
 
-        :return: 'OK'
+        :param block: The MAC address block.
+        :returns: 'OK'
+        :raises InvalidMacAddressBlock: If the MAC address block is not in 0-7 range.
         """
         if block < 0 or block > 7:
-            raise ValueError("MAC address block has to be in range 0-7.")
+            raise InvalidMacAddressBlock
         return self._set_mac_address_block(block=block)
 
+    def _set_mac_address_block(self, block: int) -> str:
+        """Helper method to set the MAC address block."""
+        url = self._url_for("mac_address_block", block=block)
+        return self._session.patch(url).json()
+
     def get_new_compute_host_state(self) -> str:
-        url = "/system/compute_hosts/configuration"
+        """
+        Get the admission state of the new compute host.
+
+        :returns: The admission state of the new compute host.
+        """
+        url = self._url_for("host_configuration")
         return self._session.get(url).json()["admission_state"]
 
     def set_new_compute_host_state(self, admission_state: str) -> str:
-        url = "/system/compute_hosts/configuration"
+        """
+        Set the admission state of the new compute host.
+
+        :param admission_state: The admission state to set.
+        :returns: The updated admission state.
+        """
+        url = self._url_for("host_configuration")
         return self._session.patch(
             url, json={"admission_state": admission_state}
         ).json()["admission_state"]
@@ -237,8 +324,22 @@ def add_compute_host_local(
         is_connected: bool,
         is_synced: bool,
         admission_state: str,
-        nodes: Optional[list[str]] = None,
+        nodes: list[str] | None = None,
     ) -> ComputeHost:
+        """
+        Add a compute host locally.
+
+        :param compute_id: The ID of the compute host.
+        :param hostname: The hostname of the compute host.
+        :param server_address: The server address of the compute host.
+        :param is_connector: A boolean indicating if the compute host is a connector.
+        :param is_simulator: A boolean indicating if the compute host is a simulator.
+        :param is_connected: A boolean indicating if the compute host is connected.
+        :param is_synced: A boolean indicating if the compute host is synced.
+        :param admission_state: The admission state of the compute host.
+        :param nodes: A list of node IDs associated with the compute host.
+        :returns: The added compute host.
+        """
         new_compute_host = ComputeHost(
             self,
             compute_id,
@@ -262,8 +363,22 @@ def add_system_notice_local(
         content: str,
         enabled: bool,
         acknowledged: dict[str, bool],
-        groups: Optional[list[str]] = None,
+        groups: list[str] | None = None,
     ) -> SystemNotice:
+        """
+        Add a system notice locally.
+
+        :param id: The unique identifier of the system notice.
+        :param level: The level of the system notice.
+        :param label: The label or title of the system notice.
+        :param content: The content or description of the system notice.
+        :param enabled: A flag indicating whether the system notice is enabled or not.
+        :param acknowledged: A dictionary mapping user IDs to their acknowledgment
+            status.
+        :param groups: A list of group names to associate with the system notice.
+            (Optional)
+        :returns: The newly created system notice object.
+        """
         new_system_notice = SystemNotice(
             self,
             id,
@@ -279,6 +394,8 @@ def add_system_notice_local(
 
 
 class ComputeHost:
+    _URL_TEMPLATES = {"compute_host": "system/compute_hosts/{compute_id}"}
+
     def __init__(
         self,
         system: SystemManagement,
@@ -290,10 +407,24 @@ def __init__(
         is_connected: bool,
         is_synced: bool,
         admission_state: str,
-        nodes: Optional[list[str]] = None,
+        nodes: list[str] | None = None,
     ):
+        """
+        A compute host, which hosts some of the nodes of the simulation.
+
+        :param system: The SystemManagement instance.
+        :param compute_id: The ID of the compute host.
+        :param hostname: The hostname of the compute host.
+        :param server_address: The server address of the compute host.
+        :param is_connector: Whether the compute host is a connector.
+        :param is_simulator: Whether the compute host is a simulator.
+        :param is_connected: Whether the compute host is connected.
+        :param is_synced: Whether the compute host is synced.
+        :param admission_state: The admission state of the compute host.
+        :param nodes: The list of nodes associated with the compute host.
+        """
         self._system = system
-        self._session: httpx.Client = system.session
+        self._session: httpx.Client = system._session
         self._compute_id = compute_id
         self._hostname = hostname
         self._server_address = server_address
@@ -303,63 +434,92 @@ def __init__(
         self._is_synced = is_synced
         self._admission_state = admission_state
         self._nodes = nodes if nodes is not None else []
-        self._base_url = f"{self._session.base_url}/system/compute_hosts/{compute_id}"
 
     def __str__(self):
         return f"Compute host: {self._hostname}"
 
+    def _url_for(self, endpoint, **kwargs) -> str:
+        """
+        Generate the URL for a given API endpoint.
+
+        :param endpoint: The desired endpoint.
+        :param **kwargs: Keyword arguments used to format the URL.
+        :returns: The formatted URL.
+        """
+        kwargs["compute_id"] = self._compute_id
+        return get_url_from_template(endpoint, self._URL_TEMPLATES, kwargs)
+
     @property
     def compute_id(self) -> str:
+        """Return the ID of the compute host."""
         return self._compute_id
 
     @property
     def hostname(self) -> str:
+        """Return the hostname of the compute host."""
         self._system.sync_compute_hosts_if_outdated()
         return self._hostname
 
     @property
     def server_address(self) -> str:
+        """Return the server address of the compute host."""
         self._system.sync_compute_hosts_if_outdated()
         return self._server_address
 
     @property
     def is_connector(self) -> bool:
+        """Return whether the compute host is a connector."""
         return self._is_connector
 
     @property
     def is_simulator(self) -> bool:
+        """Return whether the compute host is a simulator."""
         return self._is_simulator
 
     @property
     def is_connected(self) -> bool:
+        """Return whether the compute host is connected."""
         self._system.sync_compute_hosts_if_outdated()
         return self._is_connected
 
     @property
     def is_synced(self) -> bool:
+        """Return whether the compute host is synced."""
         self._system.sync_compute_hosts_if_outdated()
         return self._is_synced
 
     @property
     def nodes(self) -> list[str]:
+        """Return the list of nodes associated with the compute host."""
         self._system.sync_compute_hosts_if_outdated()
         return self._nodes
 
     @property
     def admission_state(self) -> str:
+        """Return the admission state of the compute host."""
         self._system.sync_compute_hosts_if_outdated()
         return self._admission_state
 
     @admission_state.setter
-    def admission_state(self, value: str):
+    def admission_state(self, value: str) -> None:
+        """Set the admission state of the compute host."""
         self._set_compute_host_property("admission_state", value)
         self._admission_state = value
 
     def remove(self) -> None:
-        _LOGGER.info("Removing compute host %s", self)
-        self._session.delete(self._base_url)
+        """Remove the compute host."""
+        _LOGGER.info(f"Removing compute host {self}")
+        url = self._url_for("compute_host")
+        self._session.delete(url)
+
+    def update(self, host_data: dict[str, Any], push_to_server: bool = True) -> None:
+        """
+        Update the compute host with the given data.
 
-    def update(self, host_data: dict[str, Any], push_to_server: bool = False):
+        :param host_data: The data to update the compute host.
+        :param push_to_server: Whether to push the changes to the server.
+            Defaults to True; should only be False when used by internal methods.
+        """
         if push_to_server:
             self._set_compute_host_properties(host_data)
             return
@@ -368,28 +528,54 @@ def update(self, host_data: dict[str, Any], push_to_server: bool = False):
             setattr(self, f"_{key}", value)
 
     def _set_compute_host_property(self, key: str, val: Any) -> None:
-        _LOGGER.debug("Setting compute host property %s %s: %s", self, key, val)
+        """
+        Set a specific property of the compute host.
+
+        :param key: The property key.
+        :param val: The new value for the property.
+        """
+        _LOGGER.debug(f"Setting compute host property {self} {key}: {val}")
         self._set_compute_host_properties({key: val})
 
     def _set_compute_host_properties(self, host_data: dict[str, Any]) -> None:
-        new_data = self._session.patch(url=self._base_url, json=host_data).json()
-        self.update(new_data)
+        """
+        Set multiple properties of the compute host.
+
+        :param host_data: The data to set as properties of the compute host.
+        """
+        url = self._url_for("compute_host")
+        new_data = self._session.patch(url, json=host_data).json()
+        self.update(new_data, push_to_server=False)
 
 
 class SystemNotice:
+    _URL_TEMPLATES = {"notice": "system/notices/{notice_id}"}
+
     def __init__(
         self,
         system: SystemManagement,
         id: str,
         level: str,
         label: str,
-        content: bool,
+        content: str,
         enabled: bool,
         acknowledged: dict[str, bool],
-        groups: Optional[list[str]] = None,
+        groups: list[str] | None = None,
     ):
+        """
+        A system notice, which notifies users of maintenance or other events.
+
+        :param system: The SystemManagement instance.
+        :param id: The ID of the system notice.
+        :param level: The level of the system notice.
+        :param label: The label of the system notice.
+        :param content: The content of the system notice.
+        :param enabled: Whether the system notice is enabled.
+        :param acknowledged: The acknowledgement status of the system notice.
+        :param groups: The groups associated with the system notice.
+        """
         self._system = system
-        self._session: httpx.Client = system.session
+        self._session: httpx.Client = system._session
         self._id = id
         self._level = level
         self._label = label
@@ -397,47 +583,73 @@ def __init__(
         self._enabled = enabled
         self._acknowledged = acknowledged
         self._groups = groups
-        self._base_url = f"{self._session.base_url}/system/notices/{id}"
+
+    def _url_for(self, endpoint, **kwargs) -> str:
+        """
+        Generate the URL for a given API endpoint.
+
+        :param endpoint: The desired endpoint.
+        :param **kwargs: Keyword arguments used to format the URL.
+        :returns: The formatted URL.
+        """
+        kwargs["notice_id"] = self._id
+        return get_url_from_template(endpoint, self._URL_TEMPLATES, kwargs)
 
     @property
     def id(self) -> str:
+        """Return the ID of the system notice."""
         return self._id
 
     @property
     def level(self) -> str:
+        """Return the level of the system notice."""
         self._system.sync_system_notices_if_outdated()
         return self._level
 
     @property
     def label(self) -> str:
+        """Return the label of the system notice."""
         self._system.sync_system_notices_if_outdated()
         return self._label
 
     @property
     def content(self) -> str:
+        """Return the content of the system notice."""
         self._system.sync_system_notices_if_outdated()
         return self._content
 
     @property
     def enabled(self) -> bool:
+        """Return whether the system notice is enabled."""
         self._system.sync_system_notices_if_outdated()
         return self._enabled
 
     @property
     def acknowledged(self) -> dict[str, bool]:
+        """Return the acknowledgement status of the system notice."""
         self._system.sync_system_notices_if_outdated()
         return self._acknowledged
 
     @property
-    def groups(self) -> Optional[list[str]]:
+    def groups(self) -> list[str] | None:
+        """Return the groups associated with the system notice."""
         self._system.sync_system_notices_if_outdated()
         return self._groups
 
     def remove(self) -> None:
-        _LOGGER.info("Removing system notice %s", self)
-        self._session.delete(self._base_url)
+        """Remove the system notice."""
+        _LOGGER.info(f"Removing system notice {self}")
+        url = self._url_for("notice")
+        self._session.delete(url)
 
-    def update(self, notice_data: dict[str, Any], push_to_server: bool = False):
+    def update(self, notice_data: dict[str, Any], push_to_server: bool = True) -> None:
+        """
+        Update the system notice with the given data.
+
+        :param notice_data: The data to update the system notice with.
+        :param push_to_server: Whether to push the changes to the server.
+            Defaults to True; should only be False when used by internal methods.
+        """
         if push_to_server:
             self._set_notice_properties(notice_data)
             return
@@ -446,9 +658,21 @@ def update(self, notice_data: dict[str, Any], push_to_server: bool = False):
             setattr(self, f"_{key}", value)
 
     def _set_notice_property(self, key: str, val: Any) -> None:
-        _LOGGER.debug("Setting system notice property %s %s: %s", self, key, val)
+        """
+        Set a specific property of the system notice.
+
+        :param key: The property key.
+        :param val: The new value for the property.
+        """
+        _LOGGER.debug(f"Setting system notice property {self} {key}: {val}")
         self._set_notice_properties({key: val})
 
     def _set_notice_properties(self, notice_data: dict[str, Any]) -> None:
-        new_data = self._session.patch(url=self._base_url, json=notice_data).json()
-        self.update(new_data)
+        """
+        Set multiple properties of the system notice.
+
+        :param notice_data: The data to set as properties of the system notice.
+        """
+        url = self._url_for("notice")
+        new_data = self._session.patch(url, json=notice_data).json()
+        self.update(new_data, push_to_server=False)
diff --git a/virl2_client/models/users.py b/virl2_client/models/users.py
index 7e591b6..e9e6ae3 100644
--- a/virl2_client/models/users.py
+++ b/virl2_client/models/users.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -20,47 +20,61 @@
 
 from __future__ import annotations
 
-from typing import TYPE_CHECKING, Any, Optional
+from typing import TYPE_CHECKING, Any
 
-from ..utils import UNCHANGED
+from ..utils import UNCHANGED, get_url_from_template
 
 if TYPE_CHECKING:
     import httpx
 
 
 class UserManagement:
+    _URL_TEMPLATES = {
+        "users": "users",
+        "user": "users/{user_id}",
+        "user_groups": "users/{user_id}/groups",
+        "user_id": "users/{username}/id",
+    }
+
     def __init__(self, session: httpx.Client) -> None:
         self._session = session
 
-    @property
-    def base_url(self) -> str:
-        return "users"
+    def _url_for(self, endpoint, **kwargs):
+        """
+        Generate the URL for a given API endpoint.
+
+        :param endpoint: The desired endpoint.
+        :param **kwargs: Keyword arguments used to format the URL.
+        :returns: The formatted URL.
+        """
+        return get_url_from_template(endpoint, self._URL_TEMPLATES, kwargs)
 
-    def users(self) -> list:
+    def users(self) -> list[str]:
         """
         Get the list of available users.
 
-        :return: list of user objects
+        :returns: List of user IDs.
         """
-        return self._session.get(self.base_url).json()
+        url = self._url_for("users")
+        return self._session.get(url).json()
 
     def get_user(self, user_id: str) -> dict:
         """
-        Gets user info.
+        Get user information.
 
-        :param user_id: user uuid4
-        :return: user object
+        :param user_id: User UUID4.
+        :returns: User object.
         """
-        url = self.base_url + "/{}".format(user_id)
+        url = self._url_for("user", user_id=user_id)
         return self._session.get(url).json()
 
     def delete_user(self, user_id: str) -> None:
         """
-        Deletes user.
+        Delete a user.
 
-        :param user_id: user uuid4
+        :param user_id: User UUID4.
         """
-        url = self.base_url + "/{}".format(user_id)
+        url = self._url_for("user", user_id=user_id)
         self._session.delete(url)
 
     def create_user(
@@ -70,20 +84,20 @@ def create_user(
         fullname: str = "",
         description: str = "",
         admin: bool = False,
-        groups: Optional[list[str]] = None,
-        resource_pool: Optional[str] = None,
+        groups: list[str] | None = None,
+        resource_pool: str | None = None,
     ) -> dict:
         """
-        Creates user.
-
-        :param username: username
-        :param pwd: desired password
-        :param fullname: full name
-        :param description: description
-        :param admin: whether to create admin user
-        :param groups: adds user to groups specified
-        :param resource_pool: adds user to resource pool specified
-        :return: user object
+        Create a new user.
+
+        :param username: Username.
+        :param pwd: Desired password.
+        :param fullname: Full name.
+        :param description: Description.
+        :param admin: Whether to create an admin user.
+        :param groups: List of groups to which the user should be added.
+        :param resource_pool: Resource pool to which the user should be added.
+        :returns: User object.
         """
         data = {
             "username": username,
@@ -94,30 +108,30 @@ def create_user(
             "groups": groups or [],
             "resource_pool": resource_pool,
         }
-        url = self.base_url
+        url = self._url_for("users")
         return self._session.post(url, json=data).json()
 
     def update_user(
         self,
         user_id: str,
-        fullname: Optional[str] = UNCHANGED,
-        description: Optional[str] = UNCHANGED,
-        groups: Optional[list[str]] = UNCHANGED,
-        admin: Optional[bool] = None,
-        password_dict: Optional[dict[str, str]] = None,
-        resource_pool: Optional[str] = UNCHANGED,
+        fullname: str | None = UNCHANGED,
+        description: str | None = UNCHANGED,
+        groups: list[str] | None = UNCHANGED,
+        admin: bool | None = None,
+        password_dict: dict[str, str] | None = None,
+        resource_pool: str | None = UNCHANGED,
     ) -> dict:
         """
-        Updates user.
-
-        :param user_id: user uuid4
-        :param fullname: full name
-        :param description: description
-        :param admin: whether to create admin user
-        :param groups: adds user to groups specified
-        :param password_dict: dict containing old and new passwords
-        :param resource_pool: adds user to resource pool specified
-        :return: user object
+        Update an existing user.
+
+        :param user_id: User UUID4.
+        :param fullname: Full name.
+        :param description: Description.
+        :param admin: Whether to create an admin user.
+        :param groups: List of groups to which the user should be added.
+        :param password_dict: Dictionary containing old and new passwords.
+        :param resource_pool: Resource pool to which the user should be added.
+        :returns: User object.
         """
         data: dict[str, Any] = {}
         if fullname is not UNCHANGED:
@@ -133,24 +147,25 @@ def update_user(
         if resource_pool is not UNCHANGED:
             data["resource_pool"] = resource_pool
 
-        url = self.base_url + "/{}".format(user_id)
+        url = self._url_for("user", user_id=user_id)
         return self._session.patch(url, json=data).json()
 
     def user_groups(self, user_id: str) -> list[str]:
         """
-        Get the groups the user is member of.
+        Get the groups that a user is a member of.
 
-        :param user_id: user uuid4
+        :param user_id: User UUID4.
+        :returns: List of group names.
         """
-        url = self.base_url + "/{}/groups".format(user_id)
+        url = self._url_for("user_groups", user_id=user_id)
         return self._session.get(url).json()
 
     def user_id(self, username: str) -> str:
         """
-        Get unique user uuid4.
+        Get the unique UUID4 of a user.
 
-        :param username: user name
-        :return: user unique identifier
+        :param username: User name.
+        :returns: User unique identifier.
         """
-        url = self.base_url + "/{}/id".format(username)
+        url = self._url_for("user_id", username=username)
         return self._session.get(url).json()
diff --git a/virl2_client/utils.py b/virl2_client/utils.py
index c614357..543b5cc 100644
--- a/virl2_client/utils.py
+++ b/virl2_client/utils.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2022, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -22,22 +22,24 @@
 
 from contextlib import nullcontext
 from functools import wraps
-from typing import TYPE_CHECKING, Callable, Optional, Type, TypeVar, Union, cast
+from typing import TYPE_CHECKING, Callable, Type, TypeVar, Union, cast
 
 import httpx
 
 from .exceptions import (
+    AnnotationNotFound,
     ElementNotFound,
     InterfaceNotFound,
     LabNotFound,
     LinkNotFound,
     NodeNotFound,
+    VirlException,
 )
 
 if TYPE_CHECKING:
-    from .models import Interface, Lab, Link, Node
+    from .models import Annotation, Interface, Lab, Link, Node
 
-    Element = Union[Lab, Node, Interface, Link]
+    Element = Union[Lab, Node, Interface, Link, Annotation]
 
 TCallable = TypeVar("TCallable", bound=Callable)
 
@@ -48,13 +50,21 @@ def __repr__(self):
 
 
 UNCHANGED = _Sentinel()
+_CONFIG_MODE = "exclude_configurations=false"
 
 
-def _make_not_found(instance: Element, owner: Type[Element]) -> ElementNotFound:
+def _make_not_found(instance: Element) -> ElementNotFound:
     """Composes and raises an ElementNotFound error for the given instance."""
-    class_name = owner.__name__
+    class_name = type(instance).__name__
+    if class_name.startswith("Annotation"):
+        class_name = "Annotation"
     instance_id = instance._id
-    instance_label = instance._title if class_name == "Lab" else instance._label
+    if class_name == "Lab":
+        instance_label = instance._title
+    elif class_name.startswith("Annotation"):
+        instance_label = instance._type
+    else:
+        instance_label = instance._label
 
     error_text = (
         f"{class_name} {instance_label} ({instance_id}) no longer exists on the server."
@@ -64,6 +74,7 @@ def _make_not_found(instance: Element, owner: Type[Element]) -> ElementNotFound:
         "Node": NodeNotFound,
         "Interface": InterfaceNotFound,
         "Link": LinkNotFound,
+        "Annotation": AnnotationNotFound,
     }[class_name]
     return error(error_text)
 
@@ -71,54 +82,48 @@ def _make_not_found(instance: Element, owner: Type[Element]) -> ElementNotFound:
 def _check_and_mark_stale(
     func: Callable,
     instance: Element,
-    owner: Optional[Type[Element]] = None,
     *args,
     **kwargs,
 ):
     """
-    Checks staleness before and after calling `func`
+    Check staleness before and after calling `func`
     and updates staleness if a 404 is raised.
 
-    :param func: the function to be called if not stale
-    :param instance: the instance of the parent class of `func`
-        which has a `_stale` attribute
-    :param owner: the class of `instance`
-    :param args: positional arguments to be passed to `func`
-    :param kwargs: keyword arguments to be passed to `func`
+    :param func: The function to be called if the instance is not stale.
+    :param instance: The instance of the parent class of `func`
+        which has a `_stale` attribute.
+    :param args: Positional arguments to be passed to `func`.
+    :param kwargs: Keyword arguments to be passed to `func`.
     """
-    if owner is None:
-        owner = type(instance)
 
     if instance._stale:
-        raise _make_not_found(instance, owner)
+        raise _make_not_found(instance)
 
     try:
         ret = func(*args, **kwargs)
         if instance._stale:
-            raise _make_not_found(instance, owner)
+            raise _make_not_found(instance)
         return ret
 
     except httpx.HTTPStatusError as exc:
         resp = exc.response
-        class_name = owner.__name__
+        class_name = type(instance).__name__
         instance_id = instance._id
         if (
             resp.status_code == 404
             and f"{class_name} not found: {instance_id}" in resp.text
         ):
             instance._stale = True
-            raise _make_not_found(instance, owner) from exc
+            raise _make_not_found(instance) from exc
         raise
 
 
 def check_stale(func: TCallable) -> TCallable:
-    """
-    A decorator that will make the wrapped function check staleness.
-    """
+    """A decorator that will make the wrapped function check staleness."""
 
     @wraps(func)
     def wrapper_stale(*args, **kwargs):
-        return _check_and_mark_stale(func, args[0], None, *args, **kwargs)
+        return _check_and_mark_stale(func, args[0], *args, **kwargs)
 
     return cast(TCallable, wrapper_stale)
 
@@ -126,8 +131,13 @@ def wrapper_stale(*args, **kwargs):
 class property_s(property):
     """A modified `property` that will check staleness."""
 
+    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
+        super().__init__(fget=fget, fset=fset, fdel=fdel)
+        if doc:
+            self.__doc__ = doc
+
     def __get__(self, instance, owner):
-        return _check_and_mark_stale(super().__get__, instance, owner, instance, owner)
+        return _check_and_mark_stale(super().__get__, instance, instance, owner)
 
 
 def locked(func: TCallable) -> TCallable:
@@ -148,3 +158,23 @@ def wrapper_locked(*args, **kwargs):
             return func(*args, **kwargs)
 
     return cast(TCallable, wrapper_locked)
+
+
+def get_url_from_template(
+    endpoint: str, url_templates: dict[str, str], values: dict | None = None
+) -> str:
+    """
+    Generate the URL for a given API endpoint from given templates.
+
+    :param endpoint: The desired endpoint.
+    :param url_templates: The templates to map values to.
+    :param values: Keyword arguments used to format the URL.
+    :returns: The formatted URL.
+    """
+    endpoint_url_template = url_templates.get(endpoint)
+    if endpoint_url_template is None:
+        raise VirlException(f"Invalid endpoint: {endpoint}")
+    if values is None:
+        values = {}
+    values["CONFIG_MODE"] = _CONFIG_MODE
+    return endpoint_url_template.format(**values)
diff --git a/virl2_client/virl2_client.py b/virl2_client/virl2_client.py
index 93ff379..353f3be 100644
--- a/virl2_client/virl2_client.py
+++ b/virl2_client/virl2_client.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -28,7 +28,7 @@
 from functools import lru_cache
 from pathlib import Path
 from threading import RLock
-from typing import Any, NamedTuple, Optional
+from typing import Any, NamedTuple
 from urllib.parse import urljoin, urlsplit, urlunsplit
 
 import httpx
@@ -47,7 +47,7 @@
 )
 from .models.authentication import make_session
 from .models.configuration import get_configuration
-from .utils import locked
+from .utils import get_url_from_template, locked
 
 _LOGGER = logging.getLogger(__name__)
 cached = lru_cache(maxsize=None)  # cache results forever
@@ -72,7 +72,7 @@ def parse_version_str(version_str: str) -> str:
         return res[0]
 
     def __repr__(self):
-        return "{}".format(self.version_str)
+        return self.version_str
 
     def __eq__(self, other):
         return (
@@ -132,9 +132,9 @@ class ClientConfig(NamedTuple):
     """Stores client library configuration, which can be used to create
     any number of identically configured instances of ClientLibrary."""
 
-    url: Optional[str] = None
-    username: Optional[str] = None
-    password: Optional[str] = None
+    url: str | None = None
+    username: str | None = None
+    password: str | None = None
     ssl_verify: bool | str = True
     allow_http: bool = False
     auto_sync: float = 1.0
@@ -164,7 +164,7 @@ class ClientLibrary:
     """Python bindings for the REST API of a CML controller."""
 
     # current client version
-    VERSION = Version("2.6.1")
+    VERSION = Version("2.7.0")
     # list of Version objects
     INCOMPATIBLE_CONTROLLER_VERSIONS = [
         Version("2.0.0"),
@@ -176,12 +176,27 @@ class ClientLibrary:
         Version("2.2.2"),
         Version("2.2.3"),
     ]
+    _URL_TEMPLATES = {
+        "auth_test": "authok",
+        "system_info": "system_information",
+        "import": "import",
+        "import_1x": "import/virl-1x",
+        "sample_labs": "sample/labs",
+        "sample_lab": "sample/labs/{lab_title}",
+        "labs": "labs",
+        "lab": "labs/{lab_id}",
+        "lab_topology": "labs/{lab_id}/topology",
+        "diagnostics": "diagnostics",
+        "system_health": "system_health",
+        "system_stats": "system_stats",
+        "populate_lab_tiles": "populate_lab_tiles",
+    }
 
     def __init__(
         self,
-        url: Optional[str] = None,
-        username: Optional[str] = None,
-        password: Optional[str] = None,
+        url: str | None = None,
+        username: str | None = None,
+        password: str | None = None,
         ssl_verify: bool | str = True,
         raise_for_auth_failure: bool = False,
         allow_http: bool = False,
@@ -190,7 +205,7 @@ def __init__(
         events: bool = False,
     ) -> None:
         """
-        Initializes a ClientLibrary instance. Note that ssl_verify can
+        Initialize a ClientLibrary instance. Note that ssl_verify can
         also be a string that points to a cert (see class documentation).
 
         :param url: URL of controller. It's also possible to pass the
@@ -200,20 +215,21 @@ def __init__(
             to pass the username via ``VIRL2_USER`` or ``VIRL_USERNAME`` variable.
         :param password: Password of the user to authenticate. It's also possible
             to pass the password via ``VIRL2_PASS`` or ``VIRL_PASSWORD`` variable.
-        :param ssl_verify: Path SSL controller certificate, or True to load
+        :param ssl_verify: Path of the SSL controller certificate, or True to load
             from ``CA_BUNDLE`` or ``CML_VERIFY_CERT`` environment variable,
             or False to disable.
-        :param raise_for_auth_failure: Raise an exception if unable
-            to connect to controller (use for scripting scenarios).
-        :param allow_http: If set, a https URL will not be enforced
-        :param convergence_wait_max_iter: maximum number of iterations to wait for
-            lab to converge.
-        :param convergence_wait_time: wait interval in seconds to wait during one
-            iteration during lab convergence wait.
-        :param events: whether to use events - event listeners for state updates and
-            event handlers for custom handling.
-        :raises InitializationError: If no URL is provided,
-            authentication fails or host can't be reached.
+        :param raise_for_auth_failure: Raise an exception if unable to connect to
+            controller. (Use for scripting scenarios.)
+        :param allow_http: If set, a https URL will not be enforced.
+        :param convergence_wait_max_iter: Maximum number of iterations for convergence.
+        :param convergence_wait_time: Time in seconds to sleep between convergence calls
+            on the backend.
+        :param events: A flag indicating whether to enable event-based data
+            synchronization from the server. When enabled, utilizes a mechanism for
+            receiving real-time updates from the server, instead of periodically
+            requesting the data.
+        :raises InitializationError: If no URL is provided, authentication fails or host
+            can't be reached.
         """
         url, username, password, cert = get_configuration(
             url, username, password, ssl_verify
@@ -221,7 +237,7 @@ def __init__(
         if cert is not None:
             ssl_verify = cert
 
-        url, base_url = prepare_url(url, allow_http)
+        url, base_url = _prepare_url(url, allow_http)
         self.username: str = username
         self.password: str = password
 
@@ -234,7 +250,7 @@ def __init__(
         except httpx.InvalidURL as exc:
             raise InitializationError(exc) from None
         # checks version from system_info against self.VERSION
-        self.check_controller_version()
+        controller_version = self.check_controller_version()
 
         self._session.auth = TokenAuth(self)
         # Note: session.auth is defined in the httpx module to be of type Auth,
@@ -257,7 +273,9 @@ def __init__(
         self.raise_for_auth_failure = raise_for_auth_failure
         self._labs: dict[str, Lab] = {}
 
-        self.licensing = Licensing(self._session)
+        self.licensing = Licensing(
+            self._session, is_cert_deprecated=controller_version >= Version("2.7.0")
+        )
         self.user_management = UserManagement(self._session)
         self.group_management = GroupManagement(self._session)
         self.system_management = SystemManagement(
@@ -286,7 +304,7 @@ def __init__(
                 return
 
         self.event_listener = None
-        self.session.lock = None
+        self._session.lock = None
         if events:
             # http-based auto sync should be off by default when using events
             self.auto_sync = False
@@ -304,11 +322,25 @@ def __repr__(self):
         )
 
     def __str__(self):
-        return "{} URL: {}".format(self.__class__.__name__, self._session.base_url)
+        return f"{self.__class__.__name__} URL: {self._session.base_url}"
+
+    def _url_for(self, endpoint, **kwargs):
+        """
+        Generate the URL for a given API endpoint.
+
+        :param endpoint: The desired endpoint.
+        :param **kwargs: Keyword arguments used to format the URL.
+        :returns: The formatted URL.
+        """
+        return get_url_from_template(endpoint, self._URL_TEMPLATES, kwargs)
 
     def _make_test_auth_call(self) -> None:
-        """Make a call to confirm auth works by using the "authok" endpoint."""
-        url = "authok"
+        """
+        Make a call to confirm that authentication works.
+
+        :raises InitializationError: If authentication fails.
+        """
+        url = self._url_for("auth_test")
         try:
             self._session.get(url)
         except httpx.HTTPStatusError as exc:
@@ -320,115 +352,106 @@ def _make_test_auth_call(self) -> None:
                 raise InitializationError(message)
             raise
         except httpx.HTTPError as exc:
-            # TODO: subclass InitializationError
             raise InitializationError(exc)
 
     @staticmethod
     def _environ_get(
-        key: str, value: Optional[Any] = None, default: Optional[Any] = None
-    ) -> Optional[Any]:
-        """If the value is not set yet, fetch it from environment or return default."""
+        key: str, value: Any | None = None, default: Any | None = None
+    ) -> Any | None:
+        """
+        If the value is not yet set, fetch it from the environment or return the default
+        value.
+
+        :param key: The key to fetch the value for.
+        :param value: The value to use if it is already set.
+        :param default: The default value to use if the key is not set
+            and value is None.
+        :returns: The fetched value or the default value.
+        """
         if value is None:
             value = os.environ.get(key)
             if value:
-                _LOGGER.info("Using value %s from environment", key)
+                _LOGGER.info(f"Using value {key} from environment")
             else:
                 value = default
         return value
 
-    @property
-    def session(self) -> httpx.Client:
-        """
-        Returns the used `httpx` client session object.
-
-        :returns: The session object
-        """
-        return self._session
-
     @property
     def uuid(self) -> str:
-        """
-        Returns the UUID4 that identifies this client to the server.
-
-        :returns: the UUID4 string
-        """
+        """Return the UUID4 that identifies this client to the server."""
         return self._session.headers["X-Client-UUID"]
 
-    def logout(self, clear_all_sessions: bool = False) -> None:
+    def logout(self, clear_all_sessions: bool = False) -> bool:
         """
-        Invalidate current token.
+        Invalidate the current token.
+
+        :param clear_all_sessions: Whether to clear all user sessions as well.
         """
-        return self._session.auth.logout(  # type: ignore
-            clear_all_sessions=clear_all_sessions
-        )
+        return self._session.auth.logout(clear_all_sessions=clear_all_sessions)
 
     def get_host(self) -> str:
         """
-        Returns the hostname of the session to the controller.
+        Return the hostname of the session to the controller.
 
-        :returns: A hostname
+        :returns: The hostname.
         """
-        return self.session.base_url.host
+        return self._session.base_url.host
 
     def system_info(self) -> dict:
         """
         Get information about the system where the application runs.
         Can be called without authentication.
 
-        :returns: system information json
+        :returns: The system information as a JSON object.
         """
-        url = "system_information"
+        url = self._url_for("system_info")
         return self._session.get(url).json()
 
-    def check_controller_version(self) -> None:
+    def check_controller_version(self) -> Version | None:
         """
-        Checks remote controller version against current client version
-        (specified in self.VERSION) and against controller version
-        blacklist (specified in self.INCOMPATIBLE_CONTROLLER_VERSIONS) and
-        raises exception if versions are incompatible or prints warning
-        if client minor version is lower than controller minor version.
+        Check remote controller version against current client version
+        (specified in `self.VERSION`) and against controller version
+        blacklist (specified in `self.INCOMPATIBLE_CONTROLLER_VERSIONS`).
+        Raise exception if versions are incompatible, or print warning
+        if the client minor version is lower than the controller minor version.
         """
-        controller_version = self.system_info().get("version", "").split("-")[0]
-
-        # are we running against a test version?
-        if controller_version == "testing":
-            _LOGGER.warning("testing version detected!")
-            return
+        controller_version = self.system_info().get("version")
+        try:
+            controller_version_obj = Version(controller_version)
+        except (TypeError, ValueError):
+            _LOGGER.warning(f"Invalid version detected: {controller_version}!")
+            return None
 
-        controller_version_obj = Version(controller_version)
         if controller_version_obj in self.INCOMPATIBLE_CONTROLLER_VERSIONS:
             raise InitializationError(
-                "Controller version {} is marked incompatible! "
-                "List of versions marked explicitly as incompatible: {}.".format(
-                    controller_version_obj, self.INCOMPATIBLE_CONTROLLER_VERSIONS
-                )
+                f"Controller version {controller_version_obj} is marked incompatible! "
+                f"List of versions marked explicitly as incompatible: "
+                f"{self.INCOMPATIBLE_CONTROLLER_VERSIONS}."
             )
         if self.VERSION.major_differs(controller_version_obj):
             raise InitializationError(
-                "Major version mismatch. Client {}, controller {}.".format(
-                    self.VERSION, controller_version_obj
-                )
+                f"Major version mismatch. Client {self.VERSION}, "
+                f"controller {controller_version_obj}."
             )
         if self.VERSION.minor_differs(controller_version_obj) and self.VERSION.minor_lt(
             controller_version_obj
         ):
             _LOGGER.warning(
-                "Please ensure the client version is compatible with the controller"
-                " version. Client %s, controller %s.",
-                self.VERSION,
-                controller_version_obj,
+                f"Please ensure the client version is compatible with the controller "
+                f"version. Client {self.VERSION}, controller {controller_version_obj}."
             )
+        return controller_version_obj
 
     def is_system_ready(
         self, wait: bool = False, max_wait: int = 60, sleep: int = 5
     ) -> bool:
         """
-        Reports whether the system is ready or not.
+        Report whether the system is ready or not.
 
-        :param wait: if this is true, the call will block until it's ready
-        :param max_wait: maximum time to wait, in seconds
-        :param sleep: time to wait between tries, in seconds
-        :returns: ready state
+        :param wait: Whether to block until the system is ready.
+        :param max_wait: The maximum time to wait in seconds.
+        :param sleep: The time to wait between tries in seconds.
+        :returns: The ready state of the system.
         """
         loops = 1
         if wait:
@@ -453,6 +476,12 @@ def is_system_ready(
 
     @staticmethod
     def is_virl_1x(path: Path) -> bool:
+        """
+        Check if the given file is of VIRL version 1.x.
+
+        :param path: The path to check.
+        :returns: Whether the file is of VIRL version 1.x.
+        """
         if path.suffix == ".virl":
             return True
         return False
@@ -460,13 +489,17 @@ def is_virl_1x(path: Path) -> bool:
     @locked
     def start_event_listening(self):
         """
-        Starts listening for and parsing websocket events.
+        Start listening for and parsing websocket events.
 
         To replace the default event handling mechanism,
         subclass `event_handling.EventHandler` (or `EventHandlerBase` if necessary),
-        then do:
-            self.websockets = event_listening.EventListener(self)
-            self.websockets._event_handler = handler
+        then do::
+        from .event_listening import EventListener
+        custom_listener = EventListener()
+        custom_listener._event_handler = CustomHandler(client_library)
+        client_library.event_listener = custom_listener
+
+        :returns:
         """
         from .event_listening import EventListener
 
@@ -478,44 +511,49 @@ def start_event_listening(self):
 
     @locked
     def stop_event_listening(self):
+        """Stop listening for and parsing websocket events."""
         if self.event_listener:
-            self.session.lock = None
+            self._session.lock = None
             self.event_listener.stop_listening()
 
     @locked
     def import_lab(
         self,
         topology: str,
-        title: Optional[str] = None,
+        title: str | None = None,
         offline: bool = False,
         virl_1x: bool = False,
     ) -> Lab:
         """
-        Imports an existing topology from a string.
-
-        :param topology: Topology representation as a string
-        :param title: Title of the lab (optional)
-        :param offline: whether the ClientLibrary should import the
-            lab locally.  The topology parameter has to be a JSON string
-            in this case.  This can not be XML or YAML representation of
-            the lab.  Compatible JSON from `GET /labs/{lab_id}/topology`.
-        :param virl_1x: Is this old virl-1x topology format (default=False)
-        :returns: A Lab instance
-        :raises ValueError: if there's no lab ID in the API response
-        :raises httpx.HTTPError: if there was a transport error
-        """
-        # TODO: refactor to return the local lab, and sync it,
-        #  if already exists in self._labs
+        Import an existing topology from a string.
+
+        :param topology: The topology representation as a string.
+        :param title: The title of the lab.
+        :param offline: Whether to import the lab locally.
+        :param virl_1x: Whether the topology format is the old, VIRL 1.x format.
+        :returns: The imported Lab instance.
+        :raises ValueError: If no lab ID is returned in the API response.
+        :raises httpx.HTTPError: If there was a transport error.
+        """
+        if title is not None:
+            for lab_id in self._labs:
+                if (lab := self._labs[lab_id]).title == title:
+                    # Lab of this title already exists, sync and return it
+                    lab.sync()
+                    return lab
         if offline:
             lab_id = "offline_lab"
         else:
             if virl_1x:
-                url = "import/virl-1x"
+                url = self._url_for("import_1x")
             else:
-                url = "import"
+                url = self._url_for("import")
             if title is not None:
-                url = "{}?title={}".format(url, title)
-            result = self._session.post(url, content=topology).json()
+                result = self._session.post(
+                    url, params={"title": title}, content=topology
+                ).json()
+            else:
+                result = self._session.post(url, content=topology).json()
             lab_id = result.get("id")
             if lab_id is None:
                 raise ValueError("No lab ID returned!")
@@ -543,17 +581,18 @@ def import_lab(
         return lab
 
     @locked
-    def import_lab_from_path(self, path: str, title: Optional[str] = None) -> Lab:
+    def import_lab_from_path(self, path: str, title: str | None = None) -> Lab:
         """
-        Imports an existing topology from a file / path.
+        Import an existing topology from a file or path.
 
-        :param path: Topology filename / path
-        :param title: Title of the lab
-        :returns: A Lab instance
+        :param path: The topology filename or path.
+        :param title: The title of the lab.
+        :returns: The imported Lab instance.
+        :raises FileNotFoundError: If the specified path does not exist.
         """
         topology_path = Path(path)
         if not topology_path.exists():
-            message = "{} can not be found".format(path)
+            message = f"{path} can not be found"
             raise FileNotFoundError(message)
 
         topology = topology_path.read_text()
@@ -563,38 +602,40 @@ def import_lab_from_path(self, path: str, title: Optional[str] = None) -> Lab:
 
     def get_sample_labs(self) -> dict[str, dict]:
         """
-        Returns a dictionary with information about all sample labs available on host.
+        Return a dictionary with information about all sample labs
+        available on the host.
 
-        :returns: A dictionary of sample lab information, where keys are titles
+        :returns: A dictionary of sample lab information where the keys are the titles.
         """
-        url = "sample/labs"
+        url = self._url_for("sample_labs")
         return self._session.get(url).json()
 
     @locked
     def import_sample_lab(self, title: str) -> Lab:
         """
-        Imports a built-in sample lab.
+        Import a built-in sample lab.
 
-        :param title: sample lab name, as returned by get_sample_labs
-        :returns: a Lab instance
+        :param title: The sample lab name.
+        :returns: The imported Lab instance.
         """
 
-        url = "sample/labs/" + title
+        url = self._url_for("sample_lab", lab_title=title)
         lab_id = self._session.put(url).json()
         return self.join_existing_lab(lab_id)
 
     @locked
     def all_labs(self, show_all: bool = False) -> list[Lab]:
         """
-        Joins all labs owned by this user (or all labs period) and returns their list.
+        Join all labs owned by this user (or all labs) and return their list.
 
-        :param show_all: Whether to get only labs owned by the admin or all user labs
-        :returns: A list of lab objects
+        :param show_all: Whether to get only labs owned by the admin or all user labs.
+        :returns: A list of Lab objects.
         """
-        url = "labs"
+        url = {"url": self._url_for("labs")}
         if show_all:
-            url += "?show_all=true"
-        lab_ids = self._session.get(url).json()
+            url["params"] = {"show_all": True}
+        lab_ids = self._session.get(**url).json()
+
         result = []
         for lab_id in lab_ids:
             lab = self.join_existing_lab(lab_id)
@@ -604,17 +645,30 @@ def all_labs(self, show_all: bool = False) -> list[Lab]:
 
     @locked
     def _remove_stale_labs(self):
+        """Remove stale labs from the client library."""
         for lab in list(self._labs.values()):
             if lab._stale:
                 self._remove_lab_local(lab)
 
     @locked
     def local_labs(self) -> list[Lab]:
+        """
+        Return a list of local labs.
+
+        :returns: A list of local labs.
+        """
         self._remove_stale_labs()
         return list(self._labs.values())
 
     @locked
     def get_local_lab(self, lab_id: str) -> Lab:
+        """
+        Get a local lab by its ID.
+
+        :param lab_id: The ID of the lab.
+        :returns: The Lab object with the specified ID.
+        :raises LabNotFound: If the lab with the given ID does not exist.
+        """
         self._remove_stale_labs()
         try:
             return self._labs[lab_id]
@@ -622,14 +676,21 @@ def get_local_lab(self, lab_id: str) -> Lab:
             raise LabNotFound(lab_id)
 
     @locked
-    def create_lab(self, title: Optional[str] = None) -> Lab:
+    def create_lab(
+        self,
+        title: str | None = None,
+        description: str | None = None,
+        notes: str | None = None,
+    ) -> Lab:
         """
-        Creates an empty lab with the given name. If no name is given, then
-        the created lab ID is set as the name.
+        Create a new lab with optional title, description, and notes.
 
-        Note that the lab will be auto-syncing according to the Client Library's
-        auto-sync setting when created. The lab has a property to override this
-        on a per-lab basis.
+        If no title, description, or notes are provided, the server will generate a
+        default title in the format "Lab at Mon 13:30 PM" and leave the description
+        and notes blank.
+
+        The lab will automatically sync based on the Client Library's auto-sync setting
+        when created, but this behavior can be overridden on a per-lab basis.
 
         Example::
 
@@ -637,16 +698,19 @@ def create_lab(self, title: Optional[str] = None) -> Lab:
             print(lab.id)
             lab.create_node("r1", "iosv", 50, 100)
 
-        :param title: The Lab name (or title)
-        :returns: A Lab instance
+        :param title: The title of the lab.
+        :param description: The description of the lab.
+        :param notes: The notes of the lab.
+        :returns: A Lab instance representing the created lab.
         """
-        url = "labs"
-        params = {"title": title}
-        result = self._session.post(url, params=params).json()
-        # TODO: server generated title not loaded
+        url = self._url_for("labs")
+        body = {"title": title, "description": description, "notes": notes}
+        # exclude values left at None
+        body = {k: v for k, v in body.items() if v is not None}
+        result = self._session.post(url, json=body).json()
         lab_id = result["id"]
         lab = Lab(
-            title or lab_id,
+            result["lab_title"],
             lab_id,
             self._session,
             self.username,
@@ -657,21 +721,22 @@ def create_lab(self, title: Optional[str] = None) -> Lab:
             wait_time=self.convergence_wait_time,
             resource_pool_manager=self.resource_pool_management,
         )
+        # This is just to skip a deprecation warning in _import_lab
+        result["_created"] = True
+        lab._import_lab(result)
         self._labs[lab_id] = lab
         return lab
 
     @locked
     def remove_lab(self, lab_id: str | Lab) -> None:
         """
-        Use carefully, it removes a given lab::
-
-            client_library.remove_lab("1f6cd7")
+        Remove a lab identified by its ID or Lab object.
 
-        If you have a lab object, you can also simply do:
+        Use this method with caution as it permanently deletes the specified lab.
 
-            lab.remove()
+        If you have the lab object, you can also do ``lab.remove()``.
 
-        :param lab_id: the lab ID or object
+        :param lab_id: The ID or Lab object representing the lab to be removed.
         """
         self._remove_stale_labs()
         if isinstance(lab_id, Lab):
@@ -696,27 +761,26 @@ def _remove_lab_local(self, lab: Lab) -> None:
             pass
 
     def _remove_unjoined_lab(self, lab_id: str):
-        url = "labs/{}".format(lab_id)
+        """Helper function to remove an unjoined lab from the server."""
+        url = self._url_for("lab", lab_id=lab_id)
         response = self._session.delete(url)
-        _LOGGER.debug("removed lab: %s", response.text)
+        _LOGGER.debug(f"Removed lab: {response.text}")
 
     @locked
     def join_existing_lab(self, lab_id: str, sync_lab: bool = True) -> Lab:
         """
-        Creates a new ClientLibrary lab instance that is retrieved
-        from the controller.
-
-        If `sync_lab` is set to true, then synchronize current lab
-        by applying the changes that were done in UI or in another
-        ClientLibrary session.
+        Join a lab that exists on the server and make it accessible locally.
 
-        Join preexisting lab::
+        If `sync_lab` is set to True, the current lab will be synchronized by applying
+        any changes that were made in the UI or in another ClientLibrary session.
 
+        Example::
             lab = client_library.join_existing_lab("2e6a18")
 
-        :param lab_id: The lab ID to be joined.
-        :param sync_lab: Synchronize changes.
-        :returns: A Lab instance
+        :param lab_id: The ID of the lab to be joined.
+        :param sync_lab: Whether to synchronize the lab.
+        :returns: A Lab instance representing the joined lab.
+        :raises LabNotFound: If no lab with the given ID exists on the host.
         """
         self._remove_stale_labs()
         if lab_id in self._labs:
@@ -725,7 +789,8 @@ def join_existing_lab(self, lab_id: str, sync_lab: bool = True) -> Lab:
         if sync_lab:
             try:
                 # check if lab exists through REST call
-                topology = self.session.get(f"labs/{lab_id}/topology").json()
+                url = self._url_for("lab_topology", lab_id=lab_id)
+                topology = self._session.get(url).json()
             except httpx.HTTPStatusError as exc:
                 if exc.response.status_code == 404:
                     raise LabNotFound("No lab with the given ID exists on the host.")
@@ -753,29 +818,29 @@ def join_existing_lab(self, lab_id: str, sync_lab: bool = True) -> Lab:
 
     def get_diagnostics(self) -> dict:
         """
-        Returns the controller diagnostic data as JSON
+        Return the controller diagnostic data as a JSON object.
 
-        :returns: diagnostic data
+        :returns: The diagnostic data.
         """
-        url = "diagnostics"
+        url = self._url_for("diagnostics")
         return self._session.get(url).json()
 
     def get_system_health(self) -> dict:
         """
-        Returns the controller system health data as JSON
+        Return the controller system health data as a JSON object.
 
-        :returns: system health data
+        :returns: The system health data.
         """
-        url = "system_health"
+        url = self._url_for("system_health")
         return self._session.get(url).json()
 
     def get_system_stats(self) -> dict:
         """
-        Returns the controller resource statistics as JSON
+        Return the controller resource statistics as a JSON object.
 
-        :returns: system resource statistics
+        :returns: The system resource statistics.
         """
-        url = "system_stats"
+        url = self._url_for("system_stats")
         return self._session.get(url).json()
 
     @locked
@@ -783,10 +848,10 @@ def find_labs_by_title(self, title: str) -> list[Lab]:
         """
         Return a list of labs which match the given title.
 
-        :param title: The title to search for
-        :returns: A list of lab objects which match the title specified
+        :param title: The title to search for.
+        :returns: A list of Lab objects matching the specified title.
         """
-        url = "populate_lab_tiles"
+        url = self._url_for("populate_lab_tiles")
         resp = self._session.get(url).json()
 
         # populate_lab_tiles response has been changed in 2.1
@@ -810,18 +875,19 @@ def find_labs_by_title(self, title: str) -> list[Lab]:
 
     def get_lab_list(self, show_all: bool = False) -> list[str]:
         """
-        Returns list of all lab IDs.
+        Get the list of all lab IDs.
 
-        :param show_all: Whether to get only labs owned by the admin or all user labs
-        :returns: a list of Lab IDs
+        :param show_all: Whether to include labs owned by all users (True) or only labs
+            owned by the admin (False).
+        :returns: A list of lab IDs.
         """
-        url = "labs"
+        url = {"url": self._url_for("labs")}
         if show_all:
-            url += "?show_all=true"
-        return self._session.get(url).json()
+            url["params"] = {"show_all": True}
+        return self._session.get(**url).json()
 
 
-def prepare_url(url: str, allow_http: bool) -> tuple[str, str]:
+def _prepare_url(url: str, allow_http: bool) -> tuple[str, str]:
     # prepare the URL
     try:
         url_parts = urlsplit(url, "https")


Test patch:
diff --git a/conftest.py b/conftest.py
index e616983..b8b34b3 100644
--- a/conftest.py
+++ b/conftest.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 #
@@ -21,8 +21,7 @@
 
 from __future__ import annotations
 
-import os
-from typing import Iterator
+from pathlib import Path
 
 import pytest
 
@@ -47,8 +46,6 @@ def pytest_addoption(
         parser.addini("asyncio_mode", "suppress the warning")
 
 
-@pytest.fixture(scope="function")
-def change_test_dir(request: pytest.FixtureRequest) -> Iterator[None]:
-    os.chdir(request.path.parent)
-    yield
-    os.chdir(request.config.invocation_params.dir)
+@pytest.fixture
+def test_dir(request: pytest.FixtureRequest) -> Path:
+    return request.path.parent
diff --git a/tests/conftest.py b/tests/conftest.py
index 48d4f3c..af03672 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -84,7 +84,8 @@ def resp_body_from_file(request: httpx.Request) -> httpx.Response:
     elif endpoint_parts[0] == "labs":
         lab_id = endpoint_parts[1]
         filename = "_".join(endpoint_parts[2:]) + "-" + lab_id + ".json"
-    file_path = Path("test_data", filename)
+    test_dir = Path(__file__).parent.resolve()
+    file_path = test_dir / "test_data" / filename
     return httpx.Response(200, text=file_path.read_text())
 
 
@@ -117,9 +118,23 @@ def respx_mock_with_labs(respx_mock):
         respx_mock.get(
             FAKE_HOST_API
             + f"labs/444a78d1-575c-4746-8469-696e580f17b6/nodes/{node}?operational=true"
+            f"&exclude_configurations=true"
         ).respond(
             json={"operational": {"compute_id": "99c887f5-052e-4864-a583-49fa7c4b68a9"}}
         )
+    respx_mock.get(
+        FAKE_HOST_API
+        + "labs/444a78d1-575c-4746-8469-696e580f17b6/nodes?data=true&operational=true&"
+        "exclude_configurations=true"
+    ).respond(
+        json=[
+            {
+                "id": node,
+                "operational": {"compute_id": "99c887f5-052e-4864-a583-49fa7c4b68a9"},
+            }
+            for node in nodes
+        ]
+    )
     resp_from_files = (
         "labs",
         "populate_lab_tiles",
@@ -141,6 +156,6 @@ def respx_mock_with_labs(respx_mock):
 
 
 @pytest.fixture
-def client_library(respx_mock_with_labs, change_test_dir):
+def client_library(respx_mock_with_labs):
     client = ClientLibrary(url=FAKE_HOST, username="test", password="pa$$")
     yield client
diff --git a/tests/requirements.txt b/tests/requirements.txt
index fe4b788..24a8520 100644
--- a/tests/requirements.txt
+++ b/tests/requirements.txt
@@ -1,28 +1,30 @@
-anyio==3.6.2; python_full_version >= "3.6.2" and python_version >= "3.7"
-certifi==2023.7.22; python_version >= "3.7"
-cfgv==3.3.1; python_full_version >= "3.6.1" and python_version >= "3.8"
-colorama==0.4.6; python_version >= "3.7" and python_full_version < "3.0.0" and sys_platform == "win32" or sys_platform == "win32" and python_version >= "3.7" and python_full_version >= "3.7.0"
-distlib==0.3.6; python_version >= "3.8"
-exceptiongroup==1.1.1; python_version < "3.11" and python_version >= "3.7"
-filelock==3.11.0; python_version >= "3.8"
-flake8==6.0.0; python_full_version >= "3.8.1"
-h11==0.14.0; python_version >= "3.7"
-httpcore==0.17.0; python_version >= "3.7"
-httpx==0.24.0; python_version >= "3.7"
-identify==2.5.22; python_version >= "3.8"
-idna==3.4; python_full_version >= "3.6.2" and python_version >= "3.7"
-iniconfig==2.0.0; python_version >= "3.7"
-mccabe==0.7.0; python_version >= "3.6" and python_full_version >= "3.8.1"
-nodeenv==1.7.0; python_version >= "3.8" and python_full_version < "3.0.0" or python_full_version >= "3.7.0" and python_version >= "3.8"
-packaging==23.1; python_version >= "3.7"
-platformdirs==3.2.0; python_version >= "3.8"
-pluggy==1.0.0; python_version >= "3.7"
-pre-commit==3.2.2; python_version >= "3.8"
-pycodestyle==2.10.0; python_version >= "3.6" and python_full_version >= "3.8.1"
-pyflakes==3.0.1; python_version >= "3.6" and python_full_version >= "3.8.1"
-pytest==7.3.1; python_version >= "3.7"
-pyyaml==6.0; python_version >= "3.8"
-respx==0.20.1; python_version >= "3.7"
-sniffio==1.3.0; python_full_version >= "3.6.2" and python_version >= "3.7"
-tomli==2.0.1; python_version < "3.11" and python_version >= "3.7"
-virtualenv==20.21.0; python_version >= "3.8"
+anyio==4.3.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+certifi==2024.2.2 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+cfgv==3.4.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+colorama==0.4.6 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0" and sys_platform == "win32"
+distlib==0.3.8 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+exceptiongroup==1.2.0 ; python_full_version >= "3.8.1" and python_version < "3.11"
+filelock==3.13.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+flake8==7.0.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+h11==0.14.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+httpcore==1.0.4 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+httpx==0.26.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+identify==2.5.35 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+idna==3.6 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+iniconfig==2.0.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+mccabe==0.7.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+nodeenv==1.8.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+packaging==23.2 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+platformdirs==4.2.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+pluggy==1.4.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+pre-commit==3.5.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+pycodestyle==2.11.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+pyflakes==3.2.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+pytest==7.4.4 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+pyyaml==6.0.1 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+respx==0.20.2 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+setuptools==69.1.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+sniffio==1.3.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
+tomli==2.0.1 ; python_full_version >= "3.8.1" and python_version < "3.11"
+typing-extensions==4.9.0 ; python_full_version >= "3.8.1" and python_version < "3.11"
+virtualenv==20.25.0 ; python_full_version >= "3.8.1" and python_full_version < "4.0.0"
diff --git a/tests/test_client_library.py b/tests/test_client_library.py
index 307e6b4..a194f9f 100644
--- a/tests/test_client_library.py
+++ b/tests/test_client_library.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -20,7 +20,6 @@
 
 import json
 import logging
-import os
 import re
 import sys
 from pathlib import Path
@@ -44,7 +43,21 @@
     sys.version_info < (3, 7), reason="requires Python3.7"
 )
 
-# TODO: split into multiple test modules, by feature
+
+# TODO: split into multiple test modules, by feature.
+@pytest.fixture
+def reset_env(monkeypatch):
+    env_vars = [
+        "VIRL2_URL",
+        "VIRL_HOST",
+        "VIRL2_USER",
+        "VIRL_USERNAME",
+        "VIRL2_PASS",
+        "VIRL_PASSWORD",
+    ]
+
+    for key in env_vars:
+        monkeypatch.delenv(key, raising=False)
 
 
 @python37_or_newer
@@ -61,13 +74,13 @@ def test_import_lab_from_path_virl(
         lab = cl.import_lab_from_path(path=(tmp_path / "topology.virl").as_posix())
 
     assert lab.title is not None
-    assert lab.lab_base_url.startswith("labs/")
+    assert lab._url_for("lab").startswith("labs/")
 
-    cl.session.post.assert_called_once_with(
+    cl._session.post.assert_called_once_with(
         "import/virl-1x",
         content="<?xml version='1.0' encoding='UTF-8'?>",
     )
-    cl.session.post.assert_called_once()
+    cl._session.post.assert_called_once()
     sync_mock.assert_called_once_with()
 
 
@@ -86,10 +99,11 @@ def test_import_lab_from_path_virl_title(
             path=(tmp_path / "topology.virl").as_posix(), title=new_title
         )
     assert lab.title is not None
-    assert lab.lab_base_url.startswith("labs/")
+    assert lab._url_for("lab").startswith("labs/")
 
-    cl.session.post.assert_called_once_with(
-        f"import/virl-1x?title={new_title}",
+    cl._session.post.assert_called_once_with(
+        "import/virl-1x",
+        params={"title": new_title},
         content="<?xml version='1.0' encoding='UTF-8'?>",
     )
 
@@ -104,24 +118,20 @@ def test_ssl_certificate(client_library_server_current, mocked_session):
     cl.is_system_ready(wait=True)
 
     assert cl._ssl_verify == "/home/user/cert.pem"
-    assert cl.session.mock_calls[:4] == [
-        call.get("authok"),
-    ]
+    assert cl._session.mock_calls[:4] == [call.get("authok")]
 
 
 def test_ssl_certificate_from_env_variable(
     client_library_server_current, monkeypatch, mocked_session
 ):
-    monkeypatch.setitem(os.environ, "CA_BUNDLE", "/home/user/cert.pem")
+    monkeypatch.setenv("CA_BUNDLE", "/home/user/cert.pem")
     cl = ClientLibrary(
         url="https://0.0.0.0/fake_url/", username="test", password="pa$$"
     )
 
     assert cl.is_system_ready()
     assert cl._ssl_verify == "/home/user/cert.pem"
-    assert cl.session.mock_calls[:4] == [
-        call.get("authok"),
-    ]
+    assert cl._session.mock_calls[:4] == [call.get("authok")]
 
 
 @python37_or_newer
@@ -186,10 +196,10 @@ def initial_different_response(initial, subsequent=httpx.Response(200)):
 
 def test_client_library_init_allow_http(client_library_server_current):
     cl = ClientLibrary("http://somehost", "virl2", "virl2", allow_http=True)
-    assert cl.session.base_url.scheme == "http"
-    assert cl.session.base_url.host == "somehost"
-    assert cl.session.base_url.port is None
-    assert cl.session.base_url.path.endswith("/api/v0/")
+    assert cl._session.base_url.scheme == "http"
+    assert cl._session.base_url.host == "somehost"
+    assert cl._session.base_url.port is None
+    assert cl._session.base_url.path.endswith("/api/v0/")
     assert cl.username == "virl2"
     assert cl.password == "virl2"
 
@@ -201,7 +211,7 @@ def test_client_library_init_disallow_http(client_library_server_current):
         ClientLibrary("http://somehost", "virl2", "virl2", allow_http=False)
 
 
-# the test fails if you have variables set in .virlci or env
+# the test fails if you have variables set in env
 @pytest.mark.parametrize("via", ["environment", "parameter"])
 @pytest.mark.parametrize("env_var", ["VIRL2_URL", "VIRL_HOST"])
 @pytest.mark.parametrize(
@@ -217,7 +227,7 @@ def test_client_library_init_disallow_http(client_library_server_current):
     ],
 )
 def test_client_library_init_url(
-    client_library_server_current, monkeypatch, via, env_var, params
+    client_library_server_current, reset_env, monkeypatch, via, env_var, params
 ):
     monkeypatch.setattr("getpass.getpass", input)
     (fail, url) = params
@@ -240,27 +250,27 @@ def test_client_library_init_url(
                 allow_http=True,
                 raise_for_auth_failure=True,
             )
-            if isinstance(err, OSError):
-                pattern = "(reading from stdin)"
-                assert re.match(pattern, str(err.value))
+        if isinstance(err, OSError):
+            pattern = "(reading from stdin)"
+            assert re.match(pattern, str(err.value))
     else:
         cl = ClientLibrary(url, username="virl2", password="virl2", allow_http=True)
-        url_parts = cl.session.base_url
+        url_parts = cl._session.base_url
         assert url_parts.scheme == (expected_parts.scheme or "https")
         assert url_parts.host == (expected_parts.host or expected_parts.path)
         assert url_parts.port == expected_parts.port
         assert url_parts.path == "/api/v0/"
-        assert cl.session.base_url.path.endswith("/api/v0/")
+        assert cl._session.base_url.path.endswith("/api/v0/")
         assert cl.username == "virl2"
         assert cl.password == "virl2"
 
 
-# the test fails if you have variables set in .virlci or env
+# the test fails if you have variables set in env
 @pytest.mark.parametrize("via", ["environment", "parameter"])
 @pytest.mark.parametrize("env_var", ["VIRL2_USER", "VIRL_USERNAME"])
 @pytest.mark.parametrize("params", [(False, "johndoe"), (True, ""), (True, None)])
 def test_client_library_init_user(
-    client_library_server_current, monkeypatch, via, env_var, params
+    client_library_server_current, reset_env, monkeypatch, via, env_var, params
 ):
     monkeypatch.setattr("getpass.getpass", input)
     url = "validhostname"
@@ -278,22 +288,22 @@ def test_client_library_init_user(
     if fail:
         with pytest.raises((OSError, InitializationError)) as err:
             ClientLibrary(url=url, username=user, password="virl2")
-            if isinstance(err, OSError):
-                pattern = "(reading from stdin)"
-                assert re.match(pattern, str(err.value))
+        if isinstance(err, OSError):
+            pattern = "(reading from stdin)"
+            assert re.match(pattern, str(err.value))
     else:
         cl = ClientLibrary(url, username=user, password="virl2")
         assert cl.username == params[1]
         assert cl.password == "virl2"
-        assert cl.session.base_url == "https://validhostname/api/v0/"
+        assert cl._session.base_url == "https://validhostname/api/v0/"
 
 
-# the test fails if you have variables set in .virlci or env
+# the test fails if you have variables set in env
 @pytest.mark.parametrize("via", ["environment", "parameter"])
 @pytest.mark.parametrize("env_var", ["VIRL2_PASS", "VIRL_PASSWORD"])
 @pytest.mark.parametrize("params", [(False, "validPa$$w!2"), (True, ""), (True, None)])
 def test_client_library_init_password(
-    client_library_server_current, monkeypatch, via, env_var, params
+    client_library_server_current, reset_env, monkeypatch, via, env_var, params
 ):
     monkeypatch.setattr("getpass.getpass", input)
     url = "validhostname"
@@ -311,14 +321,14 @@ def test_client_library_init_password(
     if fail:
         with pytest.raises((OSError, InitializationError)) as err:
             ClientLibrary(url=url, username="virl2", password=password)
-            if isinstance(err, OSError):
-                pattern = "(reading from stdin)"
-                assert re.match(pattern, str(err.value))
+        if isinstance(err, OSError):
+            pattern = "(reading from stdin)"
+            assert re.match(pattern, str(err.value))
     else:
         cl = ClientLibrary(url, username="virl2", password=password)
         assert cl.username == "virl2"
         assert cl.password == params[1]
-        assert cl.session.base_url == "https://validhostname/api/v0/"
+        assert cl._session.base_url == "https://validhostname/api/v0/"
 
 
 @pytest.mark.parametrize(
@@ -334,14 +344,14 @@ def test_client_library_init_password(
 )
 def test_client_library_config(client_library_server_current, mocked_session, config):
     client_library = config.make_client()
-    assert client_library.session.base_url.path.startswith(config.url)
+    assert client_library._session.base_url.path.startswith(config.url)
     assert client_library.username == config.username
     assert client_library.password == config.password
     assert client_library.allow_http == config.allow_http
     assert client_library._ssl_verify == config.ssl_verify
     assert client_library.auto_sync == (config.auto_sync >= 0.0)
     assert client_library.auto_sync_interval == config.auto_sync
-    assert client_library.session.mock_calls == [
+    assert client_library._session.mock_calls == [
         call.get("authok"),
         call.base_url.path.startswith(config.url),
         call.base_url.path.startswith().__bool__(),
@@ -360,9 +370,10 @@ def test_client_library_str_and_repr(client_library_server_current):
 def test_major_version_mismatch(client_library_server_1_0_0):
     with pytest.raises(InitializationError) as err:
         ClientLibrary("somehost", "virl2", password="virl2")
-    assert str(
-        err.value
-    ) == "Major version mismatch. Client {}, controller 1.0.0.".format(CURRENT_VERSION)
+    assert (
+        str(err.value)
+        == f"Major version mismatch. Client {CURRENT_VERSION}, controller 1.0.0."
+    )
 
 
 def test_incompatible_version(client_library_server_2_0_0):
@@ -382,8 +393,8 @@ def test_client_minor_version_gt_nowarn(client_library_server_current, caplog):
         client_library = ClientLibrary("somehost", "virl2", password="virl2")
     assert client_library is not None
     assert (
-        "Please ensure the client version is compatible with the controller version. "
-        "Client {}, controller 2.0.0.".format(CURRENT_VERSION) not in caplog.text
+        f"Please ensure the client version is compatible with the controller version. "
+        f"Client {CURRENT_VERSION}, controller 2.0.0." not in caplog.text
     )
 
 
@@ -392,8 +403,8 @@ def test_client_minor_version_lt_warn(client_library_server_2_9_0, caplog):
         client_library = ClientLibrary("somehost", "virl2", password="virl2")
     assert client_library is not None
     assert (
-        "Please ensure the client version is compatible with the controller version. "
-        "Client {}, controller 2.9.0.".format(CURRENT_VERSION) in caplog.text
+        f"Please ensure the client version is compatible with the controller version. "
+        f"Client {CURRENT_VERSION}, controller 2.9.0." in caplog.text
     )
 
 
@@ -402,8 +413,8 @@ def test_client_minor_version_lt_warn_1(client_library_server_2_19_0, caplog):
         client_library = ClientLibrary("somehost", "virl2", password="virl2")
     assert client_library is not None
     assert (
-        "Please ensure the client version is compatible with the controller version. "
-        "Client {}, controller 2.19.0.".format(CURRENT_VERSION) in caplog.text
+        f"Please ensure the client version is compatible with the controller version. "
+        f"Client {CURRENT_VERSION}, controller 2.19.0." in caplog.text
     )
 
 
@@ -412,8 +423,8 @@ def test_exact_version_no_warn(client_library_server_current, caplog):
         client_library = ClientLibrary("somehost", "virl2", password="virl2")
     assert client_library is not None
     assert (
-        "Please ensure the client version is compatible with the controller version. "
-        "Client {}, controller 2.0.0.".format(CURRENT_VERSION) not in caplog.text
+        f"Please ensure the client version is compatible with the controller version. "
+        f"Client {CURRENT_VERSION}, controller 2.0.0." not in caplog.text
     )
 
 
@@ -704,12 +715,12 @@ def test_different_version_strings():
 
 
 def test_import_lab_offline(
-    change_test_dir, client_library_server_current, mocked_session, tmp_path: Path
+    client_library_server_current, mocked_session, tmp_path: Path, test_dir
 ):
     client_library = ClientLibrary(
         url="https://0.0.0.0/fake_url/", username="test", password="pa$$"
     )
-    topology_file_path = "test_data/sample_topology.json"
+    topology_file_path = test_dir / "test_data/sample_topology.json"
     with open(topology_file_path) as fh:
         topology_file = fh.read()
         client_library.import_lab(topology_file, "topology-v0_0_4", offline=True)
diff --git a/tests/test_client_library_labs.py b/tests/test_client_library_labs.py
index 5ed4e05..47b99a1 100644
--- a/tests/test_client_library_labs.py
+++ b/tests/test_client_library_labs.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -52,18 +52,13 @@ def test_topology_creation_and_removal():
     lnk1 = lab._create_link_local(i1, i2, "0")
     lnk2 = lab._create_link_local(i3, i4, "1")
 
-    assert sorted([node_b, node_c, node_a]) == [node_a, node_b, node_c]
-    assert sorted([i4, i2, i3, i1]) == [i1, i2, i3, i4]
-    assert sorted([lnk2, lnk1]) == [lnk1, lnk2]
-
     assert set(lab.nodes()) == {node_a, node_b, node_c}
-    assert lab.statistics == {"nodes": 3, "links": 2, "interfaces": 4}
+    assert lab.statistics == {"annotations": 0, "nodes": 3, "links": 2, "interfaces": 4}
     assert node_a.degree() == 1
     assert node_b.degree() == 2
     assert node_c.degree() == 1
 
     assert node_a.links() == [lnk1]
-    assert sorted(node_b.links()) == [lnk1, lnk2]
     assert node_c.links() == [lnk2]
 
     assert i1.connected is True
@@ -87,22 +82,22 @@ def test_topology_creation_and_removal():
     assert lnk2.interfaces == (i3, i4)
 
     lab.remove_link(lnk2)
-    assert lab.statistics == {"nodes": 3, "links": 1, "interfaces": 4}
+    assert lab.statistics == {"annotations": 0, "nodes": 3, "links": 1, "interfaces": 4}
 
     lab.remove_node(node_b)
-    assert lab.statistics == {"nodes": 2, "links": 0, "interfaces": 2}
+    assert lab.statistics == {"annotations": 0, "nodes": 2, "links": 0, "interfaces": 2}
 
     lab.remove_interface(i4)
-    assert lab.statistics == {"nodes": 2, "links": 0, "interfaces": 1}
+    assert lab.statistics == {"annotations": 0, "nodes": 2, "links": 0, "interfaces": 1}
 
     lab.remove_interface(i1)
-    assert lab.statistics == {"nodes": 2, "links": 0, "interfaces": 0}
+    assert lab.statistics == {"annotations": 0, "nodes": 2, "links": 0, "interfaces": 0}
 
     lab.remove_node(node_a)
-    assert lab.statistics == {"nodes": 1, "links": 0, "interfaces": 0}
+    assert lab.statistics == {"annotations": 0, "nodes": 1, "links": 0, "interfaces": 0}
 
     lab.remove_node(node_c)
-    assert lab.statistics == {"nodes": 0, "links": 0, "interfaces": 0}
+    assert lab.statistics == {"annotations": 0, "nodes": 0, "links": 0, "interfaces": 0}
 
 
 def test_need_to_wait1():
@@ -301,7 +296,7 @@ def test_find_by_label():
     node = lab.get_node_by_label("server-a")
     assert node.id == "n0"
 
-    with pytest.raises(NodeNotFound) as exc:
+    with pytest.raises(NodeNotFound):
         node = lab.get_node_by_label("does-not-exist")
         assert node is None
 
@@ -338,7 +333,12 @@ def test_next_free_interface():
 def test_join_existing_lab(client_library):
     lab = client_library.join_existing_lab("444a78d1-575c-4746-8469-696e580f17b6")
     assert lab.title == "IOSv Feature Tests"
-    assert lab.statistics == {"nodes": 7, "links": 8, "interfaces": 24}
+    assert lab.statistics == {
+        "annotations": 0,
+        "nodes": 7,
+        "links": 8,
+        "interfaces": 24,
+    }
 
 
 def test_all_labs(client_library):
diff --git a/tests/test_configuration.py b/tests/test_configuration.py
index e0045d8..17874a2 100644
--- a/tests/test_configuration.py
+++ b/tests/test_configuration.py
@@ -1,161 +1,77 @@
+#
+# This file is part of VIRL 2
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
+# All rights reserved.
+#
 import os
-from pathlib import Path
 
 import pytest
 
 from virl2_client import ClientLibrary
 from virl2_client.models.configuration import _CONFIG_FILE_NAME
 
-
-@pytest.fixture
-def cwd_virlrc():
-    return Path(_CONFIG_FILE_NAME)
+_TEST_ENV = {
+    "VIRL2_URL": "0.0.0.0",
+    "VIRL_HOST": "0.0.0.0",
+    "VIRL2_USER": "test_admin",
+    "VIRL_USERNAME": "test_admin",
+    "VIRL2_PASS": "test_test123",
+    "VIRL_PASSWORD": "test_test123",
+}
 
 
 @pytest.fixture
-def home_virlrc():
-    return Path.home() / _CONFIG_FILE_NAME
+def cwd_virlrc(tmp_path):
+    path = tmp_path / _CONFIG_FILE_NAME
+    with path.open("w") as f:
+        for name, value in _TEST_ENV.items():
+            f.write(f"{name}={value}\n")
 
+    os.chdir(path.parent)
 
-@pytest.fixture
-def files_to_store(cwd_virlrc, home_virlrc):
-    return [cwd_virlrc, home_virlrc]
+    yield path
 
+    os.remove(path)
 
-@pytest.fixture(autouse=True)
-def store_original_file(files_to_store):
-    # store file if exists
-    holder = {}
-    for file_path in files_to_store:
-        if file_path.is_file():
-            with file_path.open("r") as f:
-                holder[file_path] = f.read()
-            file_path.unlink()
 
-    yield holder
+@pytest.fixture
+def home_virlrc(tmp_path):
+    path = tmp_path / _CONFIG_FILE_NAME
+    with path.open("w") as f:
+        for name, value in _TEST_ENV.items():
+            f.write(f"{name}={value}\n")
 
-    # teardown
-    for file_path in files_to_store:
-        if file_path.is_file():
-            file_path.unlink()
+    HOME = "HOME"
+    home = os.environ.get(HOME)
+    os.environ[HOME] = str(path.parent)
 
-    # restore original home file
-    for file_path, content in holder.items():
-        with file_path.open("w") as f:
-            f.write(content)
+    yield path
 
+    os.environ[HOME] = home
+    os.remove(path)
 
-@pytest.fixture
-def tested_credentials(files_to_store):
-    configuration = {
-        "VIRL2_URL": "0.0.0.0",
-        "VIRL_HOST": "0.0.0.0",
-        "VIRL2_USER": "test_admin",
-        "VIRL_USERNAME": "test_admin",
-        "VIRL2_PASS": "test_test123",
-        "VIRL_PASSWORD": "test_test123",
-    }
-    virlrc_test = Path(".virlrc_test")
-    if not virlrc_test.is_file():
-        return configuration
-
-    with virlrc_test.open("r") as f:
-        for line in f.readlines():
-            try:
-                name, value = line.split("=", 1)
-                if name in configuration:
-                    configuration[name] = value
-            except ValueError:
-                continue
-
-    return configuration
-
-
-@pytest.fixture(autouse=True)
-def store_original_exports(tested_credentials):
-    store = {}
-
-    # export test credentials
-    for name in tested_credentials:
-        if name in os.environ:
-            store[name] = os.environ[name]
-            del os.environ[name]
-
-    yield store
-
-    # teardown
-    for name in tested_credentials:
-        if name in os.environ:
-            del os.environ[name]
-
-    # restore exports
-    for name in store:
-        os.environ[name] = store[name]
-
-
-def test_local_virlrc(
-    tested_credentials, client_library_server_current, mocked_session, cwd_virlrc
-):
-    # load test data into file
-    with cwd_virlrc.open("w") as f:
-        for name, value in tested_credentials.items():
-            f.write(f"{name}={value}\n")
 
-    assert cwd_virlrc.is_file()
+def test_local_virlrc(client_library_server_current, cwd_virlrc):
     cl = ClientLibrary(ssl_verify=False)
-
     assert cl.is_system_ready()
     assert all([cl.url, cl.username, cl.password])
 
 
-def test_export_credentials(
-    tested_credentials, client_library_server_current, mocked_session, cwd_virlrc
-):
-    for name, value in tested_credentials.items():
-        assert os.environ.get(name, None) is None
-        os.environ[name] = value
-        assert os.environ.get(name, None) is not None
+def test_export_credentials(client_library_server_current, monkeypatch):
+    for name, value in _TEST_ENV.items():
+        monkeypatch.setenv(name, value)
 
-    assert not cwd_virlrc.is_file()
     cl = ClientLibrary(ssl_verify=False)
-
     assert cl.is_system_ready()
     assert all([cl.url, cl.username, cl.password])
 
 
-def test_home_directory_virlrc(
-    tested_credentials,
-    client_library_server_current,
-    mocked_session,
-    home_virlrc,
-    cwd_virlrc,
-):
-    assert not home_virlrc.is_file()
-
-    with home_virlrc.open("w") as f:
-        for name, value in tested_credentials.items():
-            f.write(f"{name}={value}\n")
-
-            assert os.environ.get(name, None) is None
-
-    assert not cwd_virlrc.is_file()
+def test_home_directory_virlrc(client_library_server_current, home_virlrc):
     cl = ClientLibrary(ssl_verify=False)
-
     assert cl.is_system_ready()
     assert all([cl.url, cl.username, cl.password])
 
 
-def test_read_from_stdin(
-    tested_credentials,
-    client_library_server_current,
-    mocked_session,
-    home_virlrc,
-    cwd_virlrc,
-):
-    assert not home_virlrc.is_file()
-    assert not cwd_virlrc.is_file()
-    for name in tested_credentials:
-        assert name not in os.environ
-
+def test_read_from_stdin(client_library_server_current):
     with pytest.raises(OSError, match="reading from stdin"):
         _ = ClientLibrary(ssl_verify=False)
diff --git a/tests/test_definitions.py b/tests/test_definitions.py
index 6167132..62df6f9 100644
--- a/tests/test_definitions.py
+++ b/tests/test_definitions.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
diff --git a/tests/test_image_upload.py b/tests/test_image_upload.py
index 7453bb6..5bf2c26 100644
--- a/tests/test_image_upload.py
+++ b/tests/test_image_upload.py
@@ -1,6 +1,6 @@
 #
 # This file is part of VIRL 2
-# Copyright (c) 2019-2023, Cisco Systems, Inc.
+# Copyright (c) 2019-2024, Cisco Systems, Inc.
 # All rights reserved.
 #
 # Python bindings for the Cisco VIRL 2 Network Simulation Platform
@@ -59,6 +59,8 @@
     "file.qcow.qcow",
     "qcow2.qcow2.qcow2",
     ".file.qcow",
+    "file.iol",
+    "qcow.iol",
 ]
 
 
@@ -98,34 +100,19 @@ def windows_path(path: str):
         pytest.param("\\"),
         pytest.param("..\\..\\"),
         pytest.param("\\test\\"),
-        pytest.param("test_data/"),
-    ],
-)
-@pytest.mark.parametrize(
-    "rename",
-    [
-        pytest.param(None),
-        pytest.param("renamed.qcow"),
-        pytest.param("renamed.qcow2"),
-        pytest.param("renamed"),
     ],
 )
+@pytest.mark.parametrize("usage", [pytest.param("name"), pytest.param("rename")])
 @pytest.mark.parametrize(
     "test_string, message",
     [pytest.param(test_str, "wrong format") for test_str in WRONG_FORMAT_LIST]
     + [pytest.param(test_str, "not supported") for test_str in NOT_SUPPORTED_LIST]
     + [pytest.param(test_str, "") for test_str in EXPECTED_PASS_LIST],
 )
-def test_image_upload_file(
-    change_test_dir,
-    test_path: str,
-    rename: str,
-    test_string: str,
-    message: str,
-):
+def test_image_upload_file(usage: str, test_string: str, message: str, test_path: str):
     session = MagicMock()
-    session.post = MagicMock()
     nid = NodeImageDefinitions(session)
+    rename = None
     filename = test_path + test_string
 
     if message in ("wrong format", "not supported"):