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

Issue:
SIMPLE-5289 pcl small ldap improvements fixes



Discussion / hints:

Fix patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 2e5253a..a2e6f31 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -4,18 +4,18 @@
 
 repos:
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.3.0
+    rev: v4.4.0
     hooks:
       - id: trailing-whitespace
       - id: end-of-file-fixer
       - id: check-added-large-files
-  - repo: https://github.com/pre-commit/mirrors-isort
-    rev: v5.10.1
+  - repo: https://github.com/PyCQA/isort
+    rev: 5.12.0
     hooks:
       - id: isort
         args: [--profile, black]
   - repo: https://github.com/psf/black
-    rev: 22.10.0
+    rev: 23.3.0
     hooks:
       - id: black
         language_version: python3
diff --git a/docs/source/examples.rst b/docs/source/examples.rst
index ceb01db..3b0b954 100644
--- a/docs/source/examples.rst
+++ b/docs/source/examples.rst
@@ -11,13 +11,13 @@ A custom SSL certificate bundle can be passed in `ssl_verify`::
 
     client = ClientLibrary("https://192.168.1.1", "username", "password", ssl_verify="./cert.pem")
 
-You can pass a certificate using the ``CA_BUNDLE`` environment variable as well.
+You can pass a certificate using the ``CA_BUNDLE`` or ``CML_VERIFY_CERT`` environment variables as well.
 
 If no username or password are given then the environment will be checked,
-looking for ``VIRL2_USER`` and ``VIRL2_PASS``, respectively. Environment
-variables take precedence over those provided in arguments.
+looking for ``VIRL2_USER`` or ``VIRL_USERNAME`` and ``VIRL2_PASS`` or ``VIRL_PASSWORD``, respectively.
+Environment variables take precedence over those provided in arguments.
 
-It's also possible to pass the URL as an environment variable ``VIRL2_URL``.
+It's also possible to pass the URL as an environment variable ``VIRL2_URL`` or ``VIRL_HOST``.
 
 Disabling SSL certificate verification (not recommended)::
 
diff --git a/examples/sample.py b/examples/sample.py
index 1d2ab7c..e910f6e 100644
--- a/examples/sample.py
+++ b/examples/sample.py
@@ -86,7 +86,7 @@
 lab.sync_states()
 for node in lab.nodes():
     print(node, node.state)
-    for iface in node.interfaces:
+    for iface in node.interfaces():
         print(iface, iface.state)
 
 assert [link for link in lab.links() if link.state is not None] == []
diff --git a/poetry.lock b/poetry.lock
index 74dceff..81ac4e1 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,6 +1,6 @@
 [[package]]
 name = "aiofiles"
-version = "22.1.0"
+version = "23.1.0"
 description = "File support for asyncio."
 category = "main"
 optional = true
@@ -8,7 +8,7 @@ python-versions = ">=3.7,<4.0"
 
 [[package]]
 name = "aiohttp"
-version = "3.8.3"
+version = "3.8.4"
 description = "Async http client/server framework (asyncio)"
 category = "main"
 optional = true
@@ -18,7 +18,7 @@ python-versions = ">=3.6"
 aiosignal = ">=1.1.2"
 async-timeout = ">=4.0.0a3,<5.0"
 attrs = ">=17.3.0"
-charset-normalizer = ">=2.0,<3.0"
+charset-normalizer = ">=2.0,<4.0"
 frozenlist = ">=1.1.1"
 multidict = ">=4.5,<7.0"
 yarl = ">=1.0,<2.0"
@@ -55,11 +55,11 @@ frozenlist = ">=1.1.0"
 
 [[package]]
 name = "alabaster"
-version = "0.7.12"
+version = "0.7.13"
 description = "A configurable sidebar-enabled Sphinx theme"
 category = "main"
 optional = true
-python-versions = "*"
+python-versions = ">=3.6"
 
 [[package]]
 name = "anyio"
@@ -80,11 +80,14 @@ trio = ["trio (>=0.16,<0.22)"]
 
 [[package]]
 name = "async-lru"
-version = "1.0.3"
-description = "Simple lru_cache for asyncio"
+version = "2.0.2"
+description = "Simple LRU cache for asyncio"
 category = "main"
 optional = true
-python-versions = ">=3.6"
+python-versions = ">=3.8"
+
+[package.dependencies]
+typing-extensions = ">=4.0.0"
 
 [[package]]
 name = "async-timeout"
@@ -96,32 +99,33 @@ python-versions = ">=3.6"
 
 [[package]]
 name = "attrs"
-version = "22.1.0"
+version = "23.1.0"
 description = "Classes Without Boilerplate"
 category = "main"
-optional = false
-python-versions = ">=3.5"
+optional = true
+python-versions = ">=3.7"
 
 [package.extras]
-dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
-docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
-tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
-tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "cloudpickle"]
+cov = ["attrs", "coverage[toml] (>=5.3)"]
+dev = ["attrs", "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)"]
 
 [[package]]
 name = "babel"
-version = "2.11.0"
+version = "2.12.1"
 description = "Internationalization utilities"
 category = "main"
 optional = true
-python-versions = ">=3.6"
+python-versions = ">=3.7"
 
 [package.dependencies]
-pytz = ">=2015.7"
+pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""}
 
 [[package]]
 name = "certifi"
-version = "2022.9.24"
+version = "2022.12.7"
 description = "Python package for providing Mozilla's CA Bundle."
 category = "main"
 optional = false
@@ -156,14 +160,11 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
 [[package]]
 name = "charset-normalizer"
-version = "2.1.1"
+version = "3.1.0"
 description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
 category = "main"
 optional = true
-python-versions = ">=3.6.0"
-
-[package.extras]
-unicode_backport = ["unicodedata2"]
+python-versions = ">=3.7.0"
 
 [[package]]
 name = "colorama"
@@ -175,7 +176,7 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7
 
 [[package]]
 name = "cryptography"
-version = "38.0.4"
+version = "40.0.2"
 description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
 category = "main"
 optional = true
@@ -185,12 +186,14 @@ python-versions = ">=3.6"
 cffi = ">=1.12"
 
 [package.extras]
-docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"]
+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)"]
-pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"]
+pep8test = ["black", "ruff", "mypy", "check-manifest"]
 sdist = ["setuptools-rust (>=0.11.4)"]
 ssh = ["bcrypt (>=3.1.5)"]
-test = ["pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"]
+test = ["pytest (>=6.2.0)", "pytest-shard (>=0.1.2)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601"]
+test-randomorder = ["pytest-randomly"]
+tox = ["tox"]
 
 [[package]]
 name = "dill"
@@ -221,7 +224,7 @@ python-versions = ">=3.6"
 
 [[package]]
 name = "docutils"
-version = "0.17.1"
+version = "0.18.1"
 description = "Docutils -- Python Documentation Utilities"
 category = "main"
 optional = true
@@ -229,7 +232,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
 [[package]]
 name = "exceptiongroup"
-version = "1.0.4"
+version = "1.1.1"
 description = "Backport of PEP 654 (exception groups)"
 category = "dev"
 optional = false
@@ -240,28 +243,28 @@ test = ["pytest (>=6)"]
 
 [[package]]
 name = "filelock"
-version = "3.8.0"
+version = "3.11.0"
 description = "A platform independent file lock."
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 
 [package.extras]
-docs = ["furo (>=2022.6.21)", "sphinx (>=5.1.1)", "sphinx-autodoc-typehints (>=1.19.1)"]
-testing = ["covdefaults (>=2.2)", "coverage (>=6.4.2)", "pytest (>=7.1.2)", "pytest-cov (>=3)", "pytest-timeout (>=2.1)"]
+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)"]
 
 [[package]]
 name = "flake8"
-version = "5.0.4"
+version = "6.0.0"
 description = "the modular source code checker: pep8 pyflakes and co"
 category = "dev"
 optional = false
-python-versions = ">=3.6.1"
+python-versions = ">=3.8.1"
 
 [package.dependencies]
 mccabe = ">=0.7.0,<0.8.0"
-pycodestyle = ">=2.9.0,<2.10.0"
-pyflakes = ">=2.5.0,<2.6.0"
+pycodestyle = ">=2.10.0,<2.11.0"
+pyflakes = ">=3.0.0,<3.1.0"
 
 [[package]]
 name = "frozenlist"
@@ -284,11 +287,11 @@ smmap = ">=3.0.1,<6"
 
 [[package]]
 name = "gitpython"
-version = "3.1.18"
-description = "Python Git Library"
+version = "3.1.31"
+description = "GitPython is a Python library used to interact with Git repositories"
 category = "main"
 optional = true
-python-versions = ">=3.6"
+python-versions = ">=3.7"
 
 [package.dependencies]
 gitdb = ">=4.0.1,<5"
@@ -303,7 +306,7 @@ python-versions = ">=3.7"
 
 [[package]]
 name = "httpcore"
-version = "0.16.2"
+version = "0.17.0"
 description = "A minimal low-level HTTP client."
 category = "main"
 optional = false
@@ -321,7 +324,7 @@ socks = ["socksio (>=1.0.0,<2.0.0)"]
 
 [[package]]
 name = "httpx"
-version = "0.23.1"
+version = "0.24.0"
 description = "The next generation HTTP client."
 category = "main"
 optional = false
@@ -329,19 +332,19 @@ python-versions = ">=3.7"
 
 [package.dependencies]
 certifi = "*"
-httpcore = ">=0.15.0,<0.17.0"
-rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]}
+httpcore = ">=0.15.0,<0.18.0"
+idna = "*"
 sniffio = "*"
 
 [package.extras]
 brotli = ["brotli", "brotlicffi"]
-cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<13)"]
+cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<14)"]
 http2 = ["h2 (>=3,<5)"]
 socks = ["socksio (>=1.0.0,<2.0.0)"]
 
 [[package]]
 name = "identify"
-version = "2.5.9"
+version = "2.5.22"
 description = "File identification library for Python"
 category = "dev"
 optional = false
@@ -368,7 +371,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
 [[package]]
 name = "importlib-metadata"
-version = "5.1.0"
+version = "6.4.1"
 description = "Read metadata from Python packages"
 category = "main"
 optional = true
@@ -378,17 +381,17 @@ python-versions = ">=3.7"
 zipp = ">=0.5"
 
 [package.extras]
-docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "jaraco.tidelift (>=1.4)"]
+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)"]
 perf = ["ipython"]
-testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "pytest-flake8", "importlib-resources (>=1.3)"]
 
 [[package]]
 name = "iniconfig"
-version = "1.1.1"
-description = "iniconfig: brain-dead simple config-ini parsing"
+version = "2.0.0"
+description = "brain-dead simple config-ini parsing"
 category = "dev"
 optional = false
-python-versions = "*"
+python-versions = ">=3.7"
 
 [[package]]
 name = "jinja2"
@@ -417,7 +420,7 @@ six = "*"
 
 [[package]]
 name = "markupsafe"
-version = "2.1.1"
+version = "2.1.2"
 description = "Safely add untrusted strings to HTML/XML markup."
 category = "main"
 optional = true
@@ -433,7 +436,7 @@ python-versions = ">=3.6"
 
 [[package]]
 name = "multidict"
-version = "6.0.2"
+version = "6.0.4"
 description = "multidict implementation"
 category = "main"
 optional = true
@@ -449,18 +452,15 @@ python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*
 
 [[package]]
 name = "packaging"
-version = "21.3"
+version = "23.1"
 description = "Core utilities for Python packages"
 category = "main"
 optional = false
-python-versions = ">=3.6"
-
-[package.dependencies]
-pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
+python-versions = ">=3.7"
 
 [[package]]
 name = "pathspec"
-version = "0.10.2"
+version = "0.11.1"
 description = "Utility library for gitignore style pattern matching of file paths."
 category = "main"
 optional = true
@@ -468,15 +468,15 @@ python-versions = ">=3.7"
 
 [[package]]
 name = "platformdirs"
-version = "2.5.4"
+version = "3.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"
 
 [package.extras]
-docs = ["furo (>=2022.9.29)", "proselint (>=0.13)", "sphinx-autodoc-typehints (>=1.19.4)", "sphinx (>=5.3)"]
-test = ["appdirs (==1.4.4)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest (>=7.2)"]
+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)"]
 
 [[package]]
 name = "pluggy"
@@ -492,23 +492,22 @@ testing = ["pytest", "pytest-benchmark"]
 
 [[package]]
 name = "pre-commit"
-version = "2.20.0"
+version = "3.2.2"
 description = "A framework for managing and maintaining multi-language pre-commit hooks."
 category = "dev"
 optional = false
-python-versions = ">=3.7"
+python-versions = ">=3.8"
 
 [package.dependencies]
 cfgv = ">=2.0.0"
 identify = ">=1.0.0"
 nodeenv = ">=0.11.1"
 pyyaml = ">=5.1"
-toml = "*"
-virtualenv = ">=20.0.8"
+virtualenv = ">=20.10.0"
 
 [[package]]
 name = "prettytable"
-version = "3.5.0"
+version = "3.7.0"
 description = "A simple Python library for easily displaying tabular data in a visually appealing ASCII table format"
 category = "main"
 optional = true
@@ -522,7 +521,7 @@ tests = ["pytest", "pytest-cov", "pytest-lazy-fixture"]
 
 [[package]]
 name = "psutil"
-version = "5.9.4"
+version = "5.9.5"
 description = "Cross-platform lib for process and system monitoring in Python."
 category = "main"
 optional = true
@@ -533,7 +532,7 @@ test = ["ipaddress", "mock", "enum34", "pywin32", "wmi"]
 
 [[package]]
 name = "pyats"
-version = "22.11"
+version = "23.3"
 description = "pyATS - Python Automation Test System"
 category = "main"
 optional = true
@@ -541,29 +540,29 @@ python-versions = ">=3.5"
 
 [package.dependencies]
 packaging = ">=20.0"
-"pyats.aereport" = ">=22.11.0,<22.12.0"
-"pyats.aetest" = ">=22.11.0,<22.12.0"
-"pyats.async" = ">=22.11.0,<22.12.0"
-"pyats.connections" = ">=22.11.0,<22.12.0"
-"pyats.datastructures" = ">=22.11.0,<22.12.0"
-"pyats.easypy" = ">=22.11.0,<22.12.0"
-"pyats.kleenex" = ">=22.11.0,<22.12.0"
-"pyats.log" = ">=22.11.0,<22.12.0"
-"pyats.reporter" = ">=22.11.0,<22.12.0"
-"pyats.results" = ">=22.11.0,<22.12.0"
-"pyats.tcl" = ">=22.11.0,<22.12.0"
-"pyats.topology" = ">=22.11.0,<22.12.0"
-"pyats.utils" = ">=22.11.0,<22.12.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"
 
 [package.extras]
-full = ["cookiecutter", "genie (>=22.11.0,<22.12.0)", "pyats.robot (>=22.11.0,<22.12.0)", "genie.libs.robot (>=22.11.0,<22.12.0)", "genie.telemetry (>=22.11.0,<22.12.0)", "genie.trafficgen (>=22.11.0,<22.12.0)", "pyats.contrib (>=22.11.0,<22.12.0)"]
-library = ["genie (>=22.11.0,<22.12.0)"]
-robot = ["pyats.robot (>=22.11.0,<22.12.0)", "genie.libs.robot (>=22.11.0,<22.12.0)"]
+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)"]
 template = ["cookiecutter"]
 
 [[package]]
 name = "pyats.aereport"
-version = "22.11"
+version = "23.3"
 description = "pyATS AEreport: Result Collection and Reporting"
 category = "main"
 optional = true
@@ -573,15 +572,15 @@ python-versions = ">=3.5"
 jinja2 = "*"
 junit-xml = "*"
 psutil = "*"
-"pyats.log" = ">=22.11.0,<22.12.0"
-"pyats.results" = ">=22.11.0,<22.12.0"
+"pyats.log" = ">=23.3.0,<23.4.0"
+"pyats.results" = ">=23.3.0,<23.4.0"
 
 [package.extras]
 dev = ["sphinx", "sphinx-rtd-theme"]
 
 [[package]]
 name = "pyats.aetest"
-version = "22.11"
+version = "23.3"
 description = "pyATS AEtest: Testscript Engine"
 category = "main"
 optional = true
@@ -589,11 +588,11 @@ python-versions = ">=3.5"
 
 [package.dependencies]
 jinja2 = "*"
-"pyats.aereport" = ">=22.11.0,<22.12.0"
-"pyats.datastructures" = ">=22.11.0,<22.12.0"
-"pyats.log" = ">=22.11.0,<22.12.0"
-"pyats.results" = ">=22.11.0,<22.12.0"
-"pyats.utils" = ">=22.11.0,<22.12.0"
+"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"
 pyyaml = "*"
 
 [package.extras]
@@ -601,37 +600,37 @@ dev = ["sphinx", "sphinx-rtd-theme"]
 
 [[package]]
 name = "pyats.async"
-version = "22.11"
+version = "23.3"
 description = "pyATS Async: Asynchronous Execution of Codes"
 category = "main"
 optional = true
 python-versions = ">=3.5"
 
 [package.dependencies]
-"pyats.log" = ">=22.11.0,<22.12.0"
+"pyats.log" = ">=23.3.0,<23.4.0"
 
 [package.extras]
 dev = ["sphinx", "sphinx-rtd-theme"]
 
 [[package]]
 name = "pyats.connections"
-version = "22.11"
+version = "23.3"
 description = "pyATS Connection: Device Connection Handling & Base Classes"
 category = "main"
 optional = true
 python-versions = ">=3.5"
 
 [package.dependencies]
-"pyats.async" = ">=22.11.0,<22.12.0"
-"pyats.datastructures" = ">=22.11.0,<22.12.0"
-unicon = ">=22.11.0,<22.12.0"
+"pyats.async" = ">=23.3.0,<23.4.0"
+"pyats.datastructures" = ">=23.3.0,<23.4.0"
+unicon = ">=23.3.0,<23.4.0"
 
 [package.extras]
 dev = ["sphinx", "sphinx-rtd-theme"]
 
 [[package]]
 name = "pyats.datastructures"
-version = "22.11"
+version = "23.3"
 description = "pyATS Datastructures: Extended Datastructures for Grownups"
 category = "main"
 optional = true
@@ -642,7 +641,7 @@ dev = ["sphinx", "sphinx-rtd-theme"]
 
 [[package]]
 name = "pyats.easypy"
-version = "22.11"
+version = "23.3"
 description = "pyATS Easypy: launcher and runtime environment"
 category = "main"
 optional = true
@@ -652,20 +651,20 @@ python-versions = ">=3.5"
 distro = "*"
 jinja2 = "*"
 psutil = "*"
-"pyats.aereport" = ">=22.11.0,<22.12.0"
-"pyats.datastructures" = ">=22.11.0,<22.12.0"
-"pyats.kleenex" = ">=22.11.0,<22.12.0"
-"pyats.log" = ">=22.11.0,<22.12.0"
-"pyats.results" = ">=22.11.0,<22.12.0"
-"pyats.topology" = ">=22.11.0,<22.12.0"
-"pyats.utils" = ">=22.11.0,<22.12.0"
+"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"
 
 [package.extras]
 dev = ["sphinx", "sphinx-rtd-theme"]
 
 [[package]]
 name = "pyats.kleenex"
-version = "22.11"
+version = "23.3"
 description = "pyATS Kleenex: Testbed Preparation, Clean & Finalization"
 category = "main"
 optional = true
@@ -673,12 +672,12 @@ python-versions = ">=3.5"
 
 [package.dependencies]
 distro = "*"
-"pyats.aetest" = ">=22.11.0,<22.12.0"
-"pyats.async" = ">=22.11.0,<22.12.0"
-"pyats.datastructures" = ">=22.11.0,<22.12.0"
-"pyats.log" = ">=22.11.0,<22.12.0"
-"pyats.topology" = ">=22.11.0,<22.12.0"
-"pyats.utils" = ">=22.11.0,<22.12.0"
+"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"
 requests = "*"
 
 [package.extras]
@@ -686,7 +685,7 @@ dev = ["sphinx", "sphinx-rtd-theme"]
 
 [[package]]
 name = "pyats.log"
-version = "22.11"
+version = "23.3"
 description = "pyATS Log: Logging Format and Utilities"
 category = "main"
 optional = true
@@ -699,7 +698,7 @@ aiohttp-swagger = ">=1.0.15"
 async-lru = ">=1.0.2"
 chardet = ">=3.0.4,<5.0.0"
 jinja2 = "*"
-"pyats.datastructures" = ">=22.11.0,<22.12.0"
+"pyats.datastructures" = ">=23.3.0,<23.4.0"
 python-engineio = ">=3.13.0,<4.0.0"
 python-socketio = ">=4.2.0,<5.0.0"
 pyyaml = "*"
@@ -709,18 +708,18 @@ dev = ["sphinx", "sphinx-rtd-theme"]
 
 [[package]]
 name = "pyats.reporter"
-version = "22.11"
+version = "23.3"
 description = "pyATS Reporter: Result Collection and Reporting"
 category = "main"
 optional = true
 python-versions = ">=3.5"
 
 [package.dependencies]
-gitpython = "<=3.1.18"
-"pyats.aereport" = ">=22.11.0,<22.12.0"
-"pyats.log" = ">=22.11.0,<22.12.0"
-"pyats.results" = ">=22.11.0,<22.12.0"
-"pyats.utils" = ">=22.11.0,<22.12.0"
+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"
 pyyaml = "*"
 
 [package.extras]
@@ -728,7 +727,7 @@ dev = ["sphinx", "sphinx-rtd-theme"]
 
 [[package]]
 name = "pyats.results"
-version = "22.11"
+version = "23.3"
 description = "pyATS Results: Representing Results using Objects"
 category = "main"
 optional = true
@@ -739,31 +738,31 @@ dev = ["sphinx", "sphinx-rtd-theme"]
 
 [[package]]
 name = "pyats.tcl"
-version = "22.11"
+version = "23.3"
 description = "pyATS Tcl: Tcl Integration and Objects"
 category = "main"
 optional = true
 python-versions = ">=3.5"
 
 [package.dependencies]
-"pyats.datastructures" = ">=22.11.0,<22.12.0"
-"pyats.log" = ">=22.11.0,<22.12.0"
+"pyats.datastructures" = ">=23.3.0,<23.4.0"
+"pyats.log" = ">=23.3.0,<23.4.0"
 
 [package.extras]
 dev = ["sphinx", "sphinx-rtd-theme"]
 
 [[package]]
 name = "pyats.topology"
-version = "22.11"
+version = "23.3"
 description = "pyATS Topology: Topology Objects and Testbed YAMLs"
 category = "main"
 optional = true
 python-versions = ">=3.5"
 
 [package.dependencies]
-"pyats.connections" = ">=22.11.0,<22.12.0"
-"pyats.datastructures" = ">=22.11.0,<22.12.0"
-"pyats.utils" = ">=22.11.0,<22.12.0"
+"pyats.connections" = ">=23.3.0,<23.4.0"
+"pyats.datastructures" = ">=23.3.0,<23.4.0"
+"pyats.utils" = ">=23.3.0,<23.4.0"
 pyyaml = "*"
 yamllint = "*"
 
@@ -772,7 +771,7 @@ dev = ["sphinx", "sphinx-rtd-theme"]
 
 [[package]]
 name = "pyats.utils"
-version = "22.11"
+version = "23.3"
 description = "pyATS Utils: Utilities Module"
 category = "main"
 optional = true
@@ -781,15 +780,15 @@ python-versions = ">=3.5"
 [package.dependencies]
 cryptography = "*"
 distro = "*"
-"pyats.datastructures" = ">=22.11.0,<22.12.0"
-"pyats.topology" = ">=22.11.0,<22.12.0"
+"pyats.datastructures" = ">=23.3.0,<23.4.0"
+"pyats.topology" = ">=23.3.0,<23.4.0"
 
 [package.extras]
 dev = ["sphinx", "sphinx-rtd-theme", "requests-mock"]
 
 [[package]]
 name = "pycodestyle"
-version = "2.9.1"
+version = "2.10.0"
 description = "Python style guide checker"
 category = "dev"
 optional = false
@@ -805,7 +804,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
 [[package]]
 name = "pyflakes"
-version = "2.5.0"
+version = "3.0.1"
 description = "passive checker of Python programs"
 category = "dev"
 optional = false
@@ -813,36 +812,24 @@ python-versions = ">=3.6"
 
 [[package]]
 name = "pygments"
-version = "2.13.0"
+version = "2.15.0"
 description = "Pygments is a syntax highlighting package written in Python."
 category = "main"
 optional = true
-python-versions = ">=3.6"
+python-versions = ">=3.7"
 
 [package.extras]
 plugins = ["importlib-metadata"]
 
-[[package]]
-name = "pyparsing"
-version = "3.0.9"
-description = "pyparsing module - Classes and methods to define and execute parsing grammars"
-category = "main"
-optional = false
-python-versions = ">=3.6.8"
-
-[package.extras]
-diagrams = ["railroad-diagrams", "jinja2"]
-
 [[package]]
 name = "pytest"
-version = "7.2.0"
+version = "7.3.1"
 description = "pytest: simple powerful testing with Python"
 category = "dev"
 optional = false
 python-versions = ">=3.7"
 
 [package.dependencies]
-attrs = ">=19.2.0"
 colorama = {version = "*", markers = "sys_platform == \"win32\""}
 exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
 iniconfig = "*"
@@ -851,7 +838,7 @@ pluggy = ">=0.12,<2.0"
 tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
 
 [package.extras]
-testing = ["argcomplete", "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", "xmlschema"]
 
 [[package]]
 name = "python-engineio"
@@ -886,7 +873,7 @@ client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"]
 
 [[package]]
 name = "pytz"
-version = "2022.6"
+version = "2023.3"
 description = "World timezone definitions, modern and historical"
 category = "main"
 optional = true
@@ -902,7 +889,7 @@ python-versions = ">=3.6"
 
 [[package]]
 name = "requests"
-version = "2.28.1"
+version = "2.28.2"
 description = "Python HTTP for Humans."
 category = "main"
 optional = true
@@ -910,7 +897,7 @@ python-versions = ">=3.7, <4"
 
 [package.dependencies]
 certifi = ">=2017.4.17"
-charset-normalizer = ">=2,<3"
+charset-normalizer = ">=2,<4"
 idna = ">=2.5,<4"
 urllib3 = ">=1.21.1,<1.27"
 
@@ -929,20 +916,6 @@ python-versions = ">=3.7"
 [package.dependencies]
 httpx = ">=0.21.0"
 
-[[package]]
-name = "rfc3986"
-version = "1.5.0"
-description = "Validating URI References per RFC 3986"
-category = "main"
-optional = false
-python-versions = "*"
-
-[package.dependencies]
-idna = {version = "*", optional = true, markers = "extra == \"idna2008\""}
-
-[package.extras]
-idna2008 = ["idna"]
-
 [[package]]
 name = "six"
 version = "1.16.0"
@@ -977,23 +950,23 @@ python-versions = "*"
 
 [[package]]
 name = "sphinx"
-version = "5.3.0"
+version = "6.1.3"
 description = "Python documentation generator"
 category = "main"
 optional = true
-python-versions = ">=3.6"
+python-versions = ">=3.8"
 
 [package.dependencies]
 alabaster = ">=0.7,<0.8"
 babel = ">=2.9"
 colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
-docutils = ">=0.14,<0.20"
+docutils = ">=0.18,<0.20"
 imagesize = ">=1.3"
 importlib-metadata = {version = ">=4.8", markers = "python_version < \"3.10\""}
 Jinja2 = ">=3.0"
 packaging = ">=21.0"
-Pygments = ">=2.12"
-requests = ">=2.5.0"
+Pygments = ">=2.13"
+requests = ">=2.25.0"
 snowballstemmer = ">=2.0"
 sphinxcontrib-applehelp = "*"
 sphinxcontrib-devhelp = "*"
@@ -1004,31 +977,32 @@ sphinxcontrib-serializinghtml = ">=1.1.5"
 
 [package.extras]
 docs = ["sphinxcontrib-websupport"]
-lint = ["flake8 (>=3.5.0)", "flake8-comprehensions", "flake8-bugbear", "flake8-simplify", "isort", "mypy (>=0.981)", "sphinx-lint", "docutils-stubs", "types-typed-ast", "types-requests"]
-test = ["pytest (>=4.6)", "html5lib", "typed-ast", "cython"]
+lint = ["flake8 (>=3.5.0)", "flake8-simplify", "isort", "ruff", "mypy (>=0.990)", "sphinx-lint", "docutils-stubs", "types-requests"]
+test = ["pytest (>=4.6)", "html5lib", "cython"]
 
 [[package]]
 name = "sphinx-rtd-theme"
-version = "1.1.1"
+version = "1.2.0"
 description = "Read the Docs theme for Sphinx"
 category = "main"
 optional = true
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
 
 [package.dependencies]
-docutils = "<0.18"
-sphinx = ">=1.6,<6"
+docutils = "<0.19"
+sphinx = ">=1.6,<7"
+sphinxcontrib-jquery = {version = ">=2.0.0,<3.0.0 || >3.0.0", markers = "python_version > \"3\""}
 
 [package.extras]
 dev = ["transifex-client", "sphinxcontrib-httpdomain", "bump2version", "wheel"]
 
 [[package]]
 name = "sphinxcontrib-applehelp"
-version = "1.0.2"
-description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books"
+version = "1.0.4"
+description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books"
 category = "main"
 optional = true
-python-versions = ">=3.5"
+python-versions = ">=3.8"
 
 [package.extras]
 lint = ["flake8", "mypy", "docutils-stubs"]
@@ -1048,16 +1022,27 @@ test = ["pytest"]
 
 [[package]]
 name = "sphinxcontrib-htmlhelp"
-version = "2.0.0"
+version = "2.0.1"
 description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files"
 category = "main"
 optional = true
-python-versions = ">=3.6"
+python-versions = ">=3.8"
 
 [package.extras]
 lint = ["flake8", "mypy", "docutils-stubs"]
 test = ["pytest", "html5lib"]
 
+[[package]]
+name = "sphinxcontrib-jquery"
+version = "4.1"
+description = "Extension to include jQuery on newer Sphinx releases"
+category = "main"
+optional = true
+python-versions = ">=2.7"
+
+[package.dependencies]
+Sphinx = ">=1.8"
+
 [[package]]
 name = "sphinxcontrib-jsmath"
 version = "1.0.1"
@@ -1093,14 +1078,6 @@ python-versions = ">=3.5"
 lint = ["flake8", "mypy", "docutils-stubs"]
 test = ["pytest"]
 
-[[package]]
-name = "toml"
-version = "0.10.2"
-description = "Python Library for Tom's Obvious, Minimal Language"
-category = "dev"
-optional = false
-python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
-
 [[package]]
 name = "tomli"
 version = "2.0.1"
@@ -1109,9 +1086,17 @@ category = "dev"
 optional = false
 python-versions = ">=3.7"
 
+[[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"
+
 [[package]]
 name = "unicon"
-version = "22.11"
+version = "23.3"
 description = "Unicon Connection Library"
 category = "main"
 optional = true
@@ -1120,7 +1105,7 @@ python-versions = ">=3.5"
 [package.dependencies]
 dill = "*"
 pyyaml = "*"
-"unicon.plugins" = ">=22.11.0,<22.12.0"
+"unicon.plugins" = ">=23.3.0,<23.4.0"
 
 [package.extras]
 dev = ["cisco-distutils", "coverage", "restview", "sphinx", "sphinxcontrib-napoleon", "sphinxcontrib-mockautodoc", "sphinx-rtd-theme"]
@@ -1129,7 +1114,7 @@ robot = ["robotframework"]
 
 [[package]]
 name = "unicon.plugins"
-version = "22.11"
+version = "23.3"
 description = "Unicon Connection Library Plugins"
 category = "main"
 optional = true
@@ -1138,14 +1123,14 @@ python-versions = "*"
 [package.dependencies]
 PrettyTable = "*"
 pyyaml = "*"
-unicon = ">=22.11.0,<22.12.0"
+unicon = ">=23.3.0,<23.4.0"
 
 [package.extras]
 dev = ["setuptools", "pip", "wheel", "coverage", "restview", "sphinx", "sphinxcontrib-napoleon", "sphinxcontrib-mockautodoc", "sphinx-rtd-theme"]
 
 [[package]]
 name = "urllib3"
-version = "1.26.13"
+version = "1.26.15"
 description = "HTTP library with thread-safe connection pooling, file post, and more."
 category = "main"
 optional = true
@@ -1158,24 +1143,24 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
 
 [[package]]
 name = "virtualenv"
-version = "20.17.0"
+version = "20.21.0"
 description = "Virtual Python Environment builder"
 category = "dev"
 optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.7"
 
 [package.dependencies]
 distlib = ">=0.3.6,<1"
 filelock = ">=3.4.1,<4"
-platformdirs = ">=2.4,<3"
+platformdirs = ">=2.4,<4"
 
 [package.extras]
-docs = ["proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-argparse (>=0.3.2)", "sphinx-rtd-theme (>=1)", "towncrier (>=22.8)"]
-testing = ["coverage (>=6.2)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=21.3)", "pytest (>=7.0.1)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.6.1)", "pytest-randomly (>=3.10.3)", "pytest-timeout (>=2.1)"]
+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)"]
 
 [[package]]
 name = "wcwidth"
-version = "0.2.5"
+version = "0.2.6"
 description = "Measures the displayed width of unicode strings in a terminal"
 category = "main"
 optional = true
@@ -1183,11 +1168,11 @@ python-versions = "*"
 
 [[package]]
 name = "yamllint"
-version = "1.28.0"
+version = "1.30.0"
 description = "A linter for YAML files."
 category = "main"
 optional = true
-python-versions = ">=3.6"
+python-versions = ">=3.7"
 
 [package.dependencies]
 pathspec = ">=0.5.3"
@@ -1207,24 +1192,25 @@ multidict = ">=4.0"
 
 [[package]]
 name = "zipp"
-version = "3.11.0"
+version = "3.15.0"
 description = "Backport of pathlib-compatible object wrapper for zip files"
 category = "main"
 optional = true
 python-versions = ">=3.7"
 
 [package.extras]
-docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "jaraco.tidelift (>=1.4)"]
-testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "jaraco.functools", "more-itertools", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "pytest-flake8"]
+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"]
 
 [extras]
 docs = ["sphinx", "sphinx_rtd_theme"]
+events = ["aiohttp"]
 pyats = ["pyats"]
 
 [metadata]
 lock-version = "1.1"
-python-versions = "^3.8.0"
-content-hash = "c393587596d34fd5c9a0eb671e344a2ebc11f3ec8cf6491dc0718850403d9f70"
+python-versions = "^3.8.1"
+content-hash = "faf4e2850830c92e5e6df0dacfd32c3e103c67194b491c9841ddae8f151dfb2e"
 
 [metadata.files]
 aiofiles = []
@@ -1293,7 +1279,6 @@ pycodestyle = []
 pycparser = []
 pyflakes = []
 pygments = []
-pyparsing = []
 pytest = []
 python-engineio = []
 python-socketio = []
@@ -1301,7 +1286,6 @@ pytz = []
 pyyaml = []
 requests = []
 respx = []
-rfc3986 = []
 six = []
 smmap = []
 sniffio = []
@@ -1311,11 +1295,12 @@ sphinx-rtd-theme = []
 sphinxcontrib-applehelp = []
 sphinxcontrib-devhelp = []
 sphinxcontrib-htmlhelp = []
+sphinxcontrib-jquery = []
 sphinxcontrib-jsmath = []
 sphinxcontrib-qthelp = []
 sphinxcontrib-serializinghtml = []
-toml = []
 tomli = []
+typing-extensions = []
 unicon = []
 "unicon.plugins" = []
 urllib3 = []
diff --git a/pyproject.toml b/pyproject.toml
index b447947..5470a7d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "virl2_client"
-version = "2.5.0"
+version = "2.6.0"
 description = "VIRL2 Client Library"
 authors = ["Simon Knight <simknigh@cisco.com>", "Ralph Schmieder <rschmied@cisco.com>"]
 license = "Apache-2.0"
@@ -20,32 +20,34 @@ classifiers = [
 ]
 
 [tool.poetry.dependencies]
-python = "^3.8.0"
-httpx = "^0.23.0"
+python = "^3.8.1"
+httpx = "^0.24.0"
+
+# optional package for events
+aiohttp = {version = "*", 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 = "^22.4", optional = true}
-pyats = {version = "^22.4", optional = true}
+# pyats = {extras=["full"], version = "^23", optional = true}
+pyats = {version = "^23", optional = true}
 
 # needed to build documentation, technically, this is a dev-dependency
 # https://github.com/python-poetry/poetry/pull/606 -- no support
 sphinx_rtd_theme = {version="^1", optional = true}
-sphinx =  {version="^5", optional = true}
-
+sphinx =  {version="^6", optional = true}
 
 [tool.poetry.dev-dependencies]
-# flake8 version 6 is available but requires python>=3.8.1
-flake8 = "^5"
-pre-commit = "^2.17.0"
+flake8 = "^6"
+pre-commit = "^3"
 pytest = "*"
 respx = "^0.20.0"
 
 [tool.poetry.extras]
+events = ["aiohttp"]
 pyats = ["pyats"]
 docs = ["sphinx", "sphinx_rtd_theme"]
 
 [build-system]
 requires = ["poetry>=1.1.8"]
-build-backend = "poetry.masonry.api"
+build-backend = "poetry.core.masonry.api"
diff --git a/virl2_client/event_handling.py b/virl2_client/event_handling.py
new file mode 100644
index 0000000..f17cd84
--- /dev/null
+++ b/virl2_client/event_handling.py
@@ -0,0 +1,241 @@
+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 .exceptions import ElementNotFound, LabNotFound
+
+if TYPE_CHECKING:
+    from ..virl2_client import ClientLibrary
+    from .models import Interface, Lab, Link, Node
+
+_LOGGER = logging.getLogger(__name__)
+
+# Fixes an arbitrary 'RuntimeError: Event loop is closed'
+# that sometimes appeared on Windows for no reason, see
+# https://stackoverflow.com/questions/45600579/asyncio-event-loop-is-closed-when-getting-loop
+if os_name == "nt":
+    asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
+
+
+class Event:
+    """An event object, stores parsed info about the event it represents."""
+
+    def __init__(self, event_dict: dict[str, Any]):
+        self.type: str = (event_dict.get("event_type") or "").casefold()
+        self.subtype_original: Optional[str] = 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.element: Union[Node, Interface, Link, None] = None
+
+
+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.
+    """
+
+    def __init__(self, client_library: ClientLibrary = None):
+        self.client_library = client_library
+
+    def parse_event(self, event: Event) -> None:
+        if event.type == "lab_event":
+            self._parse_lab(event)
+        elif event.type == "lab_element_event":
+            self._parse_element(event)
+        elif event.type == "state_change":
+            self._parse_state_change(event)
+        else:
+            self._parse_other(event)
+
+    def _parse_lab(self, event: Event) -> None:
+        if event.subtype == "created":
+            self._parse_lab_created(event)
+        elif event.subtype == "modified":
+            self._parse_lab_modified(event)
+        elif event.subtype == "deleted":
+            self._parse_lab_deleted(event)
+        elif event.subtype == "state":
+            self._parse_lab_state(event)
+        else:
+            _LOGGER.warning("Received an invalid lab event (%s)", event.subtype)
+
+    @abstractmethod
+    def _parse_lab_created(self, event: Event) -> None:
+        pass
+
+    @abstractmethod
+    def _parse_lab_modified(self, event: Event) -> None:
+        pass
+
+    @abstractmethod
+    def _parse_lab_deleted(self, event: Event) -> None:
+        pass
+
+    @abstractmethod
+    def _parse_lab_state(self, event: Event) -> None:
+        pass  # TODO: implement lab state sync via events
+
+    def _parse_element(self, event: Event) -> None:
+        if event.subtype == "created":
+            self._parse_element_created(event)
+        elif event.subtype == "modified":
+            self._parse_element_modified(event)
+        elif event.subtype == "deleted":
+            self._parse_element_deleted(event)
+        else:
+            _LOGGER.warning("Received an invalid element event (%s)", event.subtype)
+
+    @abstractmethod
+    def _parse_element_created(self, event: Event) -> None:
+        pass
+
+    @abstractmethod
+    def _parse_element_modified(self, event: Event) -> None:
+        pass
+
+    @abstractmethod
+    def _parse_element_deleted(self, event: Event) -> None:
+        pass
+
+    @abstractmethod
+    def _parse_state_change(self, event: Event) -> None:
+        pass
+
+    def _parse_other(self, event: Event) -> None:
+        # 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.
+    Used by EventListener by default, but can be subclassed and methods overridden
+    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"):
+            return
+
+        try:
+            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":
+            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)
+            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:
+        # 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:
+        event.lab.update_lab_properties(event.data)
+
+    def _parse_lab_deleted(self, event: Event) -> None:
+        self.client_library._remove_lab_local(event.lab)
+
+    def _parse_lab_state(self, event: Event) -> None:
+        event.lab._state = event.data["state"]
+
+    def _parse_element_created(self, event: Event) -> None:
+        new_element: Node | Interface | Link
+        if event.element_type == "node":
+            new_element = event.lab._import_node(
+                event.element_id,
+                event.data,
+            )
+        elif event.element_type == "interface":
+            new_element = event.lab._import_interface(
+                event.element_id,
+                event.data["node"],
+                event.data,
+            )
+        elif event.element_type == "link":
+            new_element = event.lab._import_link(
+                event.element_id,
+                event.data["interface_a"],
+                event.data["interface_b"],
+            )
+        else:
+            _LOGGER.warning("Received an invalid element type (%s)", event.element_type)
+            return
+        new_element._state = event.data.get("state")
+
+    def _parse_element_modified(self, event: Event) -> None:
+        if event.element_type == "node":
+            event.element.update(event.data, exclude_configurations=False)
+
+        elif event.element_type == "interface":
+            # it seems only port change info arrives here,
+            # which the client doesn't use, so this message can be discarded
+            pass
+
+        elif event.element_type == "link":
+            # same as above, only sends link_capture_key which is not used
+            # by the client, so we discard the message
+            pass
+
+        else:
+            _LOGGER.warning(
+                "Received an invalid lab element event (%s %s)",
+                event.subtype,
+                event.element_type,
+            )
+
+    def _parse_element_deleted(self, event: Event) -> None:
+        if event.element_type == "node":
+            event.lab._remove_node_local(event.element)
+
+        elif event.element_type == "interface":
+            event.lab._remove_interface_local(event.element)
+
+        elif event.element_type == "link":
+            event.lab._remove_link_local(event.element)
+
+        else:
+            _LOGGER.warning(
+                "Received an invalid lab element event (%s %s)",
+                event.subtype,
+                event.element_type,
+            )
+
+    def _parse_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
new file mode 100644
index 0000000..dd7f7c8
--- /dev/null
+++ b/virl2_client/event_listening.py
@@ -0,0 +1,160 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import ssl
+import threading
+from pathlib import Path
+from typing import TYPE_CHECKING, Coroutine, Optional
+from urllib.parse import urlparse
+
+import aiohttp
+
+from .event_handling import Event, EventHandler
+
+if TYPE_CHECKING:
+    from .virl2_client import ClientLibrary
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class EventListener:
+    def __init__(self, client_library: ClientLibrary):
+        """
+        Initializes a EventListener instance.
+        EventListener creates and listens to a websocket connection to the server.
+        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
+            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._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._event_handler = EventHandler(client_library)
+        self._init_ws_connection_data(client_library)
+
+    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.
+
+        :param client_library: the client library instance
+        :returns: a tuple of url and context
+        """
+        ssl_verify = client_library._ssl_verify
+        # Create an SSL context based on the 'verify' str/bool,
+        # since that is what aiohttp asks for
+        if ssl_verify is False:
+            ssl_context = ssl.create_default_context()
+            ssl_context.check_hostname = False
+            ssl_context.verify_mode = ssl.CERT_NONE
+        elif type(ssl_verify) is str and Path(ssl_verify).is_file():
+            ssl_context = ssl.create_default_context()
+            ssl_context.load_verify_locations(ssl_verify)
+        else:
+            ssl_context = None
+        self._ssl_context = ssl_context
+
+        # Take the base URL and modify it into the WS url,
+        # without string manipulation because we are civilized people
+        url_pieces = urlparse(client_library.url)
+        ws_url_pieces = url_pieces._replace(scheme="wss", path="ws/ui")
+        self._ws_url = str(ws_url_pieces.geturl())
+
+        self._auth_data = {
+            "token": client_library.session.auth.token,
+            "client_uuid": client_library.uuid,
+        }
+
+    def start_listening(self):
+        if self._listening:
+            return
+
+        self._ws_connected_event = threading.Event()
+        self._listening = True
+
+        self._thread = threading.Thread(
+            target=asyncio.run, args=(self._listen(),), daemon=True
+        )
+        self._thread.start()
+
+    def stop_listening(self):
+        if not self._listening:
+            return
+
+        self._ws_connected_event.wait()
+
+        self._ws_close_event.set()
+        self._thread.join()
+
+        self._thread = None
+        self._ws_connected_event = None
+        self._listening = False
+
+    async def _listen(self):
+        _LOGGER.info("Starting listening")
+        self._queue = asyncio.Queue()
+        self._ws_close_event = asyncio.Event()
+
+        client_task = asyncio.create_task(self._ws_client())
+        parser_task = asyncio.create_task(self._parse())
+        result = await asyncio.gather(client_task, parser_task)
+
+        self._ws_close_event = None
+        self._queue = None
+        _LOGGER.info("Listening over")
+        return result
+
+    async def _parse(self):
+        close_wait = asyncio.create_task(self._ws_close_event.wait())
+        while True:
+            queue_get = asyncio.create_task(self._queue.get())
+            await asyncio.wait(
+                {queue_get, asyncio.shield(close_wait)},
+                return_when=asyncio.FIRST_COMPLETED,
+            )
+            if close_wait.done():
+                _LOGGER.info("Closing connection")
+                if self._ws_close is not None:
+                    await self._ws_close
+                return
+            event = Event(json.loads(queue_get.result()))
+            self._event_handler.parse_event(event)
+            self._queue.task_done()
+
+    async def _ws_client(self):
+        try:
+            async with aiohttp.ClientSession() as session:
+                async with session.ws_connect(
+                    self._ws_url, ssl=self._ssl_context
+                ) as ws:
+                    await ws.send_json(self._auth_data)
+                    self._connected = True
+                    _LOGGER.info("Connected successfully")
+                    self._ws_close = ws.close()
+                    self._ws_connected_event.set()
+                    async for msg in ws:  # type: aiohttp.WSMessage
+                        self._queue.put_nowait(msg.data)
+        except aiohttp.ClientError:
+            _LOGGER.error("Connection closed unexpectedly", exc_info=True)
+        finally:
+            if self._ws_close is not None:
+                self._ws_close.close()
+                self._ws_close = None
+            self._ws_close_event.set()
+            self._ws_connected_event.set()
+            self._connected = False
+        _LOGGER.info("Disconnected")
diff --git a/virl2_client/exceptions.py b/virl2_client/exceptions.py
index d9f20dc..0d87291 100644
--- a/virl2_client/exceptions.py
+++ b/virl2_client/exceptions.py
@@ -55,9 +55,17 @@ class DesynchronizedError(VirlException):
     pass
 
 
+class InvalidContentType(VirlException):
+    pass
+
+
 class InvalidImageFile(VirlException):
     pass
 
 
 class InvalidProperty(VirlException):
     pass
+
+
+class MethodNotActive(VirlException):
+    pass
diff --git a/virl2_client/models/__init__.py b/virl2_client/models/__init__.py
index 616c535..ab1a0a4 100644
--- a/virl2_client/models/__init__.py
+++ b/virl2_client/models/__init__.py
@@ -26,6 +26,7 @@
 from .groups import GroupManagement
 from .interface import Interface
 from .lab import Lab
+from .auth_management import AuthManagement
 from .licensing import Licensing
 from .link import Link
 from .node import Node
@@ -46,4 +47,5 @@
     "GroupManagement",
     "TokenAuth",
     "ResourcePoolManagement",
+    "AuthManagement",
 )
diff --git a/virl2_client/models/auth_management.py b/virl2_client/models/auth_management.py
new file mode 100644
index 0000000..f61ad5c
--- /dev/null
+++ b/virl2_client/models/auth_management.py
@@ -0,0 +1,348 @@
+#
+# This file is part of VIRL 2
+# Copyright (c) 2019-2023, 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 time
+from typing import Any, Optional, TYPE_CHECKING
+
+from .resource_pools import ResourcePool
+from ..exceptions import MethodNotActive
+
+if TYPE_CHECKING:
+    from httpx import Client
+
+
+_BASE_URL = "system/auth"
+_CONFIG_URL = _BASE_URL + "/config"
+_TEST_URL = _BASE_URL + "/test"
+
+
+class AuthManagement:
+    def __init__(self, session: Client, auto_sync=True, auto_sync_interval=1.0):
+        """
+        Sync and modify authentication settings.
+
+        :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
+        """
+        self.auto_sync = auto_sync
+        self.auto_sync_interval = auto_sync_interval
+        self._last_sync_time = 0.0
+        self._session = session
+        self._settings = {}
+        self._managers = {
+            "local": None,
+            "ldap": LDAPManager(self),
+        }
+
+    def sync_if_outdated(self) -> None:
+        timestamp = time.time()
+        if (
+            self.auto_sync
+            and timestamp - self._last_sync_time > self.auto_sync_interval
+        ):
+            self.sync()
+
+    def sync(self) -> None:
+        self._settings = self._session.get(_CONFIG_URL).json()
+        self._last_sync_time = time.time()
+
+    @property
+    def method(self) -> str:
+        """Current authentication method."""
+        self.sync_if_outdated()
+        return self._settings["method"]
+
+    @property
+    def manager(self) -> AuthMethodManager:
+        """
+        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()
+
+    def _get_setting(self, setting: str) -> Any:
+        """
+        Returns the value of a specific setting of the current authentication method.
+
+        Note: consider using parameters instead.
+        """
+        self.sync_if_outdated()
+        return self._settings[setting]
+
+    def get_settings(self) -> dict:
+        """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.
+
+        :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"]},
+        )
+        if setting in self._settings:
+            self._settings[setting] = value
+
+    def update_settings(self, settings_dict: Optional[dict] = 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.
+        If passed multiple keyword arguments, each is taken as a setting.
+        Can mix and match; keywords take precedence.
+
+
+        Examples::
+            am = client.auth_management
+            am.update_settings({"method": "ldap", "verify_tls": False})
+            am.update_settings(method="ldap", verify_tls=False)
+            # "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
+        """
+        settings = {}
+        if settings_dict:
+            settings.update(settings_dict)
+        settings.update(kwargs)
+        if not settings:
+            raise TypeError("No settings to update.")
+        self._session.put(_CONFIG_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.
+
+        :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)
+        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
+        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
+        """
+        current = self.get_settings()
+        current["manager_password"] = manager_password
+        body = {
+            "auth-config": current,
+            "auth-data": {"username": username, "password": password},
+        }
+        response = self._session.post(_TEST_URL, json=body)
+        return response.json()
+
+
+class AuthMethodManager:
+    METHOD = ""
+
+    def __init__(self, auth_management: 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."""
+        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:
+        self._check_method()
+        return self._auth_management._get_setting(setting)
+
+    def _update_setting(self, setting: str, value: Any) -> None:
+        self._check_method()
+        self._auth_management._update_setting(setting, value)
+
+
+class LDAPManager(AuthMethodManager):
+    METHOD = "ldap"
+
+    @property
+    def server_urls(self) -> str:
+        return self._get_setting("server_urls")
+
+    @server_urls.setter
+    def server_urls(self, value: str) -> None:
+        self._update_setting("server_urls", value)
+
+    @property
+    def verify_tls(self) -> bool:
+        return self._get_setting("verify_tls")
+
+    @verify_tls.setter
+    def verify_tls(self, value: bool) -> None:
+        self._update_setting("verify_tls", value)
+
+    @property
+    def cert_data_pem(self) -> str:
+        return self._get_setting("cert_data_pem")
+
+    @cert_data_pem.setter
+    def cert_data_pem(self, value: str) -> None:
+        self._update_setting("cert_data_pem", value)
+
+    @property
+    def use_ntlm(self) -> bool:
+        return self._get_setting("use_ntlm")
+
+    @use_ntlm.setter
+    def use_ntlm(self, value: bool) -> None:
+        self._update_setting("use_ntlm", value)
+
+    @property
+    def root_dn(self) -> str:
+        return self._get_setting("root_dn")
+
+    @root_dn.setter
+    def root_dn(self, value: str) -> None:
+        self._update_setting("root_dn", value)
+
+    @property
+    def user_search_base(self) -> str:
+        return self._get_setting("user_search_base")
+
+    @user_search_base.setter
+    def user_search_base(self, value: str) -> None:
+        self._update_setting("user_search_base", value)
+
+    @property
+    def user_search_filter(self) -> str:
+        return self._get_setting("user_search_filter")
+
+    @user_search_filter.setter
+    def user_search_filter(self, value: str) -> None:
+        self._update_setting("user_search_filter", value)
+
+    @property
+    def admin_search_filter(self) -> str:
+        return self._get_setting("admin_search_filter")
+
+    @admin_search_filter.setter
+    def admin_search_filter(self, value: str) -> None:
+        self._update_setting("admin_search_filter", value)
+
+    @property
+    def group_search_base(self) -> str:
+        return self._get_setting("group_search_base")
+
+    @group_search_base.setter
+    def group_search_base(self, value: str) -> None:
+        self._update_setting("group_search_base", value)
+
+    @property
+    def group_search_filter(self) -> str:
+        return self._get_setting("group_search_filter")
+
+    @group_search_filter.setter
+    def group_search_filter(self, value: str) -> None:
+        self._update_setting("group_search_filter", value)
+
+    @property
+    def group_via_user(self) -> bool:
+        return self._get_setting("group_via_user")
+
+    @group_via_user.setter
+    def group_via_user(self, value: bool) -> None:
+        self._update_setting("group_via_user", value)
+
+    @property
+    def group_user_attribute(self) -> str:
+        return self._get_setting("group_user_attribute")
+
+    @group_user_attribute.setter
+    def group_user_attribute(self, value: str) -> None:
+        self._update_setting("group_user_attribute", value)
+
+    @property
+    def group_membership_filter(self) -> str:
+        return self._get_setting("group_membership_filter")
+
+    @group_membership_filter.setter
+    def group_membership_filter(self, value: str) -> None:
+        self._update_setting("group_membership_filter", value)
+
+    @property
+    def manager_dn(self) -> str:
+        return self._get_setting("manager_dn")
+
+    @manager_dn.setter
+    def manager_dn(self, value: str) -> None:
+        self._update_setting("manager_dn", value)
+
+    def manager_password(self, value: str) -> None:
+        self._update_setting("display_attribute", value)
+
+    # manager password can't be retrieved, only set, so we only provide a setter
+    manager_password = property(fset=manager_password)
+
+    @property
+    def display_attribute(self) -> str:
+        return self._get_setting("display_attribute")
+
+    @display_attribute.setter
+    def display_attribute(self, value: str) -> None:
+        self._update_setting("display_attribute", value)
+
+    @property
+    def email_address_attribute(self) -> str:
+        return self._get_setting("email_address_attribute")
+
+    @email_address_attribute.setter
+    def email_address_attribute(self, value: str) -> None:
+        self._update_setting("email_address_attribute", value)
+
+    @property
+    def resource_pool(self) -> str:
+        return self._get_setting("resource_pool")
+
+    @resource_pool.setter
+    def resource_pool(self, value: str | ResourcePool) -> None:
+        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 b4c4d5a..ef89e66 100644
--- a/virl2_client/models/authentication.py
+++ b/virl2_client/models/authentication.py
@@ -23,6 +23,7 @@
 import json
 import logging
 from typing import TYPE_CHECKING, Generator, Optional
+from uuid import uuid4
 
 import httpx
 
@@ -123,4 +124,5 @@ def make_session(base_url: str, ssl_verify: bool = True) -> httpx.Client:
         auth=BlankAuth(),
         follow_redirects=True,
         timeout=None,
+        headers={"X-Client-UUID": str(uuid4())},
     )
diff --git a/virl2_client/models/cl_pyats.py b/virl2_client/models/cl_pyats.py
index add9f74..c40df41 100644
--- a/virl2_client/models/cl_pyats.py
+++ b/virl2_client/models/cl_pyats.py
@@ -24,6 +24,8 @@
 from typing import TYPE_CHECKING, Any, Optional
 
 if TYPE_CHECKING:
+    from pyats.topology import Device
+
     from .lab import Lab
 
 
@@ -42,8 +44,8 @@ def __init__(self, lab: Lab, hostname: Optional[str] = None) -> None:
         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
+        :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
         """
@@ -59,6 +61,15 @@ def __init__(self, lab: Lab, hostname: Optional[str] = None) -> None:
         self._testbed: Any = None
         self._connections: list[Any] = []
 
+    @property
+    def hostname(self) -> Optional[str]:
+        """Return forced hostname/ip and port terminal server setting"""
+        return self._hostname
+
+    @hostname.setter
+    def hostname(self, hostname: Optional[str] = None) -> None:
+        self._hostname = hostname
+
     def _check_pyats_installed(self) -> None:
         if not self._pyats_installed:
             raise PyatsNotInstalled
@@ -82,16 +93,8 @@ def sync_testbed(self, username: str, password: str) -> None:
         data.devices.terminal_server.credentials.default.password = password
         self._testbed = data
 
-    def run_command(self, node_label: str, command: str) -> 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
-        :returns: The output from the device
-        """
+    def _prepare_device(self, node_label: str) -> Device:
         self._check_pyats_installed()
-
         try:
             pyats_device = self._testbed.devices[node_label]
         except KeyError:
@@ -101,29 +104,69 @@ def run_command(self, node_label: str, command: str) -> str:
         # TODO: later look at pooling connections
         pyats_device.connect(log_stdout=False, learn_hostname=True)
         self._connections.append(pyats_device)
-        return pyats_device.execute(command, log_stdout=False)
+        return pyats_device
+
+    def _prepare_params(
+        self,
+        init_exec_commands: Optional[list] = None,
+        init_config_commands: Optional[list] = 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.
 
-    def run_config_command(self, node_label: str, command: str) -> str:
+        :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
+        """
+        params = {}
+        if init_exec_commands:
+            params["init_exec_commands"] = init_exec_commands
+        if init_config_commands:
+            params["init_config_commands"] = init_config_commands
+        return params
+
+    def run_command(
+        self,
+        node_label: str,
+        command: str,
+        init_exec_commands: Optional[list] = None,
+        init_config_commands: Optional[list] = 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
+        """
+        pyats_device = self._prepare_device(node_label)
+        params = self._prepare_params(init_exec_commands, init_config_commands)
+        return pyats_device.execute(command, log_stdout=False, **params)
+
+    def run_config_command(
+        self,
+        node_label: str,
+        command: str,
+        init_exec_commands: Optional[list] = None,
+        init_config_commands: Optional[list] = None,
+    ) -> str:
         """
         Run a command on the device in `configure` mode. pyATS
         handles the change into `configure` mode automatically.
 
         :param node_label: the label / title of the device
         :param command: the command to be run in exec mode
-        :returns: The output from the device
+        :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
         """
-        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.configure(command, log_stdout=False)
+        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)
 
     def cleanup(self) -> None:
         """Cleans up the pyATS connections."""
diff --git a/virl2_client/models/configuration.py b/virl2_client/models/configuration.py
new file mode 100644
index 0000000..79d8fd3
--- /dev/null
+++ b/virl2_client/models/configuration.py
@@ -0,0 +1,102 @@
+"""
+https://github.com/CiscoDevNet/virlutils/blob/master/virl/api/credentials.py
+
+Collection of utility classes to make getting credentials and
+configuration easier.
+"""
+from __future__ import annotations
+
+import getpass
+import os
+from pathlib import Path
+
+from virl2_client.exceptions import InitializationError
+
+_CONFIG_FILE_NAME = ".virlrc"
+
+
+def _get_from_file(virlrc_parent: Path, prop_name: str) -> str | None:
+    virlrc = virlrc_parent / _CONFIG_FILE_NAME
+    if virlrc.is_file():
+        with virlrc.open() as fh:
+            config = fh.readlines()
+
+        for line in config:
+            if line.startswith(prop_name):
+                prop = line.split("=")[1].strip()
+                if prop.startswith('"') and prop.endswith('"'):
+                    prop = prop[1:-1]
+                return prop
+    return 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
+
+    """
+    # check for .virlrc in current directory
+    cwd = Path.cwd()
+    if prop := _get_from_file(cwd, prop_name):
+        return prop
+
+    # search up directory tree for a .virlrc
+    for path in cwd.parents:
+        if prop := _get_from_file(path, prop_name):
+            return prop
+
+    # try environment next
+    prop = os.getenv(prop_name, None)
+    if prop:
+        return prop
+
+    # check for .virlrc in home directory
+    prop = _get_from_file(Path.home(), prop_name)
+
+    return prop or None
+
+
+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)
+    """
+    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"
+        raise InitializationError(message)
+
+    if not (
+        username
+        or (username := _get_prop("VIRL2_USER") or _get_prop("VIRL_USERNAME"))
+        or (username := input("Please enter your VIRL username: "))
+    ):
+        message = "no username provided"
+        raise InitializationError(message)
+
+    if not (
+        password
+        or (password := _get_prop("VIRL2_PASS") or _get_prop("VIRL_PASSWORD"))
+        or (password := getpass.getpass("Please enter your password: "))
+    ):
+        message = "no password provided"
+        raise InitializationError(message)
+
+    if ssl_verify is True:
+        ssl_verify = _get_prop("CA_BUNDLE") or _get_prop("CML_VERIFY_CERT")
+
+    return host, username, password, ssl_verify
diff --git a/virl2_client/models/groups.py b/virl2_client/models/groups.py
index cd6a0ea..8132c61 100644
--- a/virl2_client/models/groups.py
+++ b/virl2_client/models/groups.py
@@ -21,6 +21,7 @@
 from __future__ import annotations
 
 from typing import TYPE_CHECKING, Optional
+from ..utils import _UNCHANGED
 
 if TYPE_CHECKING:
     import httpx
@@ -90,9 +91,9 @@ def update_group(
         self,
         group_id: str,
         name: Optional[str] = None,
-        description: Optional[str] = None,
-        members: Optional[list[str]] = None,
-        labs: Optional[list[dict[str, str]]] = None,
+        description: Optional[str] = _UNCHANGED,
+        members: Optional[list[str]] = _UNCHANGED,
+        labs: Optional[list[dict[str, str]]] = _UNCHANGED,
     ) -> dict:
         """
         Updates a group.
@@ -107,11 +108,11 @@ def update_group(
         data: dict[str, str | list] = {}
         if name is not None:
             data["name"] = name
-        if description is not None:
+        if description is not _UNCHANGED:
             data["description"] = description
-        if members is not None:
+        if members is not _UNCHANGED:
             data["members"] = members
-        if labs is not None:
+        if labs is not _UNCHANGED:
             data["labs"] = labs
         url = self.base_url + "/{}".format(group_id)
         return self._session.patch(url, json=data).json()
diff --git a/virl2_client/models/interface.py b/virl2_client/models/interface.py
index f01ef31..8c4eb54 100644
--- a/virl2_client/models/interface.py
+++ b/virl2_client/models/interface.py
@@ -25,6 +25,9 @@
 from functools import total_ordering
 from typing import TYPE_CHECKING, Optional
 
+from ..utils import check_stale
+from ..utils import property_s as property
+
 if TYPE_CHECKING:
     import httpx
 
@@ -53,13 +56,14 @@ def __init__(
         :param slot: the slot of the interface
         :param iface_type: the type of the interface, defaults to "physical"
         """
-        self.id = iid
-        self.node = node
-        self.type = iface_type
-        self.label = label
-        self.slot = slot
+        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._stale = False
         self.statistics = {
             "readbytes": 0,
             "readpackets": 0,
@@ -75,28 +79,28 @@ def __init__(
     def __eq__(self, other):
         if not isinstance(other, Interface):
             return False
-        return self.id == other.id
+        return self._id == other._id
 
     def __lt__(self, other):
         if not isinstance(other, Interface):
             return False
-        return int(self.id) < int(other.id)
+        return self._id < other._id
 
     def __str__(self):
-        return "Interface: {}".format(self.label)
+        return f"Interface: {self._label}{' (STALE)' if self._stale else ''}"
 
     def __repr__(self):
         return "{}({!r}, {!r}, {!r}, {!r}, {!r})".format(
             self.__class__.__name__,
-            self.id,
-            self.node,
-            self.label,
-            self.slot,
-            self.type,
+            self._id,
+            self._node,
+            self._label,
+            self._slot,
+            self._type,
         )
 
     def __hash__(self):
-        return hash(self.id)
+        return hash(self._id)
 
     @property
     def lab_base_url(self) -> str:
@@ -106,6 +110,26 @@ def lab_base_url(self) -> str:
     def _base_url(self) -> str:
         return self.lab_base_url + "/interfaces/{}".format(self.id)
 
+    @property
+    def id(self) -> str:
+        return self._id
+
+    @property
+    def node(self) -> Node:
+        return self._node
+
+    @property
+    def type(self) -> str:
+        return self._type
+
+    @property
+    def label(self) -> str:
+        return self._label
+
+    @property
+    def slot(self) -> Optional[int]:
+        return self._slot
+
     @property
     def physical(self) -> bool:
         """Whether the interface is physical."""
@@ -120,6 +144,9 @@ def connected(self) -> bool:
     @property
     def state(self) -> Optional[str]:
         self.node.lab.sync_states_if_outdated()
+        if self._state is None:
+            url = self._base_url + "/state"
+            self._state = self._session.get(url).json()["state"]
         return self._state
 
     @property
@@ -202,16 +229,29 @@ def get_link_to(self, other_interface: Interface) -> Optional[Link]:
             return link
         return None
 
-    def remove_on_server(self) -> None:
-        _LOGGER.info("Removing interface %s", self)
+    def remove(self) -> None:
+        self.node.lab.remove_interface(self)
 
+    @check_stale
+    def _remove_on_server(self) -> None:
+        _LOGGER.info("Removing interface %s", self)
         url = self._base_url
         self._session.delete(url)
 
+    def remove_on_server(self) -> None:
+        warnings.warn(
+            "'Interface.remove_on_server()' is deprecated, "
+            "use 'Interface.remove()' instead.",
+            DeprecationWarning,
+        )
+        self._remove_on_server()
+
+    @check_stale
     def bring_up(self) -> None:
         url = self._base_url + "/state/start"
         self._session.put(url)
 
+    @check_stale
     def shutdown(self) -> None:
         url = self._base_url + "/state/stop"
         self._session.put(url)
diff --git a/virl2_client/models/lab.py b/virl2_client/models/lab.py
index c07bd44..814c6a4 100644
--- a/virl2_client/models/lab.py
+++ b/virl2_client/models/lab.py
@@ -36,6 +36,8 @@
     NodeNotFound,
     VirlException,
 )
+from ..utils import check_stale, locked
+from ..utils import property_s as property
 from .cl_pyats import ClPyats
 from .interface import Interface
 from .link import Link
@@ -90,9 +92,10 @@ def __init__(
         self._title = title
         self._description = ""
         self._notes = ""
-        self._lab_id = lab_id
+        self._id = lab_id
         self._session = session
         self._owner = username
+        self._state = None
         self._nodes: dict[str, Node] = {}
         """
         Dictionary containing all nodes in the lab.
@@ -132,18 +135,19 @@ def __init__(
             )
         self._resource_pool_manager = resource_pool_manager
         self._resource_pools = []
+        self._stale = False
 
     def __len__(self):
         return len(self._nodes)
 
     def __str__(self):
-        return "Lab: {}".format(self._title)
+        return f"Lab: {self._title}{' (STALE)' if self._stale else ''}"
 
     def __repr__(self):
         return "{}({!r}, {!r}, {!r}, {!r}, {!r}, {!r})".format(
             self.__class__.__name__,
             self._title,
-            self._lab_id,
+            self._id,
             self._session.base_url.path,
             self.auto_sync,
             self.auto_sync_interval,
@@ -157,6 +161,8 @@ def need_to_wait(self, local_wait: Optional[bool]) -> bool:
             raise ValueError
         return local_wait
 
+    @check_stale
+    @locked
     def sync_statistics_if_outdated(self) -> None:
         timestamp = time.time()
         if (
@@ -165,6 +171,8 @@ def sync_statistics_if_outdated(self) -> None:
         ):
             self.sync_statistics()
 
+    @check_stale
+    @locked
     def sync_states_if_outdated(self) -> None:
         timestamp = time.time()
         if (
@@ -173,6 +181,8 @@ def sync_states_if_outdated(self) -> None:
         ):
             self.sync_states()
 
+    @check_stale
+    @locked
     def sync_l3_addresses_if_outdated(self) -> None:
         timestamp = time.time()
         if (
@@ -181,6 +191,8 @@ def sync_l3_addresses_if_outdated(self) -> None:
         ):
             self.sync_layer3_addresses()
 
+    @check_stale
+    @locked
     def sync_topology_if_outdated(self) -> None:
         timestamp = time.time()
         if (
@@ -189,6 +201,8 @@ def sync_topology_if_outdated(self) -> None:
         ):
             self._sync_topology(exclude_configurations=True)
 
+    @check_stale
+    @locked
     def sync_operational_if_outdated(self) -> None:
         timestamp = time.time()
         if (
@@ -205,7 +219,7 @@ def id(self) -> str:
         :returns: The Lab ID
         """
 
-        return self._lab_id
+        return self._id
 
     @property
     def title(self) -> Optional[str]:
@@ -224,9 +238,7 @@ def title(self, value: str) -> None:
 
         :param value: The new lab title
         """
-        url = self.lab_base_url
-        self._session.patch(url, json={"title": value})
-        self._title = value
+        self._set_property("title", value)
 
     @property
     def notes(self) -> str:
@@ -245,9 +257,7 @@ def notes(self, value: str) -> None:
 
         :param value: The new lab notes
         """
-        url = self.lab_base_url
-        self._session.patch(url, json={"notes": value})
-        self._notes = value
+        self._set_property("notes", value)
 
     @property
     def description(self) -> str:
@@ -266,9 +276,14 @@ def description(self, value: str) -> None:
 
         :param value: The new lab description
         """
+        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={"description": value})
-        self._description = value
+        self._session.patch(url, json={prop: value})
+        setattr(self, f"_{prop}", value)
 
     @property
     def session(self) -> httpx.Client:
@@ -323,9 +338,10 @@ def interfaces(self) -> list[Interface]:
 
     @property
     def lab_base_url(self) -> str:
-        return "labs/{}".format(self._lab_id)
+        return "labs/{}".format(self._id)
 
     @property
+    @locked
     def statistics(self) -> dict:
         """
         Returns some statistics about the lab.
@@ -448,6 +464,8 @@ def find_nodes_by_tag(self, tag: str) -> list[Node]:
         self.sync_topology_if_outdated()
         return [node for node in self.nodes() if tag in node.tags()]
 
+    @check_stale
+    @locked
     def create_node(
         self,
         label: str,
@@ -496,10 +514,20 @@ def create_node(
         if "compute_id" in kwargs:
             kwargs.pop("compute_id")
         kwargs["resource_pool"] = None
-        node = self.add_node_local(node_id, **kwargs)
+        node = self._create_node_local(node_id, **kwargs)
         return node
 
-    def add_node_local(
+    def add_node_local(self, *args, **kwargs):
+        warnings.warn(
+            "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)
+
+    @locked
+    def _create_node_local(
         self,
         node_id: str,
         label: str,
@@ -547,40 +575,45 @@ def add_node_local(
         self._nodes[node.id] = node
         return node
 
-    def remove_node(self, node: Node, wait: Optional[bool] = None) -> None:
+    @check_stale
+    @locked
+    def remove_node(self, node: Node | str, wait: Optional[bool] = None) -> None:
         """
         Removes a node from the lab.
 
-        :param node: the node
+        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)
         """
-        node.remove_on_server()
-        for iface in node.interfaces():
-            if iface.link is not None:
-                try:
-                    del self._links[iface.link.id]
-                except KeyError:
-                    # element may already have been deleted on server,
-                    # and removed locally due to auto-sync
-                    pass
-            try:
-                del self._interfaces[iface.id]
-            except KeyError:
-                # element may already have been deleted on server,
-                # and removed locally due to auto-sync
-                pass
+        if isinstance(node, str):
+            node = self.get_node_by_id(node)
+        node._remove_on_server()
+        self._remove_node_local(node)
+
+        if self.need_to_wait(wait):
+            self.wait_until_lab_converged()
+        _LOGGER.debug("%s node removed from lab %s", node._id, self._id)
+
+    @locked
+    def _remove_node_local(self, node: Node) -> None:
+        """Helper function to remove a node from the client library."""
+        for iface in tuple(self._interfaces.values()):
+            if iface._node is node:
+                self._remove_interface_local(iface)
         try:
-            del self._nodes[node.id]
+            del self._nodes[node._id]
+            node._stale = True
         except KeyError:
             # element may already have been deleted on server,
             # and removed locally due to auto-sync
             pass
 
-        if self.need_to_wait(wait):
-            self.wait_until_lab_converged()
-        _LOGGER.debug("%s node removed from lab %s", node.id, self._lab_id)
-
+    @locked
     def remove_nodes(self, wait: Optional[bool] = None) -> None:
         """
         Remove all nodes from the lab.
@@ -594,67 +627,101 @@ def remove_nodes(self, wait: Optional[bool] = None) -> None:
 
         if self.need_to_wait(wait):
             self.wait_until_lab_converged()
-        _LOGGER.debug("all nodes removed from lab %s", self._lab_id)
+        _LOGGER.debug("all nodes removed from lab %s", self._id)
 
-    def remove_link(self, link: Link, wait: Optional[bool] = None) -> None:
+    @check_stale
+    @locked
+    def remove_link(self, link: Link | str, wait: Optional[bool] = None) -> None:
         """
         Removes a link from the lab.
 
-        :param link: the link
+        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)
         """
-        link.remove_on_server()
+        if isinstance(link, str):
+            link = self.get_link_by_id(link)
+        link._remove_on_server()
+        self._remove_link_local(link)
+
+        if self.need_to_wait(wait):
+            self.wait_until_lab_converged()
+        _LOGGER.debug("link %s removed from lab %s", link._id, self._id)
+
+    @locked
+    def _remove_link_local(self, link: Link) -> None:
+        """Helper function to remove a link from the client library."""
         try:
-            del self._links[link.id]
+            del self._links[link._id]
+            link._stale = True
         except KeyError:
             # element may already have been deleted on server,
             # and removed locally due to auto-sync
             pass
 
-        if self.need_to_wait(wait):
-            self.wait_until_lab_converged()
-        _LOGGER.debug("link %s removed from lab %s", link.id, self._lab_id)
-
-    def remove_interface(self, iface: Interface, wait: Optional[bool] = None) -> None:
+    @check_stale
+    @locked
+    def remove_interface(
+        self, iface: Interface | str, wait: Optional[bool] = None
+    ) -> None:
         """
         Removes an interface from the lab.
 
-        :param iface: the interface
+        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)
         """
-        iface.remove_on_server()
-        if iface.link is not None:
-            try:
-                del self._links[iface.link.id]
-            except KeyError:
-                # element may already have been deleted on server, and removed
-                # locally due to auto-sync
-                pass
+        if isinstance(iface, str):
+            iface = self.get_interface_by_id(iface)
+        iface._remove_on_server()
+
+        self._remove_interface_local(iface)
+
+        if self.need_to_wait(wait):
+            self.wait_until_lab_converged()
+        _LOGGER.debug("interface %s removed from lab %s", iface._id, self._id)
+
+    @locked
+    def _remove_interface_local(self, iface: Interface) -> None:
+        """Helper function to remove an interface from the client library."""
+        for link in tuple(self._links.values()):
+            if iface in link.interfaces:
+                self._remove_link_local(link)
+                break
         try:
-            del self._interfaces[iface.id]
+            del self._interfaces[iface._id]
+            iface._stale = True
         except KeyError:
             # element may already have been deleted on server, and removed
             # locally due to auto-sync
             pass
 
-        if self.need_to_wait(wait):
-            self.wait_until_lab_converged()
-        _LOGGER.debug("interface %s removed from lab %s", iface.id, self._lab_id)
-
+    @check_stale
+    @locked
     def create_link(
-        self, i1: Interface, i2: Interface, wait: Optional[bool] = None
+        self, i1: Interface | str, i2: Interface | str, wait: Optional[bool] = None
     ) -> Link:
         """
         Creates a link between two interfaces
 
-        :param i1: the first interface object
-        :param i2: the second interface object
+        :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
         """
+        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"
         data = {
             "src_int": i1.id,
@@ -668,9 +735,21 @@ def create_link(
         if self.need_to_wait(wait):
             self.wait_until_lab_converged()
 
-        link = self.create_link_local(i1, i2, link_id, label)
+        link = self._create_link_local(i1, i2, link_id, label)
         return link
 
+    @check_stale
+    @locked
+    def _create_link_local(
+        self, i1: Interface, i2: Interface, link_id: str, label: Optional[str] = None
+    ) -> Link:
+        """Helper function to create a link in the client library."""
+        link = Link(self, link_id, i1, i2, label)
+        self._links[link_id] = link
+        return link
+
+    @check_stale
+    @locked
     def connect_two_nodes(self, node1: Node, node2: Node) -> Link:
         """
         Convenience method to connect two nodes within a lab.
@@ -683,16 +762,10 @@ def connect_two_nodes(self, node1: Node, node2: Node) -> Link:
         iface2 = node2.next_available_interface() or node2.create_interface()
         return self.create_link(iface1, iface2)
 
-    def create_link_local(
-        self, i1: Interface, i2: Interface, link_id: str, label: Optional[str] = None
-    ) -> Link:
-        """Helper function to create a link in the client library."""
-        link = Link(self, link_id, i1, i2, label)
-        self._links[link_id] = link
-        return link
-
+    @check_stale
+    @locked
     def create_interface(
-        self, node: Node, slot: Optional[int] = None, wait: Optional[bool] = None
+        self, node: Node | str, slot: Optional[int] = None, wait: Optional[bool] = None
     ) -> Interface:
         """
         Create an interface in the next available slot, or, if a slot is specified,
@@ -704,6 +777,8 @@ def create_interface(
             the lab wait property takes precedence)
         :returns: The newly created interface
         """
+        if isinstance(node, str):
+            node = self.get_node_by_id(node)
         url = self.lab_base_url + "/interfaces"
         payload: dict[str, str | int] = {"node": node.id}
         if slot is not None:
@@ -719,7 +794,7 @@ def create_interface(
         # TODO: need to import the topology then
         desired_interface: Optional[Interface] = None
         for iface in result:
-            lab_interface = self.create_interface_local(
+            lab_interface = self._create_interface_local(
                 iface_id=iface["id"],
                 label=iface["label"],
                 node=node,
@@ -735,7 +810,9 @@ def create_interface(
 
         return desired_interface
 
-    def create_interface_local(
+    @check_stale
+    @locked
+    def _create_interface_local(
         self,
         iface_id: str,
         label: str,
@@ -748,11 +825,12 @@ def create_interface_local(
             iface = Interface(iface_id, node, label, slot, iface_type)
             self._interfaces[iface_id] = iface
         else:  # update the interface if it already exists:
-            self._interfaces[iface_id].node = node
-            self._interfaces[iface_id].label = label
-            self._interfaces[iface_id].slot = slot
-            self._interfaces[iface_id].type = iface_type
-        return self._interfaces[iface_id]
+            iface = self._interfaces[iface_id]
+            iface._node = node
+            iface._label = label
+            iface._slot = slot
+            iface._type = iface_type
+        return iface
 
     @staticmethod
     def _get_element_from_data(data: dict, element: str) -> int:
@@ -761,6 +839,8 @@ def _get_element_from_data(data: dict, element: str) -> int:
         except (TypeError, KeyError):
             return 0
 
+    @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"
@@ -795,16 +875,10 @@ def sync_statistics(self) -> None:
                 "writepackets": writepackets,
             }
 
-            iface_a = link.interface_a
-            iface_a.statistics = {
-                "readbytes": readbytes,
-                "readpackets": readpackets,
-                "writebytes": writebytes,
-                "writepackets": writepackets,
-            }
+            link.interface_a.statistics = link.statistics
+
             # reverse for other interface
-            iface_b = link.interface_b
-            iface_b.statistics = {
+            link.interface_b.statistics = {
                 "readbytes": writebytes,
                 "readpackets": writepackets,
                 "writebytes": readbytes,
@@ -813,6 +887,8 @@ def sync_statistics(self) -> None:
 
         self._last_sync_statistics_time = time.time()
 
+    @check_stale
+    @locked
     def sync_states(self) -> None:
         """
         Sync all the states of the various elements with the back end server.
@@ -820,11 +896,11 @@ def sync_states(self) -> None:
         url = self.lab_base_url + "/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
-
+            self._nodes[node_id]._state = node_state
+        ifaces = self._interfaces.copy()
         for interface_id, interface_state in states["interfaces"].items():
             try:
-                iface = self._interfaces[interface_id]
+                iface = ifaces.pop(interface_id)
             except KeyError:
                 # TODO: handle loopbacks created server-side
                 # check how the UI handles these created today
@@ -832,12 +908,14 @@ def sync_states(self) -> None:
                 pass
             else:
                 iface._state = interface_state
-
+        for stale_iface in ifaces:
+            ifaces[stale_iface]._stale = True
         for link_id, link_state in states["links"].items():
             self._links[link_id]._state = link_state
 
         self._last_sync_state_time = time.time()
 
+    @check_stale
     def wait_until_lab_converged(
         self, max_iterations: Optional[int] = None, wait_time: Optional[int] = None
     ) -> None:
@@ -846,11 +924,11 @@ def wait_until_lab_converged(
             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._lab_id)
+        _LOGGER.info("Waiting for lab %s to converge", self._id)
         for index in range(max_iter):
             converged = self.has_converged()
             if converged:
-                _LOGGER.info("Lab %s has booted", self._lab_id)
+                _LOGGER.info("Lab %s has booted", self._id)
                 return
 
             if index % 10 == 0:
@@ -872,35 +950,41 @@ def wait_until_lab_converged(
         # specified
         raise RuntimeError(msg)
 
+    @check_stale
     def has_converged(self) -> bool:
         url = self.lab_base_url + "/check_if_converged"
         converged = self._session.get(url).json()
         return converged
 
+    @check_stale
     def start(self, wait: Optional[bool] = 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)
-        :type wait: bool
         """
         url = self.lab_base_url + "/start"
         self._session.put(url)
         if self.need_to_wait(wait):
             self.wait_until_lab_converged()
-        _LOGGER.debug("started lab: %s", self._lab_id)
+        _LOGGER.debug("started lab: %s", self._id)
 
+    @check_stale
     def state(self) -> str:
         """
         Returns the state of the lab.
 
         :returns: The state as text
         """
-        url = self.lab_base_url + "/state"
-        response = self._session.get(url)
-        _LOGGER.debug("lab state: %s -> %s", self._lab_id, response.text)
-        return response.json()
+        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"
+            response = self._session.get(url)
+            self._state = response.json()
+
+        _LOGGER.debug("lab state: %s -> %s", self._id, self._state)
+        return self._state
 
     def is_active(self) -> bool:
         """
@@ -910,6 +994,7 @@ def is_active(self) -> bool:
         """
         return self.state() == "STARTED"
 
+    @check_stale
     def details(self) -> dict[str, str | list | int]:
         """
         Returns the lab details (including state) of the lab.
@@ -918,9 +1003,10 @@ def details(self) -> dict[str, str | list | int]:
         """
         url = self.lab_base_url
         response = self._session.get(url)
-        _LOGGER.debug("lab state: %s -> %s", self._lab_id, response.text)
+        _LOGGER.debug("lab state: %s -> %s", 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.
@@ -932,8 +1018,9 @@ def stop(self, wait: Optional[bool] = None) -> None:
         self._session.put(url)
         if self.need_to_wait(wait):
             self.wait_until_lab_converged()
-        _LOGGER.debug("stopped lab: %s", self._lab_id)
+        _LOGGER.debug("stopped lab: %s", self._id)
 
+    @check_stale
     def wipe(self, wait: Optional[bool] = None) -> None:
         """
         Wipe all the nodes and links in the lab.
@@ -945,7 +1032,7 @@ def wipe(self, wait: Optional[bool] = None) -> None:
         self._session.put(url)
         if self.need_to_wait(wait):
             self.wait_until_lab_converged()
-        _LOGGER.debug("wiped lab: %s", self._lab_id)
+        _LOGGER.debug("wiped lab: %s", self._id)
 
     def remove(self) -> None:
         """
@@ -957,28 +1044,38 @@ def remove(self) -> None:
             lab.remove()
 
         """
-        # TODO: mark as disconnected
+        self._remove_on_server()
+        self._stale = True
+
+    @check_stale
+    def _remove_on_server(self):
         url = self.lab_base_url
         response = self._session.delete(url)
         _LOGGER.debug("removed lab: %s", response.text)
 
-    def sync_events(self) -> None:
-        # TODO: return a boolean if events have changed since last run
+    @check_stale
+    @locked
+    def sync_events(self) -> bool:
         url = self.lab_base_url + "/events"
         result = self._session.get(url).json()
+        changed = self.events != result
         self.events = result
+        return changed
 
+    @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.
         """
-        url = "build_configurations?lab_id=" + self._lab_id
+        url = "build_configurations?lab_id=" + self._id
         self._session.get(url)
         # sync to get the updated configs
         self.sync_topology_if_outdated()
 
+    @check_stale
+    @locked
     def sync(
         self,
         topology_only=True,
@@ -1012,6 +1109,7 @@ def sync(
             self.sync_layer3_addresses()
             self.sync_operational()
 
+    @locked
     def _sync_topology(self, exclude_configurations=False) -> None:
         """Helper function to sync topologies from the backend server."""
         # TODO: check what happens if call twice
@@ -1028,6 +1126,11 @@ def _sync_topology(self, exclude_configurations=False) -> None:
                 # response.text was empty, not a JSON object, or not the expected
                 # JSON schema. Use the raw result text.
                 pass
+            if (
+                exc.response.status_code == 404
+                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?
         topology = result.json()
@@ -1038,6 +1141,7 @@ def _sync_topology(self, exclude_configurations=False) -> None:
             self._initialized = True
         self._last_sync_topology_time = time.time()
 
+    @locked
     def import_lab(self, topology: dict) -> None:
         self._import_lab(topology)
 
@@ -1045,6 +1149,7 @@ def import_lab(self, topology: dict) -> None:
         self._handle_import_interfaces(topology)
         self._handle_import_links(topology)
 
+    @locked
     def _import_lab(self, topology: dict[str, Any]) -> None:
         """Replaces lab properties. Will raise KeyError if any property is missing."""
         lab_dict = topology.get("lab")
@@ -1064,6 +1169,7 @@ def _import_lab(self, topology: dict[str, Any]) -> None:
             self._notes = lab_dict["notes"]
             self._owner = lab_dict.get("owner", self.username)
 
+    @locked
     def _handle_import_nodes(self, topology: dict) -> None:
         for node in topology["nodes"]:
             node_id = node["id"]
@@ -1081,6 +1187,7 @@ def _handle_import_nodes(self, topology: dict) -> None:
                     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"]:
@@ -1090,6 +1197,7 @@ def _handle_import_interfaces(self, topology: dict) -> None:
                     raise ElementAlreadyExists("Interface already exists")
                 self._import_interface(iface_id, node_id, iface)
 
+    @locked
     def _handle_import_links(self, topology: dict) -> None:
         for link in topology["links"]:
             link_id = link["id"]
@@ -1100,6 +1208,7 @@ def _handle_import_links(self, topology: dict) -> None:
             label = link.get("label")
             self._import_link(link_id, iface_b_id, iface_a_id, label)
 
+    @locked
     def _import_link(
         self,
         link_id: str,
@@ -1109,8 +1218,9 @@ def _import_link(
     ) -> Link:
         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)
+        return self._create_link_local(iface_a, iface_b, link_id, label)
 
+    @locked
     def _import_interface(
         self, iface_id: str, node_id: str, iface_data: dict
     ) -> Interface:
@@ -1120,18 +1230,25 @@ def _import_interface(
         slot = iface_data.get("slot")
         iface_type = iface_data["type"]
         node = self._nodes[node_id]
-        return self.create_interface_local(iface_id, label, node, slot, iface_type)
+        return self._create_interface_local(iface_id, label, node, slot, iface_type)
 
+    @locked
     def _import_node(self, node_id: str, node_data: dict) -> Node:
         if "data" in node_data:
             node_data = node_data["data"]
         node_data.pop("id", None)
-        node_data.pop("state", None)
+        state = node_data.pop("state", None)
+        node_data.pop("lab_id", None)
+        node_data.pop("boot_progress", None)
+
         for key in ("image_definition", "configuration"):
             if key not in node_data:
                 node_data[key] = None
-        return self.add_node_local(node_id, **node_data)
+        node = self._create_node_local(node_id, **node_data)
+        node._state = state
+        return node
 
+    @locked
     def update_lab(self, topology: dict, exclude_configurations: bool) -> None:
         self._import_lab(topology)
 
@@ -1173,6 +1290,7 @@ def update_lab(self, topology: dict, exclude_configurations: bool) -> None:
 
         self._update_elements(topology, kept_nodes, exclude_configurations)
 
+    @locked
     def _remove_elements(
         self,
         removed_nodes: Iterable[str],
@@ -1180,20 +1298,21 @@ def _remove_elements(
         removed_interfaces: Iterable[str],
     ) -> None:
         for link_id in removed_links:
-            link = self._links[link_id]
+            link = self._links.pop(link_id)
             _LOGGER.info("Removed link %s", link)
-            del self._links[link_id]
+            link._stale = True
 
         for interface_id in removed_interfaces:
-            interface = self._interfaces[interface_id]
+            interface = self._interfaces.pop(interface_id)
             _LOGGER.info("Removed interface %s", interface)
-            del self._interfaces[interface_id]
+            interface._stale = True
 
         for node_id in removed_nodes:
-            node = self._nodes[node_id]
+            node = self._nodes.pop(node_id)
             _LOGGER.info("Removed node %s", node)
-            del self._nodes[node_id]
+            node._stale = True
 
+    @locked
     def _add_elements(
         self,
         topology: dict,
@@ -1232,6 +1351,7 @@ def _add_elements(
             link = self._import_link(link_id, iface_b_id, iface_a_id, label)
             _LOGGER.info("Added link %s", link)
 
+    @locked
     def _update_elements(
         self, topology: dict, kept_nodes: Iterable[str], exclude_configurations: bool
     ) -> None:
@@ -1248,6 +1368,16 @@ def _update_elements(
         # for link_id in kept_links:
         #     link_data = self._find_link_in_topology(link_id, topology)
 
+    @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?
+        self._title = properties.get("title", self._title)
+        self._description = properties.get("description", self._description)
+        self._notes = properties.get("notes", self._notes)
+        self._owner = properties.get("owner", self._owner)
+
     @staticmethod
     def _find_link_in_topology(link_id: str, topology: dict) -> dict:
         for link in topology["links"]:
@@ -1273,6 +1403,7 @@ def _find_node_in_topology(node_id: str, topology: dict) -> dict:
         # if it cannot be found, it is an internal structure error
         raise NodeNotFound
 
+    @check_stale
     def get_pyats_testbed(self, hostname: Optional[str] = None) -> str:
         """
         Return lab's pyATS YAML testbed. Example usage::
@@ -1299,9 +1430,16 @@ def get_pyats_testbed(self, hostname: Optional[str] = None) -> str:
         result = self._session.get(url, params=params)
         return result.text
 
+    @check_stale
     def sync_pyats(self) -> None:
         self.pyats.sync_testbed(self.username, self.password)
 
+    def cleanup_pyats_connections(self) -> None:
+        """Closes and cleans 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"
@@ -1312,10 +1450,7 @@ def sync_layer3_addresses(self) -> None:
             node.map_l3_addresses_to_interfaces(mapping)
         self._last_sync_l3_address_time = time.time()
 
-    def cleanup_pyats_connections(self) -> None:
-        """Closes and cleans up connection that pyATS might still hold."""
-        self.pyats.cleanup()
-
+    @check_stale
     def download(self) -> str:
         """
         Download the lab from the server in YAML format.
@@ -1335,6 +1470,7 @@ def groups(self) -> list[dict[str, str]]:
         url = self.lab_base_url + "/groups"
         return self._session.get(url).json()
 
+    @check_stale
     def update_lab_groups(
         self, group_list: list[dict[str, str]]
     ) -> list[dict[str, str]]:
@@ -1347,6 +1483,36 @@ def update_lab_groups(
         url = self.lab_base_url + "/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
+
+        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"
+        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
+
+        Accepts a list of mappings, each with a key to add or modify,
+        and the associated device name (bridge) of the controller host.
+        If no running external connector node uses this key, the device_name
+        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.
+        """
+        url = self.lab_base_url + "/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"
         response = self._session.get(url).json()
diff --git a/virl2_client/models/link.py b/virl2_client/models/link.py
index 49f829c..a5eb188 100644
--- a/virl2_client/models/link.py
+++ b/virl2_client/models/link.py
@@ -22,9 +22,13 @@
 
 import logging
 import time
+import warnings
 from functools import total_ordering
 from typing import TYPE_CHECKING, Optional
 
+from ..utils import check_stale, locked
+from ..utils import property_s as property
+
 if TYPE_CHECKING:
     import httpx
 
@@ -55,13 +59,14 @@ def __init__(
         :param iface_b: the second interface of the link
         :param label: the link label
         """
-        self.id = lid
-        self.interface_a = iface_a
-        self.interface_b = iface_b
+        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._stale = False
         self.statistics = {
             "readbytes": 0,
             "readpackets": 0,
@@ -69,9 +74,51 @@ def __init__(
             "writepackets": 0,
         }
 
+    def __str__(self):
+        return f"Link: {self._label}{' (STALE)' if self._stale else ''}"
+
+    def __repr__(self):
+        return "{}({!r}, {!r}, {!r}, {!r}, {!r})".format(
+            self.__class__.__name__,
+            str(self.lab),
+            self._id,
+            self._interface_a,
+            self._interface_b,
+            self._label,
+        )
+
+    def __eq__(self, other: object):
+        if not isinstance(other, Link):
+            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)
+
     @property
+    def id(self):
+        return self._id
+
+    @property
+    def interface_a(self):
+        return self._interface_a
+
+    @property
+    def interface_b(self):
+        return self._interface_b
+
+    @property
+    @locked
     def state(self) -> Optional[str]:
         self.lab.sync_states_if_outdated()
+        if self._state is None:
+            url = self.base_url
+            self._state = self._session.get(url).json()["state"]
         return self._state
 
     @property
@@ -94,32 +141,6 @@ def writepackets(self) -> int:
         self.lab.sync_statistics_if_outdated()
         return self.statistics["writepackets"]
 
-    def __str__(self):
-        return "Link: {}".format(self.id)
-
-    def __repr__(self):
-        return "{}({!r}, {!r}, {!r}, {!r}, {!r})".format(
-            self.__class__.__name__,
-            str(self.lab),
-            self.id,
-            self.interface_a,
-            self.interface_b,
-            self.label,
-        )
-
-    def __eq__(self, other: object):
-        if not isinstance(other, Link):
-            return False
-        return self.id == other.id
-
-    def __lt__(self, other: object):
-        if not isinstance(other, Link):
-            return False
-        return int(self.id) < int(other.id)
-
-    def __hash__(self):
-        return hash(self.id)
-
     @property
     def node_a(self) -> Node:
         self.lab.sync_topology_if_outdated()
@@ -131,12 +152,14 @@ def node_b(self) -> Node:
         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 self.node_a, self.node_b
 
     @property
+    @locked
     def interfaces(self) -> tuple[Interface, Interface]:
         self.lab.sync_topology_if_outdated()
         return self.interface_a, self.interface_b
@@ -146,6 +169,7 @@ def label(self) -> Optional[str]:
         self.lab.sync_topology_if_outdated()
         return self._label
 
+    @locked
     def as_dict(self) -> dict[str, str]:
         return {
             "id": self.id,
@@ -161,11 +185,22 @@ def lab_base_url(self) -> str:
     def base_url(self) -> str:
         return self.lab_base_url + "/links/{}".format(self.id)
 
-    def remove_on_server(self) -> None:
+    def remove(self):
+        self.lab.remove_link(self)
+
+    @check_stale
+    def _remove_on_server(self) -> None:
         _LOGGER.info("Removing link %s", self)
         url = self.base_url
         self._session.delete(url)
 
+    def remove_on_server(self) -> None:
+        warnings.warn(
+            "'Link.remove_on_server()' is deprecated, use 'Link.remove()' instead.",
+            DeprecationWarning,
+        )
+        self._remove_on_server()
+
     def wait_until_converged(
         self, max_iterations: Optional[int] = None, wait_time: Optional[int] = None
     ) -> None:
@@ -199,23 +234,27 @@ def wait_until_converged(
         # specified
         raise RuntimeError(msg)
 
+    @check_stale
     def has_converged(self) -> bool:
         url = self.base_url + "/check_if_converged"
         converged = self._session.get(url).json()
         return converged
 
+    @check_stale
     def start(self, wait: Optional[bool] = None) -> None:
         url = self.base_url + "/state/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"
         self._session.put(url)
         if self.lab.need_to_wait(wait):
             self.wait_until_converged()
 
+    @check_stale
     def set_condition(
         self, bandwidth: int, latency: int, jitter: int, loss: float
     ) -> None:
@@ -223,13 +262,9 @@ def set_condition(
         Applies conditioning to this link.
 
         :param bandwidth: desired bandwidth, 0-10000000 kbps
-        :type bandwidth: int
         :param latency: desired latency, 0-10000 ms
-        :type latency: int
         :param jitter: desired jitter, 0-10000 ms
-        :type jitter: int
         :param loss: desired loss, 0-100%
-        :type loss: float
         """
         url = self.base_url + "/condition"
         data = {
@@ -240,22 +275,24 @@ def set_condition(
         }
         self._session.patch(url, json=data)
 
-    def get_condition(self) -> Optional[dict]:
+    @check_stale
+    def get_condition(self) -> dict:
         """
         Retrieves the current condition on this link.
-        If there is no link condition specified, None is returned.
+        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.)
 
         :return: the applied link condition
         """
         url = self.base_url + "/condition"
         condition = self._session.get(url).json()
-        if condition is None:
-            return None
-
         keys = ["bandwidth", "latency", "jitter", "loss"]
         result = {k: v for (k, v) in condition.items() if k in keys}
         return result
 
+    @check_stale
     def remove_condition(self) -> None:
         """
         Removes link conditioning.
diff --git a/virl2_client/models/node.py b/virl2_client/models/node.py
index d92e5f4..3eab859 100644
--- a/virl2_client/models/node.py
+++ b/virl2_client/models/node.py
@@ -1,4 +1,5 @@
 #
+#
 # This file is part of VIRL 2
 # Copyright (c) 2019-2023, Cisco Systems, Inc.
 # All rights reserved.
@@ -27,6 +28,8 @@
 from typing import TYPE_CHECKING, Any, Optional
 
 from ..exceptions import InterfaceNotFound
+from ..utils import check_stale, locked
+from ..utils import property_s as property
 
 if TYPE_CHECKING:
     import httpx
@@ -83,7 +86,7 @@ def __init__(
         :param resource_pool: A resource pool ID if the node is in a resource pool
         """
         self.lab = lab
-        self.id = nid
+        self._id = nid
         self._label = label
         self._node_definition = node_definition
         self._x = x
@@ -101,6 +104,7 @@ def __init__(
         self._tags = tags
         self._compute_id: Optional[str] = None
         self._resource_pool = resource_pool
+        self._stale = False
 
         self.statistics: dict[str, int | float] = {
             "cpu_usage": 0,
@@ -109,7 +113,7 @@ def __init__(
         }
 
     def __str__(self):
-        return "Node: {}".format(self._label)
+        return f"Node: {self._label}{' (STALE)' if self._stale else ''}"
 
     def __repr__(self):
         return (
@@ -117,7 +121,7 @@ def __repr__(self):
             "{!r}, {!r}, {!r}, {!r}, {!r}, {!r}, {!r}, {!r})".format(
                 self.__class__.__name__,
                 str(self.lab),
-                self.id,
+                self._id,
                 self._label,
                 self._node_definition,
                 self._image_definition,
@@ -137,17 +141,18 @@ def __repr__(self):
     def __eq__(self, other):
         if not isinstance(other, Node):
             return False
-        return self.id == other.id
+        return self._id == other._id
 
     def __lt__(self, other):
         if not isinstance(other, Node):
             return False
-        return int(self.id) < int(other.id)
+        return self._id < other._id
 
     def __hash__(self):
-        return hash(self.id)
+        return hash(self._id)
 
     @property
+    @locked
     def state(self) -> Optional[str]:
         self.lab.sync_states_if_outdated()
         if self._state is None:
@@ -155,18 +160,19 @@ def state(self) -> Optional[str]:
             self._state = self._session.get(url).json()["state"]
         return self._state
 
-    @state.setter
-    def state(self, value: Optional[str]) -> None:
-        self._state = value
-
+    @check_stale
+    @locked
     def interfaces(self) -> list[Interface]:
         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 [iface for iface in self.interfaces() if iface.physical]
 
+    @check_stale
+    @locked
     def create_interface(
         self, slot: Optional[int] = None, wait: bool = False
     ) -> Interface:
@@ -177,10 +183,10 @@ def create_interface(
         :param slot: (optional)
         :param wait: Wait for the creation
         :returns: The newly created interface
-        :rtype: models.Interface
         """
         return self.lab.create_interface(self, slot, wait=wait)
 
+    @locked
     def next_available_interface(self) -> Optional[Interface]:
         """
         Returns the next available physical interface on this node.
@@ -190,13 +196,13 @@ def next_available_interface(self) -> Optional[Interface]:
         to connect to other nodes!
 
         :returns: an interface or None, if all existing ones are connected
-        :rtype: models.Interface
         """
         for iface in self.interfaces():
             if not iface.connected and iface.physical:
                 return iface
         return None
 
+    @locked
     def peer_interfaces(self) -> list[Interface]:
         peer_ifaces = []
         for iface in self.interfaces():
@@ -205,24 +211,32 @@ def peer_interfaces(self) -> list[Interface]:
                 peer_ifaces.append(peer_iface)
         return peer_ifaces
 
+    @locked
     def peer_nodes(self) -> list[Node]:
         return list({iface.node for iface in self.peer_interfaces()})
 
+    @locked
     def links(self) -> list[Link]:
         return list(
             {link for iface in self.interfaces() if (link := iface.link) is not None}
         )
 
+    @locked
     def degree(self) -> int:
         self.lab.sync_topology_if_outdated()
         return len(self.links())
 
+    @property
+    def id(self) -> str:
+        return self._id
+
     @property
     def label(self) -> str:
         self.lab.sync_topology_if_outdated()
         return self._label
 
     @label.setter
+    @locked
     def label(self, value: str) -> None:
         self._set_node_property("label", value)
         self._label = value
@@ -233,6 +247,7 @@ def x(self) -> int:
         return self._x
 
     @x.setter
+    @locked
     def x(self, value: int) -> None:
         self._set_node_property("x", value)
         self._x = value
@@ -243,6 +258,7 @@ def y(self) -> int:
         return self._y
 
     @y.setter
+    @locked
     def y(self, value: int) -> None:
         self._set_node_property("y", value)
         self._y = value
@@ -253,6 +269,7 @@ def ram(self) -> int:
         return self._ram
 
     @ram.setter
+    @locked
     def ram(self, value: int) -> None:
         self._set_node_property("ram", value)
         self._ram = value
@@ -263,6 +280,7 @@ def cpus(self) -> int:
         return self._cpus
 
     @cpus.setter
+    @locked
     def cpus(self, value: int) -> None:
         self._set_node_property("cpus", value)
         self._cpus = value
@@ -273,6 +291,7 @@ def cpu_limit(self) -> int:
         return self._cpu_limit
 
     @cpu_limit.setter
+    @locked
     def cpu_limit(self, value: int) -> None:
         self._set_node_property("cpu_limit", value)
         self._cpu_limit = value
@@ -283,6 +302,7 @@ def data_volume(self) -> int:
         return self._data_volume
 
     @data_volume.setter
+    @locked
     def data_volume(self, value: int) -> None:
         self._set_node_property("data_volume", value)
         self._data_volume = value
@@ -303,6 +323,7 @@ def boot_disk_size(self) -> int:
         return self._boot_disk_size
 
     @boot_disk_size.setter
+    @locked
     def boot_disk_size(self, value: int) -> None:
         self._set_node_property("boot_disk_size", value)
         self._boot_disk_size = value
@@ -323,6 +344,7 @@ def config(self) -> Optional[str]:
         return self.configuration
 
     @config.setter
+    @locked
     def config(self, value: str) -> None:
         warnings.warn(CONFIG_WARNING, DeprecationWarning)
         self.configuration = value
@@ -333,6 +355,7 @@ def image_definition(self) -> Optional[str]:
         return self._image_definition
 
     @image_definition.setter
+    @locked
     def image_definition(self, value: str) -> None:
         self.lab.sync_topology_if_outdated()
         self._set_node_property("image_definition", value)
@@ -376,12 +399,14 @@ def disk_write(self) -> int:
         self.lab.sync_statistics_if_outdated()
         return round(self.statistics["disk_write"] / 1048576)
 
+    @locked
     def get_interface_by_label(self, label: str) -> Interface:
         for iface in self.interfaces():
             if iface.label == label:
                 return iface
         raise InterfaceNotFound("{}:{}".format(label, self))
 
+    @locked
     def get_interface_by_slot(self, slot: int) -> Interface:
         for iface in self.interfaces():
             if iface.slot == slot:
@@ -413,6 +438,7 @@ def get_link_to(self, other_node: Node) -> Optional[Link]:
                 return link
         return None
 
+    @check_stale
     def wait_until_converged(
         self, max_iterations: Optional[int] = None, wait_time: Optional[int] = None
     ) -> None:
@@ -446,61 +472,83 @@ def wait_until_converged(
         # specified
         raise RuntimeError(msg)
 
+    @check_stale
     def has_converged(self) -> bool:
         url = self._base_url + "/check_if_converged"
         return self._session.get(url).json()
 
+    @check_stale
     def start(self, wait=False) -> None:
         url = self._base_url + "/state/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"
         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"
         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"
         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)
         return self._session.get(url).json()
 
+    @check_stale
     def console_key(self) -> str:
         url = self._base_url + "/keys/console"
         return self._session.get(url).json()
 
+    @check_stale
     def vnc_key(self) -> str:
         url = self._base_url + "/keys/vnc"
         return self._session.get(url).json()
 
-    def remove_on_server(self) -> None:
+    def remove(self) -> None:
+        self.lab.remove_node(self)
+
+    @check_stale
+    def _remove_on_server(self) -> None:
         _LOGGER.info("Removing node %s", self)
         url = self._base_url
         self._session.delete(url)
 
+    def remove_on_server(self) -> None:
+        warnings.warn(
+            "'Node.remove_on_server()' is deprecated, use 'Node.remove()' instead.",
+            DeprecationWarning,
+        )
+        self._remove_on_server()
+
+    @check_stale
     def tags(self) -> list[str]:
         """Returns the tags set on this node"""
         self.lab.sync_topology_if_outdated()
         return self._tags
 
+    @locked
     def add_tag(self, tag: str) -> None:
         current = self.tags()
         if tag not in current:
             current.append(tag)
             self._set_node_property("tags", current)
 
+    @locked
     def remove_tag(self, tag: str) -> None:
         current = self.tags()
         current.remove(tag)
@@ -524,6 +572,8 @@ def run_pyats_config_command(self, command: str) -> str:
         label = self.label
         return self.lab.pyats.run_config_command(label, command)
 
+    @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
@@ -536,6 +586,8 @@ def sync_layer3_addresses(self) -> None:
         interfaces = result.get("interfaces", {})
         self.map_l3_addresses_to_interfaces(interfaces)
 
+    @check_stale
+    @locked
     def sync_operational(self, response: dict[str, Any] = None):
         if response is None:
             url = self._base_url + "?operational=true"
@@ -544,6 +596,8 @@ def sync_operational(self, response: dict[str, Any] = None):
         self._compute_id = operational.get("compute_id")
         self._resource_pool = operational.get("resource_pool")
 
+    @check_stale
+    @locked
     def map_l3_addresses_to_interfaces(
         self, mapping: dict[str, dict[str, str]]
     ) -> None:
@@ -562,6 +616,8 @@ def map_l3_addresses_to_interfaces(
                 "ipv6": ipv6,
             }
 
+    @check_stale
+    @locked
     def update(
         self,
         node_data: dict[str, Any],
@@ -592,5 +648,6 @@ def _set_node_property(self, key: str, val: Any) -> None:
         _LOGGER.debug("Setting node property %s %s: %s", 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)
diff --git a/virl2_client/models/node_image_definitions.py b/virl2_client/models/node_image_definitions.py
index 4c81f88..8a1b525 100644
--- a/virl2_client/models/node_image_definitions.py
+++ b/virl2_client/models/node_image_definitions.py
@@ -20,22 +20,18 @@
 
 from __future__ import annotations
 
-import logging
 import os
 import pathlib
 import time
 import warnings
 from typing import TYPE_CHECKING, BinaryIO, Callable, Optional
 
-from virl2_client.exceptions import InvalidImageFile
+from virl2_client.exceptions import InvalidContentType, InvalidImageFile
 
 if TYPE_CHECKING:
     import httpx
 
 
-_LOGGER = logging.getLogger(__name__)
-
-
 class NodeImageDefinitions:
     def __init__(self, session: httpx.Client) -> None:
         """
@@ -86,33 +82,75 @@ def image_definitions_for_node_definition(self, definition_id: str) -> list[dict
         url = "node_definitions/" + definition_id + "/image_definitions"
         return self._session.get(url).json()
 
-    def upload_node_definition(self, body, json: bool = False) -> str:
+    def set_image_definition_read_only(
+        self, definition_id: str, read_only: bool
+    ) -> dict:
+        """
+        Set the read-only attribute of the image definition with the given ID.
+
+        :param definition_id: The ID of the image definition.
+        :param read_only: The new value of the read-only attribute.
+        :returns: The modified image definition.
+        """
+        url = f"image_definitions/{definition_id}/read_only"
+        return self._session.put(url, json=read_only).json()
+
+    def set_node_definition_read_only(
+        self, definition_id: str, read_only: bool
+    ) -> dict:
+        """
+        Set the read-only attribute of the node definition with the given ID.
+
+        :param definition_id: The ID of the node definition.
+        :param read_only: The new value of the read-only attribute.
+        :returns: The modified node definition.
+        """
+        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:
         """
-        Upload new node definition.
+        Uploads a new node definition.
 
         :param body: node definition (yaml or json)
-        :type: str or dict
-        :param json: whether we are sending json data
+        :param json: DEPRECATED, replaced with type check
         :return: "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",
+                DeprecationWarning,
+            )
+            is_json = True
         url = "node_definitions/"
-        if json:
+        if is_json:
             return self._session.post(url, json=body).json()
         else:
             # YAML
             return self._session.post(url, content=body).json()
 
-    def upload_image_definition(self, body, json: bool = False) -> str:
+    def upload_image_definition(
+        self, body: str | dict, json: bool | None = None
+    ) -> str:
         """
-        Upload new image definition.
+        Uploads a new image definition.
 
         :param body: image definition (yaml or json)
-        :type: str or dict
-        :param json: whether we are sending json data
+        :param json: DEPRECATED, replaced with type check
         :return: "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",
+                DeprecationWarning,
+            )
+            is_json = True
         url = "image_definitions/"
-        if json:
+        if is_json:
             return self._session.post(url, json=body).json()
         else:
             # YAML
@@ -267,3 +305,11 @@ def print_progress_bar(cur: int, total: int, start_time: float, length=50) -> No
     )
     if cur == total:
         print()
+
+
+def _is_json_content(content: dict | str) -> bool:
+    if isinstance(content, dict):
+        return True
+    elif isinstance(content, str):
+        return False
+    raise InvalidContentType(type(content))
diff --git a/virl2_client/models/system.py b/virl2_client/models/system.py
index 51353a3..1d5fe83 100644
--- a/virl2_client/models/system.py
+++ b/virl2_client/models/system.py
@@ -36,7 +36,11 @@ def __init__(self, session: httpx.Client, auto_sync=True, auto_sync_interval=1.0
         self.auto_sync = auto_sync
         self.auto_sync_interval = auto_sync_interval
         self._last_sync_compute_host_time = 0.0
+        self._last_sync_system_notice_time = 0.0
         self._compute_hosts: dict[str, ComputeHost] = {}
+        self._system_notices: dict[str, SystemNotice] = {}
+        self._maintenance_mode = False
+        self._maintenance_notice: Optional[SystemNotice] = None
 
     @property
     def session(self) -> httpx.Client:
@@ -47,6 +51,41 @@ def compute_hosts(self) -> dict[str, ComputeHost]:
         self.sync_compute_hosts_if_outdated()
         return self._compute_hosts
 
+    @property
+    def system_notices(self) -> dict[str, SystemNotice]:
+        self.sync_system_notices_if_outdated()
+        return self._system_notices
+
+    @property
+    def maintenance_mode(self) -> bool:
+        self.sync_system_notices_if_outdated()
+        return self._maintenance_mode
+
+    @maintenance_mode.setter
+    def maintenance_mode(self, value: bool) -> None:
+        url = "system/maintenance_mode"
+        self._session.patch(url, json={"maintenance_mode": value})
+        self._maintenance_mode = value
+
+    @property
+    def maintenance_notice(self) -> Optional[SystemNotice]:
+        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"
+        notice_id = None if notice is None else notice.id
+        result = self._session.patch(url, json={"notice": notice_id})
+        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)
+        self._maintenance_notice = notice
+
     def sync_compute_hosts_if_outdated(self) -> None:
         timestamp = time.time()
         if (
@@ -55,6 +94,14 @@ def sync_compute_hosts_if_outdated(self) -> None:
         ):
             self.sync_compute_hosts()
 
+    def sync_system_notices_if_outdated(self) -> None:
+        timestamp = time.time()
+        if (
+            self.auto_sync
+            and timestamp - self._last_sync_system_notice_time > self.auto_sync_interval
+        ):
+            self.sync_system_notices()
+
     def sync_compute_hosts(self) -> None:
         url = "system/compute_hosts"
         compute_hosts = self._session.get(url).json()
@@ -69,20 +116,64 @@ def sync_compute_hosts(self) -> None:
                 self.add_compute_host_local(**compute_host)
             compute_host_ids.append(compute_id)
 
-        for local_compute_host_id in list(self._compute_hosts):
-            if local_compute_host_id not in compute_host_ids:
-                self._compute_hosts.pop(local_compute_host_id)
+        for compute_id in list(self._compute_hosts):
+            if compute_id not in compute_host_ids:
+                self._compute_hosts.pop(compute_id)
         self._last_sync_compute_host_time = time.time()
 
-    def get_external_connectors(self) -> list[dict[str, str]]:
+    def sync_system_notices(self) -> None:
+        url = "system/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)
+            else:
+                self.add_system_notice_local(**system_notice)
+            system_notice_ids.append(notice_id)
+
+        for notice_id in list(self._system_notices):
+            if notice_id not in system_notice_ids:
+                self._system_notices.pop(notice_id)
+
+        url = "system/maintenance_mode"
+        maintenance = self.session.get(url).json()
+        self._maintenance_mode = maintenance["maintenance_mode"]
+        notice_id = maintenance["notice"]
+        if notice_id is None:
+            self._maintenance_notice = None
+        else:
+            self._maintenance_notice = self._system_notices.get(notice_id)
+        self._last_sync_system_notice_time = time.time()
+
+    def get_external_connectors(self, sync: bool | None = None) -> list[dict[str, str]]:
         """
-        Get the list of external connectors present on the controller
+        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 are used as External Connector nodes' configuration.
+        Device names or tags are used as External Connector nodes' configuration.
         Returns a list of objects with the device name and label.
         """
         url = "/system/external_connectors"
-        return self._session.get(url).json()
+        if sync is None:
+            return self._session.get(url).json()
+        else:
+            data = {"push_configured_state": sync}
+            return self._session.put(url, json=data).json()
+
+    def update_external_connector(
+        self, connector_id: str, data: dict[str, Any]
+    ) -> dict[str, Any]:
+        url = f"/system/external_connectors/{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}"
+        return self._session.delete(url)
 
     def get_web_session_timeout(self) -> int:
         """
@@ -126,6 +217,16 @@ def set_mac_address_block(self, block: int) -> str:
             raise ValueError("MAC address block has to be in range 0-7.")
         return self._set_mac_address_block(block=block)
 
+    def get_new_compute_host_state(self) -> str:
+        url = "/system/compute_hosts/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"
+        return self._session.patch(
+            url, json={"admission_state": admission_state}
+        ).json()["admission_state"]
+
     def add_compute_host_local(
         self,
         compute_id: str,
@@ -153,6 +254,29 @@ def add_compute_host_local(
         self._compute_hosts[compute_id] = new_compute_host
         return new_compute_host
 
+    def add_system_notice_local(
+        self,
+        id: str,
+        level: str,
+        label: str,
+        content: str,
+        enabled: bool,
+        acknowledged: dict[str, bool],
+        groups: Optional[list[str]] = None,
+    ) -> SystemNotice:
+        new_system_notice = SystemNotice(
+            self,
+            id,
+            level,
+            label,
+            content,
+            enabled,
+            acknowledged,
+            groups,
+        )
+        self._system_notices[id] = new_system_notice
+        return new_system_notice
+
 
 class ComputeHost:
     def __init__(
@@ -238,6 +362,7 @@ def remove(self) -> None:
     def update(self, host_data: dict[str, Any], push_to_server: bool = False):
         if push_to_server:
             self._set_compute_host_properties(host_data)
+            return
 
         for key, value in host_data.items():
             setattr(self, f"_{key}", value)
@@ -246,5 +371,84 @@ def _set_compute_host_property(self, key: str, val: Any) -> None:
         _LOGGER.debug("Setting compute host property %s %s: %s", self, key, val)
         self._set_compute_host_properties({key: val})
 
-    def _set_compute_host_properties(self, compute_host_data: dict[str, Any]) -> None:
-        self._session.patch(url=self._base_url, json=compute_host_data)
+    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)
+
+
+class SystemNotice:
+    def __init__(
+        self,
+        system: SystemManagement,
+        id: str,
+        level: str,
+        label: str,
+        content: bool,
+        enabled: bool,
+        acknowledged: dict[str, bool],
+        groups: Optional[list[str]] = None,
+    ):
+        self._system = system
+        self._session: httpx.Client = system.session
+        self._id = id
+        self._level = level
+        self._label = label
+        self._content = content
+        self._enabled = enabled
+        self._acknowledged = acknowledged
+        self._groups = groups
+        self._base_url = f"{self._session.base_url}/system/notices/{id}"
+
+    @property
+    def id(self) -> str:
+        return self._id
+
+    @property
+    def level(self) -> str:
+        self._system.sync_system_notices_if_outdated()
+        return self._level
+
+    @property
+    def label(self) -> str:
+        self._system.sync_system_notices_if_outdated()
+        return self._label
+
+    @property
+    def content(self) -> str:
+        self._system.sync_system_notices_if_outdated()
+        return self._content
+
+    @property
+    def enabled(self) -> bool:
+        self._system.sync_system_notices_if_outdated()
+        return self._enabled
+
+    @property
+    def acknowledged(self) -> dict[str, bool]:
+        self._system.sync_system_notices_if_outdated()
+        return self._acknowledged
+
+    @property
+    def groups(self) -> Optional[list[str]]:
+        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)
+
+    def update(self, notice_data: dict[str, Any], push_to_server: bool = False):
+        if push_to_server:
+            self._set_notice_properties(notice_data)
+            return
+
+        for key, value in notice_data.items():
+            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)
+        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)
diff --git a/virl2_client/models/users.py b/virl2_client/models/users.py
index ca3cae1..f790cc6 100644
--- a/virl2_client/models/users.py
+++ b/virl2_client/models/users.py
@@ -21,7 +21,7 @@
 from __future__ import annotations
 
 from typing import TYPE_CHECKING, Any, Optional
-
+from ..utils import _UNCHANGED
 if TYPE_CHECKING:
     import httpx
 
@@ -98,12 +98,12 @@ def create_user(
     def update_user(
         self,
         user_id: str,
-        fullname: str = "",
-        description: str = "",
-        groups: list[str] = None,
+        fullname: Optional[str] = _UNCHANGED,
+        description: Optional[str] = _UNCHANGED,
+        groups: Optional[list[str]] = _UNCHANGED,
         admin: Optional[bool] = None,
-        password_dict: dict[str, str] = None,
-        resource_pool: Optional[str] = None,
+        password_dict: Optional[dict[str, str]] = None,
+        resource_pool: Optional[str] = _UNCHANGED,
     ) -> dict:
         """
         Updates user.
@@ -118,17 +118,17 @@ def update_user(
         :return: user object
         """
         data: dict[str, Any] = {}
-        if fullname:
+        if fullname is not _UNCHANGED:
             data["fullname"] = fullname
-        if description:
+        if description is not _UNCHANGED:
             data["description"] = description
         if admin is not None:
             data["admin"] = admin
-        if groups is not None:
+        if groups is not _UNCHANGED:
             data["groups"] = groups
         if password_dict is not None:
             data["password"] = password_dict
-        if resource_pool is not None:
+        if resource_pool is not _UNCHANGED:
             data["resource_pool"] = resource_pool
 
         url = self.base_url + "/{}".format(user_id)
diff --git a/virl2_client/utils.py b/virl2_client/utils.py
new file mode 100644
index 0000000..3f05c8b
--- /dev/null
+++ b/virl2_client/utils.py
@@ -0,0 +1,150 @@
+#
+# This file is part of VIRL 2
+# Copyright (c) 2019-2022, 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
+
+from contextlib import nullcontext
+from functools import wraps
+from typing import TYPE_CHECKING, Callable, Optional, Type, TypeVar, Union, cast
+
+import httpx
+
+from .exceptions import (
+    ElementNotFound,
+    InterfaceNotFound,
+    LabNotFound,
+    LinkNotFound,
+    NodeNotFound,
+)
+
+if TYPE_CHECKING:
+    from .models import Interface, Lab, Link, Node
+
+    Element = Union[Lab, Node, Interface, Link]
+
+TCallable = TypeVar("TCallable", bound=Callable)
+
+
+class _Sentinel:
+    def __repr__(self):
+        return "<Unchanged>"
+
+
+_UNCHANGED = _Sentinel()
+
+
+def _make_not_found(instance: Element, owner: Type[Element]) -> ElementNotFound:
+    """Composes and raises an ElementNotFound error for the given instance."""
+    class_name = owner.__name__
+    instance_id = instance._id
+    instance_label = instance._title if class_name == "Lab" else instance._label
+
+    error_text = (
+        f"{class_name} {instance_label} ({instance_id}) no longer exists on the server."
+    )
+    error: Type[ElementNotFound] = {
+        "Lab": LabNotFound,
+        "Node": NodeNotFound,
+        "Interface": InterfaceNotFound,
+        "Link": LinkNotFound,
+    }[class_name]
+    return error(error_text)
+
+
+def _check_and_mark_stale(
+    func: Callable,
+    instance: Element,
+    owner: Optional[Type[Element]] = None,
+    *args,
+    **kwargs,
+):
+    """
+    Checks 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`
+    """
+    if owner is None:
+        owner = type(instance)
+
+    if instance._stale:
+        raise _make_not_found(instance, owner)
+
+    try:
+        ret = func(*args, **kwargs)
+        if instance._stale:
+            raise _make_not_found(instance, owner)
+        return ret
+
+    except httpx.HTTPStatusError as exc:
+        resp = exc.response
+        class_name = owner.__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
+
+
+def check_stale(func: TCallable) -> TCallable:
+    """
+    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 cast(TCallable, wrapper_stale)
+
+
+class property_s(property):
+    """A modified `property` that will check staleness."""
+
+    def __get__(self, instance, owner):
+        return _check_and_mark_stale(super().__get__, instance, owner, instance, owner)
+
+
+def locked(func: TCallable) -> TCallable:
+    """
+    A decorator that makes a method threadsafe.
+    Parent class instance must have a `session.lock` property for locking to occur.
+    """
+
+    @wraps(func)
+    def wrapper_locked(*args, **kwargs):
+        try:
+            ctx = args[0]._session.lock
+        except (IndexError, AttributeError):
+            ctx = None
+        if ctx is None:
+            ctx = nullcontext()
+        with ctx:
+            return func(*args, **kwargs)
+
+    return cast(TCallable, wrapper_locked)
diff --git a/virl2_client/virl2_client.py b/virl2_client/virl2_client.py
index 68dc3e1..f8fedd9 100644
--- a/virl2_client/virl2_client.py
+++ b/virl2_client/virl2_client.py
@@ -27,6 +27,7 @@
 import time
 from functools import lru_cache
 from pathlib import Path
+from threading import RLock
 from typing import Any, NamedTuple, Optional
 from urllib.parse import urljoin, urlsplit, urlunsplit
 
@@ -34,6 +35,7 @@
 
 from .exceptions import InitializationError, LabNotFound
 from .models import (
+    AuthManagement,
     GroupManagement,
     Lab,
     Licensing,
@@ -44,6 +46,8 @@
     UserManagement,
 )
 from .models.authentication import make_session
+from .models.configuration import get_configuration
+from .utils import locked
 
 _LOGGER = logging.getLogger(__name__)
 cached = lru_cache(maxsize=None)  # cache results forever
@@ -134,6 +138,7 @@ class ClientConfig(NamedTuple):
     ssl_verify: bool | str = True
     allow_http: bool = False
     auto_sync: float = 1.0
+    events: bool = False
     raise_for_auth_failure: bool = True
     convergence_wait_max_iter: int = 500
     convergence_wait_time: int | float = 5
@@ -148,9 +153,10 @@ def make_client(self) -> ClientLibrary:
             allow_http=self.allow_http,
             convergence_wait_max_iter=self.convergence_wait_max_iter,
             convergence_wait_time=self.convergence_wait_time,
+            events=self.events,
         )
-        client.auto_sync = self.auto_sync >= 0.0
         client.auto_sync_interval = self.auto_sync
+        client.auto_sync = self.auto_sync >= 0.0 and not self.events
         return client
 
 
@@ -158,7 +164,7 @@ class ClientLibrary:
     """Python bindings for the REST API of a CML controller."""
 
     # current client version
-    VERSION = Version("2.5.0")
+    VERSION = Version("2.6.0")
     # list of Version objects
     INCOMPATIBLE_CONTROLLER_VERSIONS = [
         Version("2.0.0"),
@@ -181,58 +187,52 @@ def __init__(
         allow_http: bool = False,
         convergence_wait_max_iter: int = 500,
         convergence_wait_time: int | float = 5,
+        events: bool = False,
     ) -> None:
         """
         Initializes 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
-            URL via the ``VIRL2_URL`` environment variable. If no protocol
-            scheme is provided, "https:" is used.
-        :param username: Username of the user to authenticate
-        :param password: Password of the user to authenticate
+            URL via the ``VIRL2_URL`` or ``VIRL_HOST`` environment variable.
+            If no protocol scheme is provided, "https:" is used.
+        :param username: Username of the user to authenticate. It's also possible
+            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
-            from ``CA_BUNDLE`` environment variable, or False to disable
+            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)
+            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
+            lab to converge.
         :param convergence_wait_time: wait interval in seconds to wait during one
-            iteration during lab convergence wait
+            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
+            authentication fails or host can't be reached.
         """
-
-        url = self._environ_get("VIRL2_URL", url)
-        if url is None or len(url) == 0:
-            message = "no URL provided"
-            raise InitializationError(message)
+        url, username, password, cert = get_configuration(
+            url, username, password, ssl_verify
+        )
+        if cert is not None:
+            ssl_verify = cert
 
         url, base_url = prepare_url(url, allow_http)
-
-        # check environment for username
-        username = self._environ_get("VIRL2_USER", username)
-        if username is None or len(username) == 0:
-            message = "no username provided"
-            raise InitializationError(message)
         self.username: str = username
-
-        # check environment for password
-        password = self._environ_get("VIRL2_PASS", password)
-        if password is None or len(password) == 0:
-            message = "no password provided"
-            raise InitializationError(message)
         self.password: str = password
 
-        if ssl_verify is True:
-            ssl_verify = self._environ_get("CA_BUNDLE", default=True)
-
         if ssl_verify is False:
             _LOGGER.warning("SSL Verification disabled")
 
         self._ssl_verify = ssl_verify
-        self._session = make_session(base_url, ssl_verify)
+        try:
+            self._session = make_session(base_url, ssl_verify)
+        except httpx.InvalidURL as exc:
+            raise InitializationError(exc) from None
         # checks version from system_info against self.VERSION
         self.check_controller_version()
 
@@ -270,6 +270,11 @@ def __init__(
             auto_sync=self.auto_sync,
             auto_sync_interval=self.auto_sync_interval,
         )
+        self.auth_management = AuthManagement(
+            self._session,
+            auto_sync=self.auto_sync,
+            auto_sync_interval=self.auto_sync_interval,
+        )
 
         try:
             self._make_test_auth_call()
@@ -280,6 +285,13 @@ def __init__(
                 _LOGGER.warning(exc)
                 return
 
+        self.event_listener = None
+        self.session.lock = None
+        if events:
+            # http-based auto sync should be off by default when using events
+            self.auto_sync = False
+            self.start_event_listening()
+
     def __repr__(self):
         return "{}({!r}, {!r}, {!r}, {!r}, {!r}, {!r})".format(
             self.__class__.__name__,
@@ -333,6 +345,15 @@ def session(self) -> httpx.Client:
         """
         return self._session
 
+    @property
+    def uuid(self) -> str:
+        """
+        Returns the UUID4 that identifies this client to the server.
+
+        :returns: the UUID4 string
+        """
+        return self._session.headers["X-Client-UUID"]
+
     def logout(self, clear_all_sessions: bool = False) -> None:
         """
         Invalidate current token.
@@ -436,6 +457,32 @@ def is_virl_1x(path: Path) -> bool:
             return True
         return False
 
+    @locked
+    def start_event_listening(self):
+        """
+        Starts 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
+        """
+        from .event_listening import EventListener
+
+        if self.event_listener is None:
+            self.event_listener = EventListener(self)
+        if not self.event_listener:
+            self._session.lock = RLock()
+            self.event_listener.start_listening()
+
+    @locked
+    def stop_event_listening(self):
+        if self.event_listener:
+            self.session.lock = None
+            self.event_listener.stop_listening()
+
+    @locked
     def import_lab(
         self,
         topology: str,
@@ -495,6 +542,7 @@ def import_lab(
         self._labs[lab_id] = lab
         return lab
 
+    @locked
     def import_lab_from_path(self, path: str, title: Optional[str] = None) -> Lab:
         """
         Imports an existing topology from a file / path.
@@ -522,6 +570,7 @@ def get_sample_labs(self) -> dict[str, dict]:
         url = "sample/labs"
         return self._session.get(url).json()
 
+    @locked
     def import_sample_lab(self, title: str) -> Lab:
         """
         Imports a built-in sample lab.
@@ -534,15 +583,14 @@ def import_sample_lab(self, title: str) -> Lab:
         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]:
         """
-        Retrieves a list of all defined labs.
+        Joins all labs owned by this user (or all labs period) and returns their list.
 
         :param show_all: Whether to get only labs owned by the admin or all user labs
         :returns: A list of lab objects
-        :rtype: list[models.Lab]
         """
-        # TODO: integrate this further with local labs - check if already exist
         url = "labs"
         if show_all:
             url += "?show_all=true"
@@ -554,16 +602,26 @@ def all_labs(self, show_all: bool = False) -> list[Lab]:
 
         return result
 
+    @locked
+    def _remove_stale_labs(self):
+        for lab in list(self._labs.values()):
+            if lab._stale:
+                self._remove_lab_local(lab)
+
+    @locked
     def local_labs(self) -> list[Lab]:
-        # TODO: first sync with controller to pull all possible labs
-        return [lab for lab in self._labs.values()]
+        self._remove_stale_labs()
+        return list(self._labs.values())
 
+    @locked
     def get_local_lab(self, lab_id: str) -> Lab:
+        self._remove_stale_labs()
         try:
             return self._labs[lab_id]
         except KeyError:
             raise LabNotFound(lab_id)
 
+    @locked
     def create_lab(self, title: Optional[str] = None) -> Lab:
         """
         Creates an empty lab with the given name. If no name is given, then
@@ -602,19 +660,47 @@ def create_lab(self, title: Optional[str] = None) -> Lab:
         self._labs[lab_id] = lab
         return lab
 
-    def remove_lab(self, lab_id: str) -> None:
+    @locked
+    def remove_lab(self, lab_id: str | Lab) -> None:
         """
         Use carefully, it removes a given lab::
 
             client_library.remove_lab("1f6cd7")
 
-        :param lab_id: The lab ID to be removed.
-        :type lab_id: str
+        If you have a lab object, you can also simply do:
+
+            lab.remove()
+
+        :param lab_id: the lab ID or object
         """
+        self._remove_stale_labs()
+        if isinstance(lab_id, Lab):
+            lab_id.remove()
+            self._remove_lab_local(lab_id)
+        elif lab_id in self._labs:
+            self._labs[lab_id].remove()
+            self._remove_lab_local(self._labs[lab_id])
+
+        else:
+            self._remove_unjoined_lab(lab_id)
+
+    @locked
+    def _remove_lab_local(self, lab: Lab) -> None:
+        """Helper function to remove a lab from the client library."""
+        try:
+            del self._labs[lab._id]
+            lab._stale = True
+        except KeyError:
+            # element may already have been deleted on server,
+            # and removed locally due to auto-sync
+            pass
+
+    def _remove_unjoined_lab(self, lab_id: str):
         url = "labs/{}".format(lab_id)
-        self._session.delete(url)
-        self._labs.pop(lab_id, None)
+        response = self._session.delete(url)
+        _LOGGER.debug("removed lab: %s", 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
@@ -632,9 +718,22 @@ def join_existing_lab(self, lab_id: str, sync_lab: bool = True) -> Lab:
         :param sync_lab: Synchronize changes.
         :returns: A Lab instance
         """
-        # TODO: check if lab exists through REST call
-        title = None
-        # TODO: sync lab name
+        self._remove_stale_labs()
+        if lab_id in self._labs:
+            return self._labs[lab_id]
+        topology = {}
+        if sync_lab:
+            try:
+                # check if lab exists through REST call
+                topology = self.session.get(f"labs/{lab_id}/topology").json()
+            except httpx.HTTPStatusError as exc:
+                if exc.response.status_code == 404:
+                    raise LabNotFound("No lab with the given ID exists on the host.")
+                raise
+            title = topology.get("lab", {}).get("title")
+        else:
+            title = None
+
         lab = Lab(
             title,
             lab_id,
@@ -646,7 +745,8 @@ def join_existing_lab(self, lab_id: str, sync_lab: bool = True) -> Lab:
             resource_pool_manager=self.resource_pool_management,
         )
         if sync_lab:
-            lab.sync()
+            lab.import_lab(topology)
+            lab._initialized = True
 
         self._labs[lab_id] = lab
         return lab
@@ -656,7 +756,6 @@ def get_diagnostics(self) -> dict:
         Returns the controller diagnostic data as JSON
 
         :returns: diagnostic data
-        :rtype: dict
         """
         url = "diagnostics"
         return self._session.get(url).json()
@@ -679,6 +778,7 @@ def get_system_stats(self) -> dict:
         url = "system_stats"
         return self._session.get(url).json()
 
+    @locked
     def find_labs_by_title(self, title: str) -> list[Lab]:
         """
         Return a list of labs which match the given title.
@@ -713,9 +813,7 @@ def get_lab_list(self, show_all: bool = False) -> list[str]:
         Returns list of all lab IDs.
 
         :param show_all: Whether to get only labs owned by the admin or all user labs
-        :type show_all: bool
         :returns: a list of Lab IDs
-        :rtype: list[str]
         """
         url = "labs"
         if show_all:


Test patch:
diff --git a/conftest.py b/conftest.py
index b8d2632..e616983 100644
--- a/conftest.py
+++ b/conftest.py
@@ -49,9 +49,6 @@ def pytest_addoption(
 
 @pytest.fixture(scope="function")
 def change_test_dir(request: pytest.FixtureRequest) -> Iterator[None]:
-    # TODO: check if these are correct; it seems request.fspath was deprecated
-    #  in favor of request.path and request.config.invocation_dir was deprecated
-    #  in favor of request.config.invocation_params.dir if I understand correctly
-    os.chdir(request.fspath.dirname)
+    os.chdir(request.path.parent)
     yield
-    os.chdir(request.config.invocation_dir)
+    os.chdir(request.config.invocation_params.dir)
diff --git a/tests/conftest.py b/tests/conftest.py
index e02d17b..48d4f3c 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -27,8 +27,8 @@
 from virl2_client.virl2_client import ClientLibrary
 
 CURRENT_VERSION = ClientLibrary.VERSION.version_str
-
-FAKE_HOST_API = "https://0.0.0.0/api/v0/"
+FAKE_HOST = "https://0.0.0.0"
+FAKE_HOST_API = f"{FAKE_HOST}/api/v0/"
 
 
 def client_library_patched_system_info(version):
@@ -138,3 +138,9 @@ def respx_mock_with_labs(respx_mock):
     )
     for api in resp_from_files:
         respx_mock.get(FAKE_HOST_API + api).mock(side_effect=resp_body_from_file)
+
+
+@pytest.fixture
+def client_library(respx_mock_with_labs, change_test_dir):
+    client = ClientLibrary(url=FAKE_HOST, username="test", password="pa$$")
+    yield client
diff --git a/tests/test_client_library.py b/tests/test_client_library.py
index d2cd8e9..307e6b4 100644
--- a/tests/test_client_library.py
+++ b/tests/test_client_library.py
@@ -21,6 +21,7 @@
 import json
 import logging
 import os
+import re
 import sys
 from pathlib import Path
 from unittest.mock import Mock, call, patch
@@ -200,7 +201,9 @@ 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
 @pytest.mark.parametrize("via", ["environment", "parameter"])
+@pytest.mark.parametrize("env_var", ["VIRL2_URL", "VIRL_HOST"])
 @pytest.mark.parametrize(
     "params",
     [
@@ -214,8 +217,9 @@ def test_client_library_init_disallow_http(client_library_server_current):
     ],
 )
 def test_client_library_init_url(
-    client_library_server_current, monkeypatch, via, params
+    client_library_server_current, monkeypatch, via, env_var, params
 ):
+    monkeypatch.setattr("getpass.getpass", input)
     (fail, url) = params
     expected_parts = None if fail else httpx.URL(url)
     if via == "environment":
@@ -224,11 +228,11 @@ def test_client_library_init_url(
     else:
         env = "http://badhost" if url else None
     if env is None:
-        monkeypatch.delenv("VIRL2_URL", raising=False)
+        monkeypatch.delenv(env_var, raising=False)
     else:
-        monkeypatch.setenv("VIRL2_URL", env)
+        monkeypatch.setenv(env_var, env)
     if fail:
-        with pytest.raises(InitializationError):
+        with pytest.raises((InitializationError, OSError)) as err:
             ClientLibrary(
                 url=url,
                 username="virl2",
@@ -236,6 +240,9 @@ 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))
     else:
         cl = ClientLibrary(url, username="virl2", password="virl2", allow_http=True)
         url_parts = cl.session.base_url
@@ -248,11 +255,14 @@ def test_client_library_init_url(
         assert cl.password == "virl2"
 
 
+# the test fails if you have variables set in .virlci or 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, params
+    client_library_server_current, monkeypatch, via, env_var, params
 ):
+    monkeypatch.setattr("getpass.getpass", input)
     url = "validhostname"
     (fail, user) = params
     if via == "environment":
@@ -262,12 +272,15 @@ def test_client_library_init_user(
     else:
         env = "baduser" if user else None
     if env is None:
-        monkeypatch.delenv("VIRL2_USER", raising=False)
+        monkeypatch.delenv(env_var, raising=False)
     else:
-        monkeypatch.setenv("VIRL2_USER", env)
+        monkeypatch.setenv(env_var, env)
     if fail:
-        with pytest.raises(InitializationError):
+        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))
     else:
         cl = ClientLibrary(url, username=user, password="virl2")
         assert cl.username == params[1]
@@ -275,11 +288,14 @@ def test_client_library_init_user(
         assert cl.session.base_url == "https://validhostname/api/v0/"
 
 
+# the test fails if you have variables set in .virlci or 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, params
+    client_library_server_current, monkeypatch, via, env_var, params
 ):
+    monkeypatch.setattr("getpass.getpass", input)
     url = "validhostname"
     (fail, password) = params
     if via == "environment":
@@ -289,12 +305,15 @@ def test_client_library_init_password(
     else:
         env = "badpass" if password else None
     if env is None:
-        monkeypatch.delenv("VIRL2_PASS", raising=False)
+        monkeypatch.delenv(env_var, raising=False)
     else:
-        monkeypatch.setenv("VIRL2_PASS", env)
+        monkeypatch.setenv(env_var, env)
     if fail:
-        with pytest.raises(InitializationError):
+        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))
     else:
         cl = ClientLibrary(url, username="virl2", password=password)
         assert cl.username == "virl2"
diff --git a/tests/test_client_library_labs.py b/tests/test_client_library_labs.py
index e2418f6..5ed4e05 100644
--- a/tests/test_client_library_labs.py
+++ b/tests/test_client_library_labs.py
@@ -25,34 +25,32 @@
 from virl2_client.exceptions import NodeNotFound
 from virl2_client.models import Interface, Lab
 from virl2_client.models.authentication import make_session
-from virl2_client.virl2_client import ClientLibrary
 
-FAKE_HOST = "https://0.0.0.0"
 RESOURCE_POOL_MANAGER = Mock()
 
 
 def test_topology_creation_and_removal():
-    context = Mock()
+    session = MagicMock()
     username = password = "test"
     lab = Lab(
         "laboratory",
         "1",
-        context,
+        session,
         username,
         password,
         auto_sync=0,
         resource_pool_manager=RESOURCE_POOL_MANAGER,
     )
-    node_a = lab.add_node_local("0", "node A", "nd", "im", "cfg", 0, 0)
-    node_b = lab.add_node_local("1", "node B", "nd", "im", "cfg", 1, 1)
-    node_c = lab.add_node_local("2", "node C", "nd", "im", "cfg", 2, 2)
-    i1 = lab.create_interface_local("0", "iface A", node_a, 0)
-    i2 = lab.create_interface_local("1", "iface B1", node_b, 1)
-    i3 = lab.create_interface_local("2", "iface B2", node_b, 2)
-    i4 = lab.create_interface_local("3", "iface C", node_c, 3)
+    node_a = lab._create_node_local("0", "node A", "nd", "im", "cfg", 0, 0)
+    node_b = lab._create_node_local("1", "node B", "nd", "im", "cfg", 1, 1)
+    node_c = lab._create_node_local("2", "node C", "nd", "im", "cfg", 2, 2)
+    i1 = lab._create_interface_local("0", "iface A", node_a, 0)
+    i2 = lab._create_interface_local("1", "iface B1", node_b, 1)
+    i3 = lab._create_interface_local("2", "iface B2", node_b, 2)
+    i4 = lab._create_interface_local("3", "iface C", node_c, 3)
 
-    lnk1 = lab.create_link_local(i1, i2, "0")
-    lnk2 = lab.create_link_local(i3, i4, "1")
+    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]
@@ -108,13 +106,12 @@ def test_topology_creation_and_removal():
 
 
 def test_need_to_wait1():
-    context = make_session("http://dontcare")
-    context.session = Mock()
+    session = MagicMock()
     username = password = "test"
     lab = Lab(
         "laboratory",
         "1",
-        context,
+        session,
         username,
         password,
         auto_sync=0,
@@ -127,13 +124,12 @@ def test_need_to_wait1():
 
 
 def test_need_to_wait2():
-    context = make_session("http://dontcare")
-    context.session = Mock()
+    session = MagicMock()
     username = password = "test"
     lab = Lab(
         "laboratory",
         "1",
-        context,
+        session,
         username,
         password,
         auto_sync=0,
@@ -146,13 +142,12 @@ def test_need_to_wait2():
 
 
 def test_str_and_repr():
-    context = make_session("http://dontcare")
-    context.session = Mock()
+    session = make_session("http://dontcare")
     username = password = "test"
     lab = Lab(
         "laboratory",
         "1",
-        context,
+        session,
         username,
         password,
         auto_sync=0,
@@ -164,12 +159,12 @@ def test_str_and_repr():
 
 
 def test_create_node():
-    context = MagicMock()
+    session = MagicMock()
     username = password = "test"
     lab = Lab(
         "laboratory",
         "1",
-        context,
+        session,
         username,
         password,
         auto_sync=0,
@@ -191,12 +186,13 @@ def test_create_link(respx_mock, connect_two_nodes):
     respx_mock.post("mock://mock/labs/1/links").respond(
         json={"id": "l0", "label": "segment0"}
     )
-    context = make_session("mock://mock")
+    session = make_session("mock://mock")
+    session.lock = MagicMock()
     username = password = "test"
     lab = Lab(
         "laboratory",
         "1",
-        context,
+        session,
         username,
         password,
         auto_sync=0,
@@ -230,12 +226,13 @@ def test_sync_stats(respx_mock):
     respx_mock.get("mock://mock/labs/1/simulation_stats").respond(
         json={"nodes": {}, "links": {}}
     )
-    context = make_session("mock://mock")
+    session = make_session("mock://mock")
+    session.lock = MagicMock()
     username = password = "test"
     lab = Lab(
         "laboratory",
         "1",
-        context,
+        session,
         username,
         password,
         auto_sync=0,
@@ -247,21 +244,21 @@ def test_sync_stats(respx_mock):
 
 
 def test_tags():
-    context = Mock()
+    session = MagicMock()
     username = password = "test"
     lab = Lab(
         "laboratory",
         "1",
-        context,
+        session,
         username,
         password,
         auto_sync=0,
         resource_pool_manager=RESOURCE_POOL_MANAGER,
     )
-    node_a = lab.add_node_local("0", "node A", "nd", "im", "cfg", 0, 0)
-    node_b = lab.add_node_local("1", "node B", "nd", "im", "cfg", 0, 0)
-    node_c = lab.add_node_local("2", "node C", "nd", "im", "cfg", 0, 0)
-    node_d = lab.add_node_local("3", "node D", "nd", "im", "cfg", 0, 0)
+    node_a = lab._create_node_local("0", "node A", "nd", "im", "cfg", 0, 0)
+    node_b = lab._create_node_local("1", "node B", "nd", "im", "cfg", 0, 0)
+    node_c = lab._create_node_local("2", "node C", "nd", "im", "cfg", 0, 0)
+    node_d = lab._create_node_local("3", "node D", "nd", "im", "cfg", 0, 0)
     assert len(node_a.tags()) == 0
     node_a.add_tag("Core")
     node_a.add_tag("Europe")
@@ -284,76 +281,70 @@ def test_tags():
 
 
 def test_find_by_label():
-
-    context = make_session("http://dontcare")
-    context.session = Mock()
+    session = MagicMock()
     username = password = "test"
     lab = Lab(
         "laboratory",
         "1",
-        context,
+        session,
         username,
         password,
         auto_sync=0,
         resource_pool_manager=RESOURCE_POOL_MANAGER,
     )
 
-    lab.add_node_local("n0", "server-a", "nd", "im", "cfg", 0, 0)
-    lab.add_node_local("n1", "server-b", "nd", "im", "cfg", 0, 0)
-    lab.add_node_local("n2", "server-c", "nd", "im", "cfg", 0, 0)
-    lab.add_node_local("n3", "server-d", "nd", "im", "cfg", 0, 0)
+    lab._create_node_local("n0", "server-a", "nd", "im", "cfg", 0, 0)
+    lab._create_node_local("n1", "server-b", "nd", "im", "cfg", 0, 0)
+    lab._create_node_local("n2", "server-c", "nd", "im", "cfg", 0, 0)
+    lab._create_node_local("n3", "server-d", "nd", "im", "cfg", 0, 0)
 
     node = lab.get_node_by_label("server-a")
     assert node.id == "n0"
 
     with pytest.raises(NodeNotFound) as exc:
         node = lab.get_node_by_label("does-not-exist")
-        print(exc)
         assert node is None
 
 
 def test_next_free_interface():
-    context = make_session("http://dontcare")
-    context.session = Mock()
+    session = MagicMock()
     username = password = "test"
     lab = Lab(
         "laboratory",
         "1",
-        context,
+        session,
         username,
         password,
         auto_sync=0,
         resource_pool_manager=RESOURCE_POOL_MANAGER,
     )
-    node_a = lab.add_node_local("0", "node A", "nd", "im", "cfg", 0, 0)
-    node_b = lab.add_node_local("1", "node B", "nd", "im", "cfg", 1, 1)
+    node_a = lab._create_node_local("0", "node A", "nd", "im", "cfg", 0, 0)
+    node_b = lab._create_node_local("1", "node B", "nd", "im", "cfg", 1, 1)
 
     nf = node_a.next_available_interface()
     assert nf is None
 
-    i1 = lab.create_interface_local("0", "iface 0", node_a, 0)
+    i1 = lab._create_interface_local("0", "iface 0", node_a, 0)
     nf = node_a.next_available_interface()
     assert i1 == nf
 
-    i2 = lab.create_interface_local("4", "iface 4", node_b, 1)
-    lab.create_link_local(i1, i2, "0")
+    i2 = lab._create_interface_local("4", "iface 4", node_b, 1)
+    lab._create_link_local(i1, i2, "0")
 
     nf = node_a.next_available_interface()
     assert nf is None
 
 
-def test_join_existing_lab(change_test_dir, respx_mock_with_labs):
-    client = ClientLibrary(url=FAKE_HOST, username="test", password="pa$$")
-    lab = client.join_existing_lab("444a78d1-575c-4746-8469-696e580f17b6")
+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}
 
 
-def test_all_labs(client_library_server_current, change_test_dir, respx_mock_with_labs):
-    client = ClientLibrary(url=FAKE_HOST, username="test", password="pa$$")
-    all_labs = client.all_labs()
+def test_all_labs(client_library):
+    all_labs = client_library.all_labs()
     assert len(all_labs) == 4
-    iosv_labs = client.find_labs_by_title("IOSv Feature Tests")
+    iosv_labs = client_library.find_labs_by_title("IOSv Feature Tests")
     assert len(iosv_labs) == 1
     lab: Lab = iosv_labs[0]
     node = lab.get_node_by_label("csr1000v-0")
diff --git a/tests/test_configuration.py b/tests/test_configuration.py
new file mode 100644
index 0000000..e0045d8
--- /dev/null
+++ b/tests/test_configuration.py
@@ -0,0 +1,161 @@
+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)
+
+
+@pytest.fixture
+def home_virlrc():
+    return Path.home() / _CONFIG_FILE_NAME
+
+
+@pytest.fixture
+def files_to_store(cwd_virlrc, home_virlrc):
+    return [cwd_virlrc, home_virlrc]
+
+
+@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
+
+    # teardown
+    for file_path in files_to_store:
+        if file_path.is_file():
+            file_path.unlink()
+
+    # restore original home file
+    for file_path, content in holder.items():
+        with file_path.open("w") as f:
+            f.write(content)
+
+
+@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()
+    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
+
+    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()
+    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
+
+    with pytest.raises(OSError, match="reading from stdin"):
+        _ = ClientLibrary(ssl_verify=False)
diff --git a/tests/test_definitions.py b/tests/test_definitions.py
new file mode 100644
index 0000000..6167132
--- /dev/null
+++ b/tests/test_definitions.py
@@ -0,0 +1,61 @@
+#
+# This file is part of VIRL 2
+# Copyright (c) 2019-2023, 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.
+#
+
+import pytest
+
+from virl2_client.exceptions import InvalidContentType
+from virl2_client.virl2_client import ClientLibrary
+
+# everything except str or dict is invalid
+INVALID_DEFINITIONS = {
+    "none": None,
+    "bool": True,
+    "int": 22,
+    "float": 1.0,
+    "complex": 1 + 2j,
+    "list": ["test"],
+    "tuple": ("test",),
+    "range": range(2),
+    "set": {"test"},
+    "bytes": b"test",
+    "bytearray": bytearray(2),
+    "object": object(),
+}
+
+
+@pytest.fixture(params=list(INVALID_DEFINITIONS))
+def invalid_definition(request):
+    return INVALID_DEFINITIONS[request.param]
+
+
+def test_upload_node_definition_invalid_body(
+    client_library: ClientLibrary, invalid_definition
+):
+    """Try adding an invalid Node Definition"""
+    with pytest.raises(InvalidContentType):
+        client_library.definitions.upload_node_definition(invalid_definition)
+
+
+def test_upload_image_definition_invalid_body(
+    client_library: ClientLibrary, invalid_definition
+):
+    """Try adding an invalid Image Definition"""
+    with pytest.raises(InvalidContentType):
+        client_library.definitions.upload_image_definition(invalid_definition)
diff --git a/tests/test_image_upload.py b/tests/test_image_upload.py
index 571cd82..b40803d 100644
--- a/tests/test_image_upload.py
+++ b/tests/test_image_upload.py
@@ -26,7 +26,6 @@
 import pytest
 
 from virl2_client.exceptions import InvalidImageFile
-from virl2_client.models.authentication import make_session
 from virl2_client.models.node_image_definitions import NodeImageDefinitions
 
 wrong_format_list = [
@@ -97,10 +96,8 @@ def windows_path(path: str):
     + [pytest.param(test_str, "") for test_str in expected_pass_list],
 )
 def test_image_upload_file(usage: str, test_string: str, message: str, test_path: str):
-    context = make_session("http://dontcare")
-    context.session = MagicMock()
-    nid = NodeImageDefinitions(context)
-
+    session = MagicMock()
+    nid = NodeImageDefinitions(session)
     rename = None
     filename = test_path + test_string