Repository: NucciTheBoss/cleantest (language: python)

Issue:
Mutability strikes again! Functional tests fail when run in sequence!
I did it to myself. By storing `InstanceConfig` objects inside the `LXDConfigurer` object, `get_instance_config` is actually returning a reference to the original `InstanceConfig` object, not a new object:

https://github.com/NucciTheBoss/cleantest/blob/71dd9b61fbe8e0b73329fec85490913ce0dd8db4/src/cleantest/control/_configurers/lxd_configurer.py#L69-L86

Therefore, when I assign the config name the instance name, this updates the configs name in `LXDConfigurer` as well:

https://github.com/NucciTheBoss/cleantest/blob/71dd9b61fbe8e0b73329fec85490913ce0dd8db4/src/cleantest/provider/lxd/lxd_handler.py#L74-L89

This causes problems in later test runs because now the `InstanceConfig` object's name is the name of the test environment instance rather than the name of the instance configuration entry. This issue could probably be fixed by using the `copy` module to create a "deep copy" of the `InstanceConfig` object. 


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
new file mode 100644
index 0000000..45ab6ce
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,51 @@
+# Copyright 2023 Jason C. Nucciarone
+# See LICENSE file for licensing details.
+
+name: cleantest continuous integration checks
+on:
+  workflow_call:
+  pull_request:
+
+jobs:
+  inclusive-naming:
+    name: Inclusive naming check
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+      - name: Run tests
+        uses: get-woke/woke-action@v0
+        with:
+          fail-on-error: true
+
+  lint:
+    name: Lint checks
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+      - name: Install dependencies
+        run: python3 -m pip install tox==4.3.5
+      - name: Run linters
+        run: tox -e lint
+
+  functional-tests:
+    name: Functional tests
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v3
+      - name: Install dependencies
+        run: |
+          python3 -m pip install tox==4.3.5
+          python3 -m pip install .
+          echo "$CHARMCRAFT_AUTH_TOKEN" >> tests/functional/lxd/local/charmhub.secret
+          snap download marktext --stable; bash -c "mv marktext_*.snap tests/functional/packages/snap/marktext.snap"
+        env:
+          CHARMCRAFT_AUTH_TOKEN: ${{ secrets.CHARMCRAFT_AUTH_TOKEN }}
+      - name: Setup LXD test environment provider
+        uses: canonical/setup-lxd@v0.1.0
+        with:
+          channel: 5.9/stable
+      - name: Run functional tests
+        run: tox -e functional
diff --git a/pyproject.toml b/pyproject.toml
index c929059..7f89c59 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,4 +1,4 @@
-# Copyright 2022 Canonical Ltd.
+# Copyright 2023 Jason C. Nucciarone
 # See LICENSE file for licensing details.
 
 # Testing tools configuration
@@ -14,27 +14,30 @@ log_cli_level = "INFO"
 
 # Formatting tools configuration
 [tool.black]
-line-length = 99
+line-length = 88
 target-version = ["py38"]
 
-[tool.isort]
-line_length = 99
-profile = "black"
-
 # Linting tools configuration
-[tool.flake8]
-max-line-length = 99
-max-doc-length = 99
-max-complexity = 10
-exclude = [".git", "__pycache__", ".tox", "build", "dist", "*.egg_info", "venv", "__init__.py"]
-select = ["E", "W", "F", "C", "N", "R", "D", "H"]
-# Ignore W503, E501 because using black creates errors with this
-# Ignore D107 Missing docstring in __init__
-ignore = ["W503", "E501", "D107"]
-# D100, D101, D102, D103: Ignore missing docstrings in tests
-per-file-ignores = ["tests/*:D100,D101,D102,D103,D104"]
-docstring-convention = "google"
-# Check for properly formatted copyright header in each file
-copyright-check = "True"
-copyright-author = "Canonical Ltd."
-copyright-regexp = "Copyright\\s\\d{4}([-,]\\d{4})*\\s+%(author)s"
+[tool.ruff]
+line-length = 88
+select = ["E", "W", "F", "C", "N", "D", "I001"]
+extend-ignore = [
+    "D203",
+    "D204",
+    "D213",
+    "D215",
+    "D400",
+    "D401",
+    "D404",
+    "D406",
+    "D407",
+    "D408",
+    "D409",
+    "D413",
+]
+ignore = ["E501", "D107"]
+extend-exclude = ["__pycache__", "*.egg_info", "__init__.py", "tests/*"]
+per-file-ignores = {"tests/*" = ["D100","D101","D102","D103","D104"]}
+
+[tool.ruff.mccabe]
+max-complexity = 15
diff --git a/tox.ini b/tox.ini
index 9bb8905..e4ddae8 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,4 +1,4 @@
-# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
+# Copyright 2023 Jason C. Nucciarone
 # See LICENSE file for licensing details.
 
 [tox]
@@ -9,12 +9,14 @@ envlist = fmt, lint
 [vars]
 src_path = {toxinidir}/src/
 tst_path = {toxinidir}/tests/
-all_path = {[vars]src_path} {[vars]tst_path} 
+docs_path = {toxinidir}/docs/
+all_path = {[vars]src_path} {[vars]tst_path} {[vars]docs_path} {toxinidir}/.github {toxinidir}/CODE_OF_CONDUCT.md {toxinidir}/README.md {toxinidir}/CONTRIBUTING.md
+all_code_path = {[vars]src_path} {[vars]tst_path}
 
 [testenv]
 basepython = python3
 setenv =
-    PYTHONPATH = {toxinidir}:{toxinidir}/lib:{[vars]src_path}
+    PYTHONPATH = {toxinidir}:{[vars]src_path}
     PYTHONBREAKPOINT=ipdb.set_trace
     PY_COLORS=1
 passenv =
@@ -24,31 +26,31 @@ passenv =
 description = Apply coding style standards to code.
 deps =
     black
-    isort
+    ruff
 commands =
-    isort {[vars]all_path}
-    black {[vars]all_path}
+    black {[vars]all_code_path}
+    ruff --fix {[vars]all_code_path}
 
 [testenv:lint]
 description = Check code against coding style standards.
 deps =
     black
-    flake8
-    flake8-docstrings
-    flake8-copyright
-    flake8-builtins
-    pyproject-flake8
-    pep8-naming
-    isort
+    ruff
     codespell
 commands =
-    codespell {toxinidir}/. --skip {toxinidir}/.git --skip {toxinidir}/.tox \
-        --skip {toxinidir}/build --skip {toxinidir}/lib --skip {toxinidir}/venv \
-        --skip {toxinidir}/.mypy_cache --skip {toxinidir}/icon.svg
-    # pflake8 wrapper supports config from pyproject.toml
-    pflake8 {[vars]all_path}
-    isort --check-only --diff {[vars]all_path}
-    black --check --diff {[vars]all_path}
+    codespell {[vars]all_path}
+    ruff {[vars]all_code_path}
+    black --check --diff {[vars]all_code_path}
+
+[testenv:functional]
+description = Run cleantest functional tests/
+deps =
+    -r {toxinidir}/requirements.txt
+    pytest
+    pytest-cov
+commands =
+    pytest --cov={[vars]src_path} --tb native --log-cli-level=INFO \
+        {[vars]tst_path}/functional
 
 [testenv:docs]
 description = Serve cleantest documentation locally.
@@ -99,13 +101,8 @@ deps =
     -r {toxinidir}/requirements.txt
     pytest
 commands =
-    pytest -v --tb native --log-cli-level=ERROR {[vars]tst_path}/lxd/parallel
-
-[testenv:lxd-remote]
-description = Run functional tests for a remotely-hosted LXD cluster.
-
-[testenv:lxd-remote-parallel]
-description = Run functional tests in parallel for a remotely-hosted LXD cluster.
+    pytest -v --tb native --log-cli-level=ERROR \
+        {[vars]tst_path}/functional/lxd/parallel
 
 [testenv:snaps]
 description = Run functional tests to test if cleantest supports snap packages
@@ -113,7 +110,8 @@ deps =
     -r {toxinidir}/requirements.txt
     pytest
 commands =
-    pytest -v --tb native --log-cli-level=ERROR {[vars]tst_path}/packages/snap
+    pytest -v --tb native --log-cli-level=ERROR \
+        {[vars]tst_path}/functional/packages/snap
 
 [testenv:files]
 description = Run functional tests to test file and directory injections.
@@ -121,4 +119,5 @@ deps =
     -r {toxinidir}/requirements.txt
     pytest
 commands =
-    pytest -v --tb native --log-cli-level=ERROR {[vars]tst_path}/files
+    pytest -v --tb native --log-cli-level=ERROR \
+        {[vars]tst_path}/functional/files


Test patch:
diff --git a/src/cleantest/_meta/__init__.py b/src/cleantest/_meta/__init__.py
new file mode 100644
index 0000000..54a9b2b
--- /dev/null
+++ b/src/cleantest/_meta/__init__.py
@@ -0,0 +1,11 @@
+#!/usr/bin/env python3
+# Copyright 2023 Jason C. Nucciarone
+# See LICENSE file for licensing details.
+
+"""Private metaclasses used inside cleantest.
+
+Do not use these modules outside cleantest.
+"""
+
+from .base_configurer import BaseConfigurer
+from .sources import LXDDefaultSources
diff --git a/src/cleantest/control/_configurers/_base_configurer.py b/src/cleantest/_meta/base_configurer.py
similarity index 76%
rename from src/cleantest/control/_configurers/_base_configurer.py
rename to src/cleantest/_meta/base_configurer.py
index 73a0bba..0cee128 100644
--- a/src/cleantest/control/_configurers/_base_configurer.py
+++ b/src/cleantest/_meta/base_configurer.py
@@ -1,5 +1,5 @@
 #!/usr/bin/env python3
-# Copyright 2023 Jason C. Nucciarone, Canonical Ltd.
+# Copyright 2023 Jason C. Nucciarone
 # See LICENSE file for licensing details.
 
 """Base tooling needed by configurer classes."""
@@ -8,16 +8,15 @@
 from collections import deque
 from typing import Deque, Union
 
+from cleantest._meta.mixins import Resettable
 from cleantest.control.hooks import StartEnvHook, StopEnvHook
 
 
 class DuplicateHookNameError(Exception):
     """Raised when more than one hook of the same type have the same name."""
 
-    ...
 
-
-class HookRegistry:
+class HookRegistry(Resettable):
     """Centrally store hooks for use by test environment providers."""
 
     metadata = set()
@@ -25,22 +24,38 @@ class HookRegistry:
     stopenv = deque()
 
     def __new__(cls) -> "HookRegistry":
-        if not hasattr(cls, "instance"):
+        if not hasattr(cls, "_instance"):
             cls._instance = super(HookRegistry, cls).__new__(cls)
         return cls._instance
 
+    @classmethod
+    def reset(cls) -> None:
+        """Reset metadata and hook queues."""
+        cls.metadata = set()
+        cls.startenv = deque()
+        cls.stopenv = deque()
+
     def lint(self, hook: Union[StartEnvHook, StopEnvHook]) -> None:
         """Lint hooks to ensure they compliant with set restrictions."""
-        lint = [h for h in self.metadata if hook.name == h[0] and hook.__class__.__name__ == h[1]]
+        lint = [
+            h
+            for h in self.metadata
+            if hook.name == h[0] and hook.__class__.__name__ == h[1]
+        ]
         if len(lint) > 0:
             raise DuplicateHookNameError(
                 f"Hook type {hook.__class__.__name__} with name {hook.name} already exists."
             )
 
 
-class BaseConfigurer:
+class BaseConfigurer(Resettable):
     """Base configure mixin for configurers."""
 
+    @classmethod
+    def reset(cls) -> None:
+        """Reset the hook registry."""
+        HookRegistry().reset()
+
     def register_hook(self, *hooks: Union[StartEnvHook, StopEnvHook]) -> None:
         """Register hooks in the hook registry.
 
@@ -69,7 +84,11 @@ def unregister_hook(self, *hook_names: str) -> None:
         for name in hook_names:
             for hook in HookRegistry().metadata:
                 if name == hook[0]:
-                    [dispatch[hook[1]].remove(i) for i in dispatch[hook[1]] if i.name == name]
+                    [
+                        dispatch[hook[1]].remove(i)
+                        for i in dispatch[hook[1]]
+                        if i.name == name
+                    ]
                     HookRegistry().metadata.remove(hook)
 
     @property
diff --git a/src/cleantest/_meta/hook_registry.py b/src/cleantest/_meta/hook_registry.py
new file mode 100644
index 0000000..3c4394d
--- /dev/null
+++ b/src/cleantest/_meta/hook_registry.py
@@ -0,0 +1,5 @@
+#!/usr/bin/env python3
+# Copyright 2023 Jason C. Nucciarone
+# See LICENSE file for licensing details.
+
+"""Utilities for working with hooks in cleantest."""
diff --git a/src/cleantest/_meta/mixins/__init__.py b/src/cleantest/_meta/mixins/__init__.py
new file mode 100644
index 0000000..49474e7
--- /dev/null
+++ b/src/cleantest/_meta/mixins/__init__.py
@@ -0,0 +1,10 @@
+#!/usr/bin/env python3
+# Copyright 2023 Jason C. Nucciarone
+# See LICENSE file for licensing details.
+
+"""Private mixins used inside cleantest.
+
+Do not use these modules outside cleantest.
+"""
+
+from .resettable import Resettable
diff --git a/src/cleantest/_meta/mixins/resettable.py b/src/cleantest/_meta/mixins/resettable.py
new file mode 100644
index 0000000..dc443b9
--- /dev/null
+++ b/src/cleantest/_meta/mixins/resettable.py
@@ -0,0 +1,16 @@
+#!/usr/bin/env python3
+# Copyright 2023 Jason C. Nucciarone
+# See LICENSE file for licensing details.
+
+"""Common operations for Singletons that need to be resettable."""
+
+from abc import ABC, abstractmethod
+
+
+class Resettable(ABC):
+    """Abstract mixin for Singletons that need to implement reset behavior."""
+
+    @classmethod
+    @abstractmethod
+    def reset(cls) -> None:
+        """Reset the current Singleton to its default state."""
diff --git a/src/cleantest/control/_sources.py b/src/cleantest/_meta/sources.py
similarity index 91%
rename from src/cleantest/control/_sources.py
rename to src/cleantest/_meta/sources.py
index 645be93..09bccfa 100644
--- a/src/cleantest/control/_sources.py
+++ b/src/cleantest/_meta/sources.py
@@ -1,5 +1,5 @@
 #!/usr/bin/env python3
-# Copyright 2023 Jason C. Nucciarone, Canonical Ltd.
+# Copyright 2023 Jason C. Nucciarone
 # See LICENSE file for licensing details.
 
 """Default sources for test environment providers."""
@@ -7,7 +7,7 @@
 from enum import Enum
 from typing import Any, List, Tuple
 
-from .lxd.lxd_config import InstanceSource
+from cleantest.control.lxd import InstanceSource
 
 
 class EnhancedEnum(Enum):
@@ -23,7 +23,7 @@ class LXDDefaultSources(EnhancedEnum):
     """Default sources for LXD test environment provider."""
 
     ALMALINUX_8_AMD64 = InstanceSource(
-        alias="almalinux/8",
+        alias="almalinux/8/amd64",
         mode="pull",
         protocol="simplestreams",
         server="https://images.linuxcontainers.org",
@@ -39,7 +39,7 @@ class LXDDefaultSources(EnhancedEnum):
     )
 
     ALMALINUX_9_AMD64 = InstanceSource(
-        alias="almalinux/9",
+        alias="almalinux/9/amd64",
         mode="pull",
         protocol="simplestreams",
         server="https://images.linuxcontainers.org",
@@ -55,7 +55,7 @@ class LXDDefaultSources(EnhancedEnum):
     )
 
     ARCHLINUX_AMD64 = InstanceSource(
-        alias="archlinux",
+        alias="archlinux/amd64",
         mode="pull",
         protocol="simplestreams",
         server="https://images.linuxcontainers.org",
@@ -71,7 +71,7 @@ class LXDDefaultSources(EnhancedEnum):
     )
 
     CENTOS_8_STREAM_AMD64 = InstanceSource(
-        alias="centos/8-Stream",
+        alias="centos/8-Stream/amd64",
         mode="pull",
         protocol="simplestreams",
         server="https://images.linuxcontainers.org",
@@ -87,7 +87,7 @@ class LXDDefaultSources(EnhancedEnum):
     )
 
     CENTOS_9_STREAM_AMD64 = InstanceSource(
-        alias="centos/9-Stream",
+        alias="centos/9-Stream/amd64",
         mode="pull",
         protocol="simplestreams",
         server="https://images.linuxcontainers.org",
@@ -103,7 +103,7 @@ class LXDDefaultSources(EnhancedEnum):
     )
 
     DEBIAN_10_AMD64 = InstanceSource(
-        alias="debian/10",
+        alias="debian/10/amd64",
         mode="pull",
         protocol="simplestreams",
         server="https://images.linuxcontainers.org",
@@ -119,7 +119,7 @@ class LXDDefaultSources(EnhancedEnum):
     )
 
     DEBIAN_11_AMD64 = InstanceSource(
-        alias="debian/11",
+        alias="debian/11/amd64",
         mode="pull",
         protocol="simplestreams",
         server="https://images.linuxcontainers.org",
@@ -135,7 +135,7 @@ class LXDDefaultSources(EnhancedEnum):
     )
 
     DEBIAN_12_AMD64 = InstanceSource(
-        alias="debian/12",
+        alias="debian/12/amd64",
         mode="pull",
         protocol="simplestreams",
         server="https://images.linuxcontainers.org",
@@ -151,7 +151,7 @@ class LXDDefaultSources(EnhancedEnum):
     )
 
     FEDORA_35_AMD64 = InstanceSource(
-        alias="fedora/35",
+        alias="fedora/35/amd64",
         mode="pull",
         protocol="simplestreams",
         server="https://images.linuxcontainers.org",
@@ -167,7 +167,7 @@ class LXDDefaultSources(EnhancedEnum):
     )
 
     FEDORA_36_AMD64 = InstanceSource(
-        alias="fedora/36",
+        alias="fedora/36/amd64",
         mode="pull",
         protocol="simplestreams",
         server="https://images.linuxcontainers.org",
@@ -183,7 +183,7 @@ class LXDDefaultSources(EnhancedEnum):
     )
 
     FEDORA_37_AMD64 = InstanceSource(
-        alias="fedora/37",
+        alias="fedora/37/amd64",
         mode="pull",
         protocol="simplestreams",
         server="https://images.linuxcontainers.org",
@@ -199,7 +199,7 @@ class LXDDefaultSources(EnhancedEnum):
     )
 
     ROCKYLINUX_8_AMD64 = InstanceSource(
-        alias="rockylinux/8",
+        alias="rockylinux/8/amd64",
         mode="pull",
         protocol="simplestreams",
         server="https://images.linuxcontainers.org",
@@ -215,7 +215,7 @@ class LXDDefaultSources(EnhancedEnum):
     )
 
     ROCKYLINUX_9_AMD64 = InstanceSource(
-        alias="rockylinux/9",
+        alias="rockylinux/9/amd64",
         mode="pull",
         protocol="simplestreams",
         server="https://images.linuxcontainers.org",
@@ -231,7 +231,7 @@ class LXDDefaultSources(EnhancedEnum):
     )
 
     UBUNTU_JAMMY_AMD64 = InstanceSource(
-        alias="ubuntu/jammy",
+        alias="ubuntu/jammy/amd64",
         mode="pull",
         protocol="simplestreams",
         server="https://images.linuxcontainers.org",
@@ -247,7 +247,7 @@ class LXDDefaultSources(EnhancedEnum):
     )
 
     UBUNTU_FOCAL_AMD64 = InstanceSource(
-        alias="ubuntu/focal",
+        alias="ubuntu/focal/amd64",
         mode="pull",
         protocol="simplestreams",
         server="https://images.linuxcontainers.org",
@@ -263,7 +263,7 @@ class LXDDefaultSources(EnhancedEnum):
     )
 
     UBUNTU_BIONIC_AMD64 = InstanceSource(
-        alias="ubuntu/18.04",
+        alias="ubuntu/18.04/amd64",
         mode="pull",
         protocol="simplestreams",
         server="https://images.linuxcontainers.org",
diff --git a/src/cleantest/control/_configurers/lxd_configurer.py b/src/cleantest/control/_configurers/lxd_configurer.py
index ad39336..610670b 100644
--- a/src/cleantest/control/_configurers/lxd_configurer.py
+++ b/src/cleantest/control/_configurers/lxd_configurer.py
@@ -1,14 +1,13 @@
 #!/usr/bin/env python3
-# Copyright 2023 Jason C. Nucciarone, Canonical Ltd.
+# Copyright 2023 Jason C. Nucciarone
 # See LICENSE file for licensing details.
 
 """Information needed by LXD test environment provider."""
 
-from typing import Dict
+import copy
 
-from .._sources import LXDDefaultSources
-from ..lxd.lxd_config import InstanceConfig
-from ._base_configurer import BaseConfigurer
+from cleantest._meta import BaseConfigurer, LXDDefaultSources
+from cleantest.control.lxd import InstanceConfig
 
 
 class DuplicateLXDInstanceConfigError(Exception):
@@ -16,7 +15,7 @@ class DuplicateLXDInstanceConfigError(Exception):
 
 
 class LXDInstanceConfigNotFoundError(Exception):
-    """Raised when the configuration for an LXD instance is not found in the registry."""
+    """Raised when LXD instance configuration is not found in the registry."""
 
 
 class LXDConfigurer(BaseConfigurer):
@@ -25,15 +24,22 @@ class LXDConfigurer(BaseConfigurer):
     _configs = set()
 
     def __new__(cls) -> "LXDConfigurer":
-        if not hasattr(cls, "instance"):
-            cls.instance = super(LXDConfigurer, cls).__new__(cls)
-            [
-                cls._configs.add(
-                    InstanceConfig(name=name.replace("_", "-").lower(), source=source)
-                )
-                for name, source in LXDDefaultSources.items()
-            ]
-        return cls.instance
+        if not hasattr(cls, "_instance"):
+            cls._instance = super(LXDConfigurer, cls).__new__(cls)
+            cls.reset()
+        return cls._instance
+
+    @classmethod
+    def reset(cls) -> None:
+        """Reset LXD test environment provider to default configuration."""
+        cls._configs = set()
+        [
+            cls._configs.add(
+                InstanceConfig(name=name.replace("_", "-").lower(), source=source)
+            )
+            for name, source in LXDDefaultSources.items()
+        ]
+        super().reset()
 
     def add_instance_config(self, *new_config: InstanceConfig) -> None:
         """Add a new LXD instance configuration to the registry.
@@ -81,6 +87,6 @@ def get_instance_config(self, name: str) -> InstanceConfig:
         """
         for config in self._configs:
             if config.name == name:
-                return config
+                return copy.deepcopy(config)
 
         raise LXDInstanceConfigNotFoundError(f"Could not find instance {name}.")
diff --git a/src/cleantest/control/env.py b/src/cleantest/control/env.py
index a880bd8..166de0d 100644
--- a/src/cleantest/control/env.py
+++ b/src/cleantest/control/env.py
@@ -1,5 +1,5 @@
 #!/usr/bin/env python3
-# Copyright 2023 Jason C. Nucciarone, Canonical Ltd.
+# Copyright 2023 Jason C. Nucciarone
 # See LICENSE file for licensing details.
 
 """Environment variable management for test providers."""
@@ -7,16 +7,23 @@
 import os
 from typing import Any, Dict, Optional
 
+from cleantest._meta.mixins import Resettable
 
-class Env:
+
+class Env(Resettable):
     """Manage environment data for test environments."""
 
     _env = {}
 
     def __new__(cls) -> "Env":
-        if not hasattr(cls, "instance"):
-            cls.instance = super(Env, cls).__new__(cls)
-        return cls.instance
+        if not hasattr(cls, "_instance"):
+            cls._instance = super(Env, cls).__new__(cls)
+        return cls._instance
+
+    @classmethod
+    def reset(cls) -> None:
+        """Reset environment information."""
+        cls._env = {}
 
     def add(self, env_mapping: Dict[str, Any]) -> None:
         """Add new values to environment.
diff --git a/src/cleantest/data/directory.py b/src/cleantest/data/directory.py
index 209065b..c8fcc93 100644
--- a/src/cleantest/data/directory.py
+++ b/src/cleantest/data/directory.py
@@ -26,7 +26,9 @@ class DirectoryNotFoundError(Exception):
     """Raised when a directory is not found on the host."""
 
 
-def _strip_tar(tar: tarfile.TarFile, n_components: int = 1) -> Iterable[tarfile.TarInfo]:
+def _strip_tar(
+    tar: tarfile.TarFile, n_components: int = 1
+) -> Iterable[tarfile.TarInfo]:
     """Strip components from a tarfile to a specified level.
 
     Args:
diff --git a/src/cleantest/data/file.py b/src/cleantest/data/file.py
index 9bdc27d..385fde2 100644
--- a/src/cleantest/data/file.py
+++ b/src/cleantest/data/file.py
@@ -53,13 +53,15 @@ def dump(self) -> None:
             )
 
         if self._data is None:
-            raise FileError(f"Nothing to write.")
+            raise FileError("Nothing to write.")
 
         with tarfile.open(
             fileobj=BytesIO(self._data), mode="r:gz"
         ) as tar, tempfile.TemporaryDirectory() as tmp_dir:
             tar.extractall(tmp_dir)
-            self.dest.write_bytes(pathlib.Path(tmp_dir).joinpath(self.src.name).read_bytes())
+            self.dest.write_bytes(
+                pathlib.Path(tmp_dir).joinpath(self.src.name).read_bytes()
+            )
 
     def load(self) -> None:
         """Load file from specified source.
@@ -100,7 +102,9 @@ def _injectable(self, data: Dict[str, str], **kwargs) -> str:
         """
         _ = kwargs.get("mode", None)
         if _ not in {"push", "pull"}:
-            InjectableModeError(f"Invalid mode: {_}. Please set mode to either 'push' or 'pull'.")
+            InjectableModeError(
+                f"Invalid mode: {_}. Please set mode to either 'push' or 'pull'."
+            )
         elif _ == "push":
             return textwrap.dedent(
                 f"""
diff --git a/src/cleantest/data/pkg/charmlib.py b/src/cleantest/data/pkg/charmlib.py
index 3581bef..d1f9b5e 100644
--- a/src/cleantest/data/pkg/charmlib.py
+++ b/src/cleantest/data/pkg/charmlib.py
@@ -39,7 +39,9 @@ def __init__(
         self._auth_token = None
 
         if auth_token_path is None:
-            raise CharmlibPackageError("No authentication token for Charmhub specified.")
+            raise CharmlibPackageError(
+                "No authentication token for Charmhub specified."
+            )
 
         if charmlibs is None:
             raise CharmlibPackageError("No charm libraries specified.")
@@ -94,7 +96,9 @@ def _dump(self) -> Dict[str, str]:
                 injectable (str): Injectable to run inside remote environment.
         """
         if not self.auth_token_path.exists() or not self.auth_token_path.is_file():
-            raise FileNotFoundError(f"Could not find authentication token {self.auth_token_path}")
+            raise FileNotFoundError(
+                f"Could not find authentication token {self.auth_token_path}"
+            )
 
         self._auth_token = self.auth_token_path.read_text()
 
diff --git a/src/cleantest/data/pkg/pip.py b/src/cleantest/data/pkg/pip.py
index 580a6e3..6fed095 100644
--- a/src/cleantest/data/pkg/pip.py
+++ b/src/cleantest/data/pkg/pip.py
@@ -38,7 +38,9 @@ def __init__(
         constraints: Union[str, List[str]] = None,
     ) -> None:
         self.packages = [packages] if type(packages) == str else packages
-        self.requirements = [requirements] if type(requirements) == str else requirements
+        self.requirements = (
+            [requirements] if type(requirements) == str else requirements
+        )
         self._requirements_store = []
         self.constraints = [constraints] if type(constraints) == str else constraints
         self._constraints_store = []
@@ -92,7 +94,10 @@ def _handle_pip_install(self) -> None:
             cmd = ["python3", "-m", "pip", "install", " ".join(self.packages)]
             try:
                 subprocess.run(
-                    cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
+                    cmd,
+                    stdout=subprocess.DEVNULL,
+                    stderr=subprocess.DEVNULL,
+                    check=True,
                 )
             except subprocess.CalledProcessError:
                 raise PipPackageError(
@@ -101,7 +106,9 @@ def _handle_pip_install(self) -> None:
                 )
 
         if self._constraints_store:
-            for requirement, constraint in zip(self._requirements_store, self._constraints_store):
+            for requirement, constraint in zip(
+                self._requirements_store, self._constraints_store
+            ):
                 requirement_file = pathlib.Path.home().joinpath("requirements.txt")
                 requirement_file.write_text(requirement)
                 constraint_file = pathlib.Path.home().joinpath("constraints.txt")
@@ -118,7 +125,10 @@ def _handle_pip_install(self) -> None:
                 ]
                 try:
                     subprocess.run(
-                        cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
+                        cmd,
+                        stdout=subprocess.DEVNULL,
+                        stderr=subprocess.DEVNULL,
+                        check=True,
                     )
                 except subprocess.CalledProcessError:
                     raise PipPackageError(
@@ -130,12 +140,17 @@ def _handle_pip_install(self) -> None:
                     )
         else:
             for requirement in self._requirements_store:
-                requirement_file = pathlib.Path(pathlib.Path.home().joinpath("requirements.txt"))
+                requirement_file = pathlib.Path(
+                    pathlib.Path.home().joinpath("requirements.txt")
+                )
                 requirement_file.write_text(requirement)
                 cmd = ["python3", "-m", "pip", "install", "-r", str(requirement_file)]
                 try:
                     subprocess.run(
-                        cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
+                        cmd,
+                        stdout=subprocess.DEVNULL,
+                        stderr=subprocess.DEVNULL,
+                        check=True,
                     )
                 except subprocess.CalledProcessError:
                     raise PipPackageError(
@@ -161,14 +176,18 @@ def _dump(self) -> Dict[str, str]:
             for requirement in self.requirements:
                 fin = pathlib.Path(requirement)
                 if not fin.exists() or not fin.is_file():
-                    raise FileNotFoundError(f"Could not find requirements file {requirement}.")
+                    raise FileNotFoundError(
+                        f"Could not find requirements file {requirement}."
+                    )
                 self._requirements_store.append(fin.read_text())
 
         if self.constraints is not None:
             for constraint in self.constraints:
                 fin = pathlib.Path(constraint)
                 if not fin.exists() or not fin.is_file():
-                    raise FileNotFoundError(f"Could not find constraints file {constraint}.")
+                    raise FileNotFoundError(
+                        f"Could not find constraints file {constraint}."
+                    )
                 self._constraints_store.append(fin.read_text())
 
         return super()._dump()
diff --git a/src/cleantest/data/pkg/snap.py b/src/cleantest/data/pkg/snap.py
index c44dd0f..3eae715 100644
--- a/src/cleantest/data/pkg/snap.py
+++ b/src/cleantest/data/pkg/snap.py
@@ -77,7 +77,11 @@ def _lint(self) -> None:
                 f"Invalid plug: {self._plug.__dict__}. "
                 "Plug must have an associated snap and name."
             )
-        if self._slot is not None and self._slot.snap is None and self._slot.name is None:
+        if (
+            self._slot is not None
+            and self._slot.snap is None
+            and self._slot.name is None
+        ):
             raise SnapPackageError(
                 f"Invalid slot: {self._slot.__dict__}. "
                 "Slot must at least have an associated snap or name."
@@ -107,7 +111,9 @@ class Alias:
         SnapPackageError: Raised if lint rule fails.
     """
 
-    def __init__(self, snap_name: str, app_name: str, alias_name: str, wait: bool = True) -> None:
+    def __init__(
+        self, snap_name: str, app_name: str, alias_name: str, wait: bool = True
+    ) -> None:
         self._snap_name = snap_name
         self._app_name = app_name
         self._alias_name = alias_name
@@ -116,9 +122,17 @@ def __init__(self, snap_name: str, app_name: str, alias_name: str, wait: bool =
 
     def _lint(self) -> None:
         """Lint inputs passed to class constructor."""
-        if self._snap_name is None or self._app_name is None or self._alias_name is None:
+        if (
+            self._snap_name is None
+            or self._app_name is None
+            or self._alias_name is None
+        ):
             holder = ", ".join(
-                [f"{key} = {value}" for key, value in self.__dict__.items() if value is None]
+                [
+                    f"{key} = {value}"
+                    for key, value in self.__dict__.items()
+                    if value is None
+                ]
             )
             raise SnapPackageError(f"Invalid alias: {holder} cannot be None.")
 
@@ -229,7 +243,9 @@ def _dump(self) -> Dict[str, str]:
             for local_snap in self.local_snaps:
                 snap_path = pathlib.Path(local_snap)
                 if not snap_path.exists() or not snap_path.is_file():
-                    raise FileNotFoundError(f"Could not find local snap package {snap_path}")
+                    raise FileNotFoundError(
+                        f"Could not find local snap package {snap_path}"
+                    )
                 self._cached_local_snaps.add(snap_path.read_bytes())
 
         return super()._dump()
diff --git a/src/cleantest/meta/cleantest_info.py b/src/cleantest/meta/cleantest_info.py
index a4e068d..9724ee3 100644
--- a/src/cleantest/meta/cleantest_info.py
+++ b/src/cleantest/meta/cleantest_info.py
@@ -58,7 +58,9 @@ def _dependencies(self) -> Iterable[Tuple[str, bytes]]:
                 for k, v in res.items():
                     yield k, v
 
-    def _dependency_processor(self, dependency: pkg_resources.Distribution) -> Dict[str, bytes]:
+    def _dependency_processor(
+        self, dependency: pkg_resources.Distribution
+    ) -> Dict[str, bytes]:
         """Collect source code of cleantest dependency.
 
         Args:
diff --git a/src/cleantest/meta/injectable.py b/src/cleantest/meta/injectable.py
index 25e6dfd..8b5a813 100644
--- a/src/cleantest/meta/injectable.py
+++ b/src/cleantest/meta/injectable.py
@@ -37,8 +37,12 @@ def _load(cls, checksum: str, data: str) -> object:
             raise InjectionError("Hashes do not match. Will not load untrusted object.")
 
         _ = pickle.loads(_)
-        posargs = [value for key, value in _.__dict__.items() if not key.startswith("_")]
-        hiddenargs = {key: value for key, value in _.__dict__.items() if key.startswith("_")}
+        posargs = [
+            value for key, value in _.__dict__.items() if not key.startswith("_")
+        ]
+        hiddenargs = {
+            key: value for key, value in _.__dict__.items() if key.startswith("_")
+        }
         new_cls = cls(*posargs)
         [setattr(new_cls, key, value) for key, value in hiddenargs.items()]
         return new_cls
@@ -58,7 +62,9 @@ def _dump(self, **kwargs) -> Dict[str, str]:
         return {
             "checksum": checksum,
             "data": data,
-            "injectable": self._injectable({"checksum": checksum, "data": data}, **kwargs),
+            "injectable": self._injectable(
+                {"checksum": checksum, "data": data}, **kwargs
+            ),
         }
 
     @abstractmethod
diff --git a/src/cleantest/meta/mixins/snapd_support.py b/src/cleantest/meta/mixins/snapd_support.py
index e781e3e..3a335c4 100644
--- a/src/cleantest/meta/mixins/snapd_support.py
+++ b/src/cleantest/meta/mixins/snapd_support.py
@@ -4,7 +4,6 @@
 
 """Common operations needed by classes to support snapd and snap."""
 
-import subprocess
 from shutil import which
 
 from cleantest.meta.utils import detect_os_variant
diff --git a/src/cleantest/provider/lxd/lxd.py b/src/cleantest/provider/lxd/lxd.py
index 6fa5362..4e0d104 100644
--- a/src/cleantest/provider/lxd/lxd.py
+++ b/src/cleantest/provider/lxd/lxd.py
@@ -49,7 +49,9 @@ def __init__(
         if (type(num_threads) != int or num_threads < 1) and self._parallel is True:
             env_var = os.getenv("CLEANTEST_NUM_THREADS")
             self._num_threads = (
-                env_var if env_var is not None and type(env_var) == int else os.cpu_count()
+                env_var
+                if env_var is not None and type(env_var) == int
+                else os.cpu_count()
             )
         elif type(num_threads) == int and self._parallel is True:
             self._num_threads = num_threads
diff --git a/src/cleantest/provider/lxd/lxd_handler.py b/src/cleantest/provider/lxd/lxd_handler.py
index 5de26f9..8b55670 100644
--- a/src/cleantest/provider/lxd/lxd_handler.py
+++ b/src/cleantest/provider/lxd/lxd_handler.py
@@ -69,7 +69,9 @@ def _instance_metadata(self) -> List[InstanceMetadata]:
         Returns:
             (List[InstanceMetadata]): List of metaclasses.
         """
-        return [InstanceMetadata(name=f"{self._name}-{i}", image=i) for i in self._image]
+        return [
+            InstanceMetadata(name=f"{self._name}-{i}", image=i) for i in self._image
+        ]
 
     def _build(self, instance: InstanceMetadata) -> None:
         """Build LXD test environment instance.
@@ -137,7 +139,9 @@ def _process(self, result: Any) -> Result:
         Returns:
             (Result): Processed result.
         """
-        return Result(exit_code=result.exit_code, stdout=result.stdout, stderr=result.stderr)
+        return Result(
+            exit_code=result.exit_code, stdout=result.stdout, stderr=result.stderr
+        )
 
     def _exists(self, instance: InstanceMetadata) -> InstanceMetadata:
         """Check whether an instance exists.
@@ -227,8 +231,10 @@ def _handle_artifact_download(self, instance: Any, artifact: Injectable) -> None
             "/root/post/data/load",
             dump_data["injectable"],
         )
-        result = json.loads(instance.execute(["python3", "/root/post/data/load"]).stdout)
-        with tempfile.NamedTemporaryFile() as fout:
+        result = json.loads(
+            instance.execute(["python3", "/root/post/data/load"]).stdout
+        )
+        with tempfile.NamedTemporaryFile():
             holder = artifact.__class__._load(result["checksum"], result["data"])
             holder.dump()
 
@@ -257,7 +263,9 @@ def run(self) -> Dict[str, Result]:
             self._build(self._exists(i))
             self._handle_start_env_hooks(i)
             result = self._execute(
-                self._make_testlet(self._func, self._func_name, [re.compile(r"^@lxd\(([^)]+)\)")]),
+                self._make_testlet(
+                    self._func, self._func_name, [re.compile(r"^@lxd\(([^)]+)\)")]
+                ),
                 i,
             )
             self._handle_stop_env_hooks(i)
@@ -309,7 +317,9 @@ def _target(self, i: InstanceMetadata) -> Dict[str, Result]:
         self._build(self._exists(i))
         self._handle_start_env_hooks(i)
         result = self._execute(
-            self._make_testlet(self._func, self._func_name, [re.compile(r"^@lxd\(([^)]+)\)")]),
+            self._make_testlet(
+                self._func, self._func_name, [re.compile(r"^@lxd\(([^)]+)\)")]
+            ),
             i,
         )
         self._handle_stop_env_hooks(i)
diff --git a/src/cleantest/utils/apt_handler.py b/src/cleantest/utils/apt_handler.py
index d3ade1d..0e4f425 100644
--- a/src/cleantest/utils/apt_handler.py
+++ b/src/cleantest/utils/apt_handler.py
@@ -80,7 +80,9 @@ def update() -> None:
     _is_apt_available()
     try:
         subprocess.check_call(
-            ["apt", "-y", "update"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
+            ["apt", "-y", "update"],
+            stdout=subprocess.DEVNULL,
+            stderr=subprocess.DEVNULL,
         )
     except subprocess.CalledProcessError as e:
         raise AptHandlerError(f"Failed to update apt cache. Reason: {e}.")
@@ -111,7 +113,9 @@ def install(*packages: str, version: Optional[str] = None) -> None:
             optargs=["--option=Dpkg::Options::=--force-confold"],
         )
     else:
-        _apt("install", [*packages], optargs=["--option=Dpkg::Options::=--force-confold"])
+        _apt(
+            "install", [*packages], optargs=["--option=Dpkg::Options::=--force-confold"]
+        )
 
 
 def install_local(*package_paths: str) -> None:
@@ -122,7 +126,9 @@ def install_local(*package_paths: str) -> None:
     for path in package_paths:
         if not pathlib.Path(path).exists():
             raise FileNotFoundError(f"Could not locate deb archive {path}.")
-        _apt("install", path, optargs=["--option=Dpkg::Options::=--force-confold", "-f"])
+        _apt(
+            "install", path, optargs=["--option=Dpkg::Options::=--force-confold", "-f"]
+        )
 
 
 def remove(*packages: str) -> None:
diff --git a/src/cleantest/utils/run.py b/src/cleantest/utils/run.py
index f49fbea..6bc30d1 100644
--- a/src/cleantest/utils/run.py
+++ b/src/cleantest/utils/run.py
@@ -44,6 +44,8 @@ def run(
                 check=True,
             )
         except subprocess.CalledProcessError as e:
-            raise CommandExecutionError(f"Failed to execute command {command}. Reason: {e}.")
+            raise CommandExecutionError(
+                f"Failed to execute command {command}. Reason: {e}."
+            )
 
         yield Result(res.returncode, res.stdout, res.stderr)
diff --git a/src/cleantest/utils/snap_handler.py b/src/cleantest/utils/snap_handler.py
index fd362de..a1311b5 100644
--- a/src/cleantest/utils/snap_handler.py
+++ b/src/cleantest/utils/snap_handler.py
@@ -198,9 +198,13 @@ def _snap_daemons(
         _cmd = ["snap", *command, *services]
 
         try:
-            return subprocess.run(_cmd, universal_newlines=True, check=True, capture_output=True)
+            return subprocess.run(
+                _cmd, universal_newlines=True, check=True, capture_output=True
+            )
         except CalledProcessError as e:
-            raise SnapHandlerError(f"Could not {_cmd} for snap [{self._name}]: {e.stderr}")
+            raise SnapHandlerError(
+                f"Could not {_cmd} for snap [{self._name}]: {e.stderr}"
+            )
 
     def get(self, key) -> str:
         """Gets a snap configuration value.
@@ -367,7 +371,9 @@ def ensure(
             SnapHandlerError: Raised if an error is encountered while
                 ensuring that the snap is in a given state.
         """
-        self._confinement = "classic" if classic or self._confinement == "classic" else ""
+        self._confinement = (
+            "classic" if classic or self._confinement == "classic" else ""
+        )
 
         if state not in (SnapState.PRESENT, SnapState.LATEST):
             # We are attempting to remove this snap.
@@ -495,7 +501,9 @@ def services(self) -> Dict:
 class _UnixSocketConnection(http.client.HTTPConnection):
     """Implementation of HTTPConnection that connects to a named Unix socket."""
 
-    def __init__(self, host: str, timeout: float = None, socket_path: Any = None) -> None:
+    def __init__(
+        self, host: str, timeout: float = None, socket_path: Any = None
+    ) -> None:
         if timeout is None:
             super().__init__(host)
         else:
@@ -648,7 +656,9 @@ class SnapCache(Mapping):
 
     def __init__(self):
         if not self.snapd_installed:
-            raise SnapHandlerError("snapd is not installed or not in /usr/bin") from None
+            raise SnapHandlerError(
+                "snapd is not installed or not in /usr/bin"
+            ) from None
         self._snap_client = SnapClient()
         self._snap_map = {}
         if self.snapd_installed:
@@ -843,14 +853,16 @@ def _wrap_snap_operations(
             if state is SnapState.ABSENT:
                 snap.ensure(state=SnapState.ABSENT)
             else:
-                snap.ensure(state=state, classic=classic, channel=channel, cohort=cohort)
+                snap.ensure(
+                    state=state, classic=classic, channel=channel, cohort=cohort
+                )
             snaps["success"].append(snap)
         except SnapHandlerError:
             snaps["failed"].append(s)
 
     if len(snaps["failed"]):
         raise SnapHandlerError(
-            f"Failed to install or refresh snap(s): {', '.join([s for s in snaps['failed']])}"
+            f"Failed to install or refresh snap(s): {', '.join(list(snaps['failed']))}"
         )
 
     return snaps["success"] if len(snaps["success"]) > 1 else snaps["success"][0]
@@ -920,7 +932,9 @@ def alias(alias_snap: str, app: str, alias: str, wait: bool = True) -> None:
     try:
         subprocess.check_output(_cmd, universal_newlines=True)
     except subprocess.CalledProcessError:
-        raise SnapHandlerError(f"Failed to create alias. Command used: {' '.join(_cmd)}")
+        raise SnapHandlerError(
+            f"Failed to create alias. Command used: {' '.join(_cmd)}"
+        )
 
 
 def unalias(alias_snap: str, wait: bool = True) -> None:
@@ -942,11 +956,17 @@ def unalias(alias_snap: str, wait: bool = True) -> None:
     try:
         subprocess.check_output(_cmd, universal_newlines=True)
     except subprocess.CalledProcessError:
-        raise SnapHandlerError(f"Failed to destroy alias. Command used: {' '.join(_cmd)}")
+        raise SnapHandlerError(
+            f"Failed to destroy alias. Command used: {' '.join(_cmd)}"
+        )
 
 
 def connect(
-    plug: str, plug_snap: str, slot_snap: str = None, slot: str = None, wait: bool = True
+    plug: str,
+    plug_snap: str,
+    slot_snap: str = None,
+    slot: str = None,
+    wait: bool = True,
 ) -> None:
     """Connect a snap plug to a slot.
 
@@ -1016,7 +1036,9 @@ def disconnect(
     try:
         subprocess.check_output(_cmd, universal_newlines=True)
     except subprocess.CalledProcessError:
-        raise SnapHandlerError(f"Failed to create alias. Command used: {' '.join(_cmd)}")
+        raise SnapHandlerError(
+            f"Failed to create alias. Command used: {' '.join(_cmd)}"
+        )
 
 
 def _system_set(config_item: str, value: str) -> None:
@@ -1033,7 +1055,9 @@ def _system_set(config_item: str, value: str) -> None:
     try:
         subprocess.check_call(_cmd, universal_newlines=True)
     except CalledProcessError:
-        raise SnapHandlerError(f"Failed setting system config '{config_item}' to '{value}'")
+        raise SnapHandlerError(
+            f"Failed setting system config '{config_item}' to '{value}'"
+        )
 
 
 def hold_refresh(days: int = 90) -> None:
diff --git a/tests/files/test_upload_download.py b/tests/files/test_upload_download.py
deleted file mode 100644
index 39fc7d2..0000000
--- a/tests/files/test_upload_download.py
+++ /dev/null
@@ -1,53 +0,0 @@
-#!/usr/bin/env python3
-# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
-# See LICENSE file for licensing details.
-
-"""Test that cleantest can successfully upload and download files."""
-
-import os
-import pathlib
-import tempfile
-
-from cleantest.control import Configure
-from cleantest.control.hooks import StartEnvHook, StopEnvHook
-from cleantest.data import Dir, File
-from cleantest.provider import lxd
-
-root = os.path.dirname(os.path.realpath(__file__))
-config = Configure()
-start_hook = StartEnvHook(
-    name="upload_artifact",
-    upload=[
-        File(os.path.join(root, "greeting.txt"), "/root/greeting.txt"),
-        Dir(os.path.join(root, "greetings"), "/root/greetings"),
-    ],
-)
-stop_hook = StopEnvHook(
-    name="download_artifact",
-    download=[
-        File("/root/dump.txt", os.path.join(tempfile.gettempdir(), "dump.txt"), overwrite=True),
-        Dir("/root/dump", os.path.join(tempfile.gettempdir(), "dump"), overwrite=True),
-    ],
-)
-config.register_hook(start_hook, stop_hook)
-
-
-@lxd(image="ubuntu-jammy-amd64", preserve=True)
-def work_on_artifacts():
-    import os
-    import pathlib
-    import sys
-
-    print(pathlib.Path("/root/greeting.txt").is_file(), file=sys.stdout)
-    print(pathlib.Path("/root/dump").is_dir(), file=sys.stdout)
-
-    pathlib.Path("/root/dump.txt").write_text("Dumped like a sack of rocks")
-    os.mkdir("/root/dump")
-    pathlib.Path("/root/dump/dump_1.txt").write_text("Oh I have been dumped again!")
-
-
-class TestUploadDownload:
-    def test_upload_download(self) -> None:
-        work_on_artifacts()
-        assert pathlib.Path(tempfile.gettempdir()).joinpath("dump.txt").is_file() is True
-        assert pathlib.Path(tempfile.gettempdir()).joinpath("dump").is_dir() is True
diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py
new file mode 100644
index 0000000..5693011
--- /dev/null
+++ b/tests/functional/conftest.py
@@ -0,0 +1,15 @@
+#!/usr/bin/env python3
+# Copyright 2023 Jason C. Nucciarone
+# See LICENSE file for licensing details.
+
+"""Configure functional test run."""
+
+import pytest
+from cleantest.control import Configure, Env
+
+
+@pytest.fixture(autouse=True, scope="function")
+def clean_slate() -> None:
+    """Clean up state trackers before each test function is run."""
+    Configure("lxd").reset()
+    Env().reset()
diff --git a/tests/files/greeting.txt b/tests/functional/files/greeting.txt
similarity index 100%
rename from tests/files/greeting.txt
rename to tests/functional/files/greeting.txt
diff --git a/tests/files/greetings/greeting_1.txt b/tests/functional/files/greetings/greeting_1.txt
similarity index 100%
rename from tests/files/greetings/greeting_1.txt
rename to tests/functional/files/greetings/greeting_1.txt
diff --git a/tests/files/greetings/greeting_2.txt b/tests/functional/files/greetings/greeting_2.txt
similarity index 100%
rename from tests/files/greetings/greeting_2.txt
rename to tests/functional/files/greetings/greeting_2.txt
diff --git a/tests/files/greetings/greeting_3.txt b/tests/functional/files/greetings/greeting_3.txt
similarity index 100%
rename from tests/files/greetings/greeting_3.txt
rename to tests/functional/files/greetings/greeting_3.txt
diff --git a/tests/functional/files/test_upload_download.py b/tests/functional/files/test_upload_download.py
new file mode 100644
index 0000000..6a4d905
--- /dev/null
+++ b/tests/functional/files/test_upload_download.py
@@ -0,0 +1,59 @@
+#!/usr/bin/env python3
+# Copyright 2023 Jason C. Nucciarone
+# See LICENSE file for licensing details.
+
+"""Test that cleantest can successfully upload and download files."""
+
+import os
+import pathlib
+import tempfile
+
+from cleantest.control import Configure
+from cleantest.control.hooks import StartEnvHook, StopEnvHook
+from cleantest.data import Dir, File
+from cleantest.provider import lxd
+
+
+@lxd(image="ubuntu-jammy-amd64", preserve=False)
+def work_on_artifacts():
+    import os
+    import pathlib
+    import sys
+
+    print(pathlib.Path("/root/greeting.txt").is_file(), file=sys.stdout)
+    print(pathlib.Path("/root/dump").is_dir(), file=sys.stdout)
+
+    pathlib.Path("/root/dump.txt").write_text("Dumped like a sack of rocks")
+    os.mkdir("/root/dump")
+    pathlib.Path("/root/dump/dump_1.txt").write_text("Oh I have been dumped again!")
+
+
+def test_upload_download(clean_slate) -> None:
+    root = os.path.dirname(os.path.realpath(__file__))
+    config = Configure("lxd")
+    start_hook = StartEnvHook(
+        name="upload_artifact",
+        upload=[
+            File(os.path.join(root, "greeting.txt"), "/root/greeting.txt"),
+            Dir(os.path.join(root, "greetings"), "/root/greetings"),
+        ],
+    )
+    stop_hook = StopEnvHook(
+        name="download_artifact",
+        download=[
+            File(
+                "/root/dump.txt",
+                os.path.join(tempfile.gettempdir(), "dump.txt"),
+                overwrite=True,
+            ),
+            Dir(
+                "/root/dump",
+                os.path.join(tempfile.gettempdir(), "dump"),
+                overwrite=True,
+            ),
+        ],
+    )
+    config.register_hook(start_hook, stop_hook)
+    work_on_artifacts()
+    assert pathlib.Path(tempfile.gettempdir()).joinpath("dump.txt").is_file() is True
+    assert pathlib.Path(tempfile.gettempdir()).joinpath("dump").is_dir() is True
diff --git a/tests/lxd/local/requirements.txt b/tests/functional/lxd/local/requirements.txt
similarity index 100%
rename from tests/lxd/local/requirements.txt
rename to tests/functional/lxd/local/requirements.txt
diff --git a/tests/lxd/local/test_lxd_local.py b/tests/functional/lxd/local/test_lxd_local.py
similarity index 64%
rename from tests/lxd/local/test_lxd_local.py
rename to tests/functional/lxd/local/test_lxd_local.py
index 78fb7c2..7f9a311 100644
--- a/tests/lxd/local/test_lxd_local.py
+++ b/tests/functional/lxd/local/test_lxd_local.py
@@ -1,5 +1,5 @@
 #!/usr/bin/env python3
-# Copyright 2022 Canonical Ltd.
+# Copyright 2023 Jason C. Nucciarone
 # See LICENSE file for licensing details.
 
 """Test LXD environment capabilities using local LXD cluster."""
@@ -11,23 +11,7 @@
 from cleantest.data.pkg import Charmlib, Pip
 from cleantest.provider import lxd
 
-# Define the hooks and register them.
-root = os.path.dirname(os.path.realpath(__file__))
-lxd_provider_config = Configure("lxd")
-startenv_hook = StartEnvHook(
-    name="setup_deps",
-    packages=[
-        Charmlib(
-            auth_token_path=os.path.join(root, "charmhub.secret"),
-            charmlibs=["charms.operator_libs_linux.v0.apt"],
-        ),
-        Pip(requirements=os.path.join(root, "requirements.txt")),
-    ],
-)
-lxd_provider_config.register_hook(startenv_hook)
 
-
-# Define the testlets.
 @lxd(image="ubuntu-jammy-amd64", preserve=False)
 def install_snapd():
     import sys
@@ -63,9 +47,20 @@ def install_snapd():
     sys.exit(0)
 
 
-# Run the tests through pytest.
-class TestLocalLXD:
-    def test_local_lxd(self) -> None:
-        results = install_snapd()
-        for name, result in results.items():
-            assert result.exit_code == 0
+def test_local_lxd(clean_slate) -> None:
+    root = os.path.dirname(os.path.realpath(__file__))
+    config = Configure("lxd")
+    start_hook = StartEnvHook(
+        name="setup_deps",
+        packages=[
+            Charmlib(
+                auth_token_path=os.path.join(root, "charmhub.secret"),
+                charmlibs=["charms.operator_libs_linux.v0.apt"],
+            ),
+            Pip(requirements=os.path.join(root, "requirements.txt")),
+        ],
+    )
+    config.register_hook(start_hook)
+    results = install_snapd()
+    for name, result in results.items():
+        assert result.exit_code == 0
diff --git a/tests/lxd/parallel/test_lxd_parallel.py b/tests/functional/lxd/parallel/test_lxd_parallel.py
similarity index 58%
rename from tests/lxd/parallel/test_lxd_parallel.py
rename to tests/functional/lxd/parallel/test_lxd_parallel.py
index 8ba856f..95ac817 100644
--- a/tests/lxd/parallel/test_lxd_parallel.py
+++ b/tests/functional/lxd/parallel/test_lxd_parallel.py
@@ -1,5 +1,5 @@
 #!/usr/bin/env python3
-# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
+# Copyright 2023 Jason C. Nucciarone
 # See LICENSE file for licensing details.
 
 """Test parallel testing capabilities of LXD provider using local LXD cluster."""
@@ -9,10 +9,6 @@
 from cleantest.data.pkg import Pip
 from cleantest.provider import lxd
 
-cleantest_config = Configure()
-start_hook = StartEnvHook(name="pip_injection", packages=[Pip(packages="tabulate")])
-cleantest_config.register_hook(start_hook)
-
 
 @lxd(
     image=["ubuntu-jammy-amd64", "ubuntu-focal-amd64", "ubuntu-bionic-amd64"],
@@ -34,11 +30,13 @@ def install_tabulate():
     sys.exit(0)
 
 
-class TestParallelLXD:
-    def test_parallel_lxd(self) -> None:
-        results = install_tabulate()
-        for name, result in results.items():
-            try:
-                assert result.exit_code == 0
-            except AssertionError:
-                raise Exception(f"{name} failed. Result: {result}")
+def test_parallel_lxd(clean_slate) -> None:
+    config = Configure("lxd")
+    start_hook = StartEnvHook(name="pip_injection", packages=[Pip(packages="tabulate")])
+    config.register_hook(start_hook)
+    results = install_tabulate()
+    for name, result in results.items():
+        try:
+            assert result.exit_code == 0
+        except AssertionError:
+            raise Exception(f"{name} failed. Result: {result}")
diff --git a/tests/functional/packages/snap/test_snap.py b/tests/functional/packages/snap/test_snap.py
new file mode 100644
index 0000000..435b051
--- /dev/null
+++ b/tests/functional/packages/snap/test_snap.py
@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+# Copyright 2023 Jason C. Nucciarone
+# See LICENSE file for licensing details.
+
+"""Test that cleantest can successfully install and operate snap packages."""
+
+import os
+
+from cleantest.control import Configure
+from cleantest.control.hooks import StartEnvHook
+from cleantest.data.pkg import Connection, Plug, Slot, Snap
+from cleantest.provider import lxd
+
+
+@lxd(image="ubuntu-jammy-amd64", preserve=False)
+def functional_snaps():
+    import sys
+    from shutil import which
+
+    if which("pypi-server") is None:
+        sys.exit(1)
+    elif which("marktext") is None:
+        sys.exit(1)
+    else:
+        sys.exit(0)
+
+
+def test_snap_package(clean_slate) -> None:
+    root = os.path.dirname(os.path.realpath(__file__))
+    config = Configure("lxd")
+    start_hook = StartEnvHook(
+        name="test_snaps",
+        packages=[
+            Snap(
+                snaps="pypi-server",
+                connections=[
+                    Connection(
+                        Plug("pypi-server", "removable-media"),
+                        Slot(name="removable-media"),
+                    )
+                ],
+            ),
+            Snap(
+                local_snaps=os.path.join(root, "marktext.snap"),
+                dangerous=True,
+            ),
+        ],
+    )
+    config.register_hook(start_hook)
+    results = functional_snaps()
+    for name, result in results.items():
+        assert result.exit_code == 0
diff --git a/tests/packages/snap/test_snap.py b/tests/packages/snap/test_snap.py
deleted file mode 100644
index 56c2a0c..0000000
--- a/tests/packages/snap/test_snap.py
+++ /dev/null
@@ -1,48 +0,0 @@
-#!/usr/bin/env python3
-# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
-# See LICENSE file for licensing details.
-
-"""Test that cleantest can successfully install and operate snap packages."""
-
-import os
-
-from cleantest.control import Configure
-from cleantest.control.hooks import StartEnvHook
-from cleantest.data.pkg import Connection, Plug, Slot, Snap
-from cleantest.provider import lxd
-
-root = os.path.dirname(os.path.realpath(__file__))
-config = Configure()
-start_hook = StartEnvHook(
-    name="test_snaps",
-    packages=[
-        Snap(
-            snaps="pypi-server",
-            connections=[
-                Connection(Plug("pypi-server", "removable-media"), Slot(name="removable-media"))
-            ],
-        ),
-        Snap(local_snaps=os.path.join(root, "hello-world-gtk_0.1_amd64.snap"), dangerous=True),
-    ],
-)
-config.register_hook(start_hook)
-
-
-@lxd(image="ubuntu-jammy-amd64", preserve=False)
-def functional_snaps():
-    import sys
-    from shutil import which
-
-    if which("pypi-server") is None:
-        sys.exit(1)
-    elif which("hello-world-gtk") is None:
-        sys.exit(1)
-    else:
-        sys.exit(0)
-
-
-class TestLocalLXD:
-    def test_snap_package(self) -> None:
-        results = functional_snaps()
-        for name, result in results.items():
-            assert result.exit_code == 0