Repository: NucciTheBoss/cleantest (language: python)

Issue:
On second thought, `cleantest` needs to support versions of Python
Cleantest works on newer, fancy Linux distributions, but it leaves older distributions in the dust because they do not have newer versions of Python available. Here is the question now: __how do I support older versions of Python__?

Originally, I created cleantest with the goal of testing destructive Python code, but now it has branched out into testing packages as well. I did not really think about what supporting older distributions, but now I should. My plan is to go as far back as Python 3.6. Therefore, cleantest will need to work on the following distributions of Ubuntu:

- [x] Bionic
- [x] Focal
- [x] Jammy

I considered Xenial, but `pylxd` is not supported on Python 3.5 (the default version of Python on Xenial). I also considered using snaps to support cleantest's Python version requirements, but I realized this defeats the purpose of cleantest; to test destructive Python code on a variety of hosts.

`cleantest` will need to be refactored to pass unit tests in Python 3.6 and up. Bionic will be the oldest distribution of Ubuntu guaranteed.


Discussion / hints:
It will not hurt to test Python 3.5, but `pylxd` is not guaranteed to work which is the biggest inhibitor to supporting Xenial

Fix patch:
diff --git a/README.md b/README.md
index ea3f46b..6a1a15c 100644
--- a/README.md
+++ b/README.md
@@ -122,7 +122,7 @@ Here are my (NucciTheBoss's) goals to get cleantest to release version 1.0.0:
   * Rocky
   * Fedora
   * Arch
-* Enable support for running parallel tests with LXD
+* ~~Enable support for running parallel tests with LXD~~
 * Better test logging capabilities
 * Support for a select few popular packaging formats:
   * ~~Snap~~


Test patch:
diff --git a/src/cleantest/control/configurator.py b/src/cleantest/control/configurator.py
index af41dbc..0dd49fe 100644
--- a/src/cleantest/control/configurator.py
+++ b/src/cleantest/control/configurator.py
@@ -1,15 +1,12 @@
 #!/usr/bin/env python3
-# Copyright 2022 Canonical Ltd.
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
 # See LICENSE file for licensing details.
 
 """Manage the state and flow of cleantest."""
 
-from __future__ import annotations
-
+import copy
 from collections import deque
-from typing import Deque
-
-from pydantic import BaseModel
+from typing import Deque, Union
 
 from cleantest.hooks import StartEnvHook, StartTestletHook, StopEnvHook, StopTestletHook
 
@@ -18,7 +15,7 @@ class DuplicateHookNameError(Exception):
     ...
 
 
-class HookRegistry(BaseModel):
+class HookRegistry:
     start_env: Deque[StartEnvHook] = deque()
     stop_env: Deque[StopEnvHook] = deque()
     start_testlet: Deque[StartTestletHook] = deque()
@@ -36,7 +33,7 @@ def __new__(cls) -> "Configure":
         return cls.instance
 
     def register_hook(
-        self, hook: StartEnvHook | StopEnvHook | StartTestletHook | StopTestletHook
+        self, hook: Union[StartEnvHook, StopEnvHook, StartTestletHook, StopTestletHook]
     ) -> None:
         lint = [
             h for h in self.__metadata if hook.name == h[0] and hook.__class__.__name__ == h[1]
@@ -67,16 +64,16 @@ def deregister_hook(self, hook_name: str) -> None:
                 dispatch[hook[1]].remove(hook_name)
 
     def get_start_env_hooks(self) -> Deque[StartEnvHook]:
-        return self.__hook_registry.start_env
+        return copy.deepcopy(self.__hook_registry.start_env)
 
     def get_stop_env_hooks(self) -> Deque[StopEnvHook]:
-        return self.__hook_registry.stop_env
+        return copy.deepcopy(self.__hook_registry.stop_env)
 
     def get_start_testlet_hooks(self) -> Deque[StartTestletHook]:
-        return self.__hook_registry.start_testlet
+        return copy.deepcopy(self.__hook_registry.start_testlet)
 
     def get_stop_testlet_hooks(self) -> Deque[StopTestletHook]:
-        return self.__hook_registry.stop_testlet
+        return copy.deepcopy(self.__hook_registry.stop_testlet)
 
     @property
     def _hook_registry(self) -> HookRegistry:
diff --git a/src/cleantest/hooks/startenv.py b/src/cleantest/hooks/startenv.py
index c124127..6701279 100644
--- a/src/cleantest/hooks/startenv.py
+++ b/src/cleantest/hooks/startenv.py
@@ -4,13 +4,10 @@
 
 """Hook run when test environment first starts."""
 
-from __future__ import annotations
-
 from typing import List
 
-from pydantic import BaseModel
-
 
-class StartEnvHook(BaseModel):
-    name: str = "default"
-    packages: List[object] = []
+class StartEnvHook:
+    def __init__(self, name: str = "default", packages: List[object] = []):
+        self.name = name
+        self.packages = packages
diff --git a/src/cleantest/hooks/starttestlet.py b/src/cleantest/hooks/starttestlet.py
index b269b3c..c661a5a 100644
--- a/src/cleantest/hooks/starttestlet.py
+++ b/src/cleantest/hooks/starttestlet.py
@@ -4,11 +4,9 @@
 
 """Hook run before testlet starts but after StartEnv hook."""
 
-from pydantic import BaseModel
 
-
-class StartTestletHook(BaseModel):
+class StartTestletHook:
     """Not implemented yet as I do not know if hooks are the move."""
 
-    def __init__(self) -> None:
+    def __init__(self):
         raise NotImplementedError("Hook not implemented yet.")
diff --git a/src/cleantest/hooks/stopenv.py b/src/cleantest/hooks/stopenv.py
index dc0e8cd..a479507 100644
--- a/src/cleantest/hooks/stopenv.py
+++ b/src/cleantest/hooks/stopenv.py
@@ -4,11 +4,9 @@
 
 """Hook run before test environment stops."""
 
-from pydantic import BaseModel
 
-
-class StopEnvHook(BaseModel):
+class StopEnvHook:
     """Not implemented yet as I do not know if hooks are the move."""
 
-    def __init__(self) -> None:
+    def __init__(self):
         raise NotImplementedError("Hook not implemented yet.")
diff --git a/src/cleantest/hooks/stoptestlet.py b/src/cleantest/hooks/stoptestlet.py
index 984de77..f69c3c4 100644
--- a/src/cleantest/hooks/stoptestlet.py
+++ b/src/cleantest/hooks/stoptestlet.py
@@ -4,11 +4,9 @@
 
 """Hook run after testlet finishes but before StopEnv hook."""
 
-from pydantic import BaseModel
 
-
-class StopTestletHook(BaseModel):
+class StopTestletHook:
     """Not implemented yet as I do not know if hooks are the move."""
 
-    def __init__(self) -> None:
+    def __init__(self):
         raise NotImplementedError("Hook not implemented yet.")
diff --git a/src/cleantest/pkg/_base.py b/src/cleantest/pkg/_base.py
index 6790b37..8aeab0a 100644
--- a/src/cleantest/pkg/_base.py
+++ b/src/cleantest/pkg/_base.py
@@ -4,8 +4,6 @@
 
 """Base package class for installing packages inside remote processes."""
 
-from __future__ import annotations
-
 import hashlib
 import os
 import pathlib
diff --git a/src/cleantest/pkg/charmlib.py b/src/cleantest/pkg/charmlib.py
index 5e2fe8f..7226c29 100644
--- a/src/cleantest/pkg/charmlib.py
+++ b/src/cleantest/pkg/charmlib.py
@@ -1,15 +1,13 @@
 #!/usr/bin/env python3
-# Copyright 2022 Canonical Ltd.
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
 # See LICENSE file for licensing details.
 
 """Manager for installing charm libraries inside remote processes."""
 
-from __future__ import annotations
-
 import json
 import subprocess
 from shutil import which
-from typing import List
+from typing import List, Union
 
 from cleantest.pkg._base import Package, PackageError
 from cleantest.utils import detect_os_variant
@@ -19,7 +17,7 @@ class Charmlib(Package):
     def __init__(
         self,
         auth_token_path: str = None,
-        charmlibs: str | List[str] = None,
+        charmlibs: Union[str, List[str]] = None,
         _manager: "Charmlib" = None,
     ) -> None:
         if _manager is None:
diff --git a/src/cleantest/pkg/handler/__init__.py b/src/cleantest/pkg/handler/__init__.py
index 5fb0081..754913c 100644
--- a/src/cleantest/pkg/handler/__init__.py
+++ b/src/cleantest/pkg/handler/__init__.py
@@ -2,4 +2,4 @@
 # Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
 # See LICENSE file for licensing details.
 
-import cleantest.pkg.handler.snap_handler as snap
+from . import snap_handler as snap
diff --git a/src/cleantest/pkg/handler/snap_handler.py b/src/cleantest/pkg/handler/snap_handler.py
index 377297d..2c25151 100644
--- a/src/cleantest/pkg/handler/snap_handler.py
+++ b/src/cleantest/pkg/handler/snap_handler.py
@@ -18,7 +18,7 @@
 from datetime import datetime, timedelta, timezone
 from enum import Enum
 from subprocess import CalledProcessError, CompletedProcess
-from typing import Dict, Iterable, List, Optional
+from typing import Dict, Iterable, List, Optional, Union
 
 from cleantest.pkg._base import PackageError
 
@@ -502,7 +502,7 @@ def _request(
         path: str,
         query: Dict = None,
         body: Dict = None,
-    ) -> dict | List:
+    ) -> Union[dict, List]:
         """Make a JSON request to the Snapd server with the given HTTP method and path.
 
         If query dict is provided, it is encoded and appended as a query string
@@ -665,12 +665,12 @@ def _load_info(self, name) -> Snap:
 
 @_cache_init
 def install(
-    snap_names: str | List[str],
-    state: str | SnapState = SnapState.LATEST,
+    snap_names: Union[str, List[str]],
+    state: Union[str, SnapState] = SnapState.LATEST,
     channel: Optional[str] = "latest",
     classic: Optional[bool] = False,
     cohort: Optional[str] = "",
-) -> Snap | List[Snap]:
+) -> Union[Snap, List[Snap]]:
     """Install a snap or snaps in a remote process.
 
     Args:
@@ -696,7 +696,7 @@ def install(
 
 
 @_cache_init
-def remove(snap_names: str | List[str]) -> Snap | List[Snap]:
+def remove(snap_names: Union[str, List[str]]) -> Union[Snap, List[Snap]]:
     """Removes a snap from the system.
 
     Args:
@@ -714,12 +714,12 @@ def remove(snap_names: str | List[str]) -> Snap | List[Snap]:
 
 @_cache_init
 def ensure(
-    snap_names: str | List[str],
+    snap_names: Union[str, List[str]],
     state: str,
     channel: Optional[str] = "latest",
     classic: Optional[bool] = False,
     cohort: Optional[str] = "",
-) -> Snap | List[Snap]:
+) -> Union[Snap, List[Snap]]:
     """Ensures a snap is in a given state to the system.
 
     Args:
@@ -745,7 +745,7 @@ def _wrap_snap_operations(
     channel: str,
     classic: bool,
     cohort: Optional[str] = "",
-) -> Snap | List[Snap]:
+) -> Union[Snap, List[Snap]]:
     """Wrap common operations for bare commands."""
     snaps = {"success": [], "failed": []}
 
diff --git a/src/cleantest/pkg/pip.py b/src/cleantest/pkg/pip.py
index 7e03370..470944f 100644
--- a/src/cleantest/pkg/pip.py
+++ b/src/cleantest/pkg/pip.py
@@ -1,15 +1,13 @@
 #!/usr/bin/env python3
-# Copyright 2022 Canonical Ltd.
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
 # See LICENSE file for licensing details.
 
 """Manager for installing pip packages inside remote processes."""
 
-from __future__ import annotations
-
 import pathlib
 import subprocess
 from shutil import which
-from typing import List
+from typing import List, Union
 
 from cleantest.pkg._base import Package, PackageError
 from cleantest.utils import detect_os_variant
@@ -18,9 +16,9 @@
 class Pip(Package):
     def __init__(
         self,
-        packages: str | List[str] = None,
-        requirements: str | List[str] = None,
-        constraints: str | List[str] = None,
+        packages: Union[str, List[str]] = None,
+        requirements: Union[str, List[str]] = None,
+        constraints: Union[str, List[str]] = None,
         _manager: "Pip" = None,
     ) -> None:
         if _manager is None:
@@ -130,9 +128,9 @@ def __handle_pip_install(self) -> None:
 
     def _lint_inputs(
         self,
-        packages: str | List[str] | None,
-        requirements: str | List[str] | None,
-        constraints: str | List[str] | None,
+        packages: Union[str, List[str], None],
+        requirements: Union[str, List[str], None],
+        constraints: Union[str, List[str], None],
     ) -> None:
         lint_rules = [
             lambda: True if packages is None and requirements is None else False,
diff --git a/src/cleantest/pkg/snap.py b/src/cleantest/pkg/snap.py
index 2f9e72f..047ef19 100644
--- a/src/cleantest/pkg/snap.py
+++ b/src/cleantest/pkg/snap.py
@@ -4,14 +4,11 @@
 
 """Manager for installing snap packages inside remote processes."""
 
-from __future__ import annotations
-
 import pathlib
 import subprocess
-from dataclasses import dataclass
 from enum import Enum
 from shutil import which
-from typing import List
+from typing import List, Optional, Union
 
 from cleantest.pkg._base import Package, PackageError
 from cleantest.pkg.handler import snap
@@ -26,16 +23,16 @@ class Confinement(Enum):
     DEVMODE = "devmode"
 
 
-@dataclass
 class Plug:
-    snap: str
-    name: str
+    def __init__(self, snap: str, name: str) -> None:
+        self.snap = snap
+        self.name = name
 
 
-@dataclass
 class Slot:
-    snap: str = None
-    name: str = None
+    def __init__(self, snap: Optional[str] = None, name: Optional[str] = None) -> None:
+        self.snap = snap
+        self.name = name
 
 
 class Connection:
@@ -70,8 +67,8 @@ def connect(self) -> None:
 class Snap(Package):
     def __init__(
         self,
-        snaps: str | List[str] = None,
-        local_snaps: str | List[str] = None,
+        snaps: Union[str, List[str]] = None,
+        local_snaps: Union[str, List[str]] = None,
         confinement: Confinement = Confinement.STRICT,
         channel: str = None,
         cohort: str = None,
diff --git a/src/cleantest/provider/_handler/base_handler.py b/src/cleantest/provider/_handler/base_handler.py
index d209b6c..69eb7fc 100644
--- a/src/cleantest/provider/_handler/base_handler.py
+++ b/src/cleantest/provider/_handler/base_handler.py
@@ -1,11 +1,9 @@
 #!/usr/bin/env python3
-# Copyright 2022 Canonical Ltd.
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
 # See LICENSE file for licensing details.
 
 """Abstractions and utilities for test environment providers."""
 
-from __future__ import annotations
-
 import inspect
 import os
 import pathlib
@@ -14,8 +12,8 @@
 import tempfile
 import textwrap
 from abc import ABC, abstractmethod
-from dataclasses import dataclass
-from typing import Any, Callable, Dict, List
+from collections import namedtuple
+from typing import Any, Dict, List, Union
 
 import cleantest
 
@@ -24,11 +22,7 @@ class HandlerError(Exception):
     ...
 
 
-@dataclass
-class Result:
-    exit_code: int = None
-    stdout: Any = None
-    stderr: Any = None
+Result = namedtuple("Result", ["exit_code", "stdout", "stderr"])
 
 
 class Entrypoint(ABC):
@@ -94,7 +88,9 @@ def _construct_cleantest_injection(self, path: str) -> str:
             )
         )
 
-    def _construct_testlet(self, src: str, name: str, remove: List[re.Pattern] | None) -> str:
+    def _construct_testlet(
+        self, src: str, name: str, remove: Union[List[re.Pattern], None]
+    ) -> str:
         """Construct Python source file to be run in subroutine.
 
         Args:
diff --git a/src/cleantest/provider/_handler/lxd_handler.py b/src/cleantest/provider/_handler/lxd_handler.py
index 7645925..85a5ae4 100644
--- a/src/cleantest/provider/_handler/lxd_handler.py
+++ b/src/cleantest/provider/_handler/lxd_handler.py
@@ -8,9 +8,7 @@
 import json
 import os
 import re
-import sys
 from concurrent.futures import ProcessPoolExecutor
-from dataclasses import dataclass
 from typing import Any, Callable, Dict, List
 
 from pylxd import Client
@@ -20,11 +18,11 @@
 from cleantest.provider.data.lxd_data import LXDConfig
 
 
-@dataclass
 class Instance:
-    name: str
-    image: str
-    exists: bool = False
+    def __init__(self, name: str, image: str, exists: bool = False) -> None:
+        self.name = name
+        self.image = image
+        self.exists = exists
 
 
 class LXDHandler(Handler):
@@ -144,7 +142,6 @@ def run(self) -> Dict[str, Result]:
 class Parallel(Entrypoint, LXDHandler):
     def __init__(self, attr: Dict[str, Any], func: Callable) -> None:
         [setattr(self, k, v) for k, v in attr.items()]
-        print(dir(func), file=sys.stderr)
         self._func = inspect.getsource(func)
         self._func_name = func.__name__
 
@@ -164,7 +161,9 @@ def _target(self, i: Instance) -> Dict[str, Result]:
         self._build(self._check_exists(i))
         self._handle_start_env_hooks(i)
         result = self._execute(
-            self._construct_testlet(self._func, self._func_name, [re.compile(r"^@lxd\(([^)]+)\)")]),
+            self._construct_testlet(
+                self._func, self._func_name, [re.compile(r"^@lxd\(([^)]+)\)")]
+            ),
             i,
         )
         if self._preserve is False:
diff --git a/src/cleantest/provider/data/_mixins/__init__.py b/src/cleantest/provider/data/_mixins/__init__.py
new file mode 100644
index 0000000..8e1f712
--- /dev/null
+++ b/src/cleantest/provider/data/_mixins/__init__.py
@@ -0,0 +1,5 @@
+#!/usr/bin/env python3
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
+# See LICENSE file for licensing details.
+
+from .dict_ops import DictOps
diff --git a/src/cleantest/provider/data/_mixins/dict_ops.py b/src/cleantest/provider/data/_mixins/dict_ops.py
new file mode 100644
index 0000000..da5b547
--- /dev/null
+++ b/src/cleantest/provider/data/_mixins/dict_ops.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python3
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
+# See LICENSE file for licensing details.
+
+"""Common operations needed by classes that aim to emulate dictionaries."""
+
+from typing import Dict
+
+
+class DictOps:
+    def dict(self) -> Dict:
+        return self._filterdict(self.__dict__)
+
+    def _filterdict(self, input_dict: Dict) -> Dict:
+        result = {}
+        for key, value in input_dict.items():
+            if hasattr(value, "__dict__"):
+                result.update({key: self._filterdict(value.__dict__)})
+            else:
+                result.update({key: value})
+
+        return result
diff --git a/src/cleantest/provider/data/env_data.py b/src/cleantest/provider/data/env_data.py
index 491e9ac..4fe4851 100644
--- a/src/cleantest/provider/data/env_data.py
+++ b/src/cleantest/provider/data/env_data.py
@@ -1,11 +1,11 @@
 #!/usr/bin/env python3
-# Copyright 2022 Canonical Ltd.
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
 # See LICENSE file for licensing details.
 
 """Environment variable management for test providers."""
 
 import os
-from typing import Any, Dict
+from typing import Any, Dict, Union
 
 
 class EnvDataStore:
@@ -24,7 +24,7 @@ def _raw_env(self) -> Dict[str, Any]:
     def add(self, env_mapping: Dict[str, Any]) -> None:
         self.__env.update(env_mapping)
 
-    def get(self, env_var: str) -> Any | None:
+    def get(self, env_var: str) -> Union[Any, None]:
         """Retrieve environment variable from store.
 
         Args:
diff --git a/src/cleantest/provider/data/lxd_data.py b/src/cleantest/provider/data/lxd_data.py
index 295d72a..31698f9 100644
--- a/src/cleantest/provider/data/lxd_data.py
+++ b/src/cleantest/provider/data/lxd_data.py
@@ -1,53 +1,43 @@
 #!/usr/bin/env python3
-# Copyright 2022 Canonical Ltd.
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
 # See LICENSE file for licensing details.
 
 """Information needed for LXD test provider."""
 
-from typing import Any, Dict, List
+from typing import Any, Dict, List, Optional, Union
 
-from pydantic import BaseModel
+from ._mixins import DictOps
 
 
 class BadLXDConfigError(Exception):
     """Raised when the newly entered configuration fails the lint check."""
 
-    def __init__(self, config: Dict, desc: str = "Bad configuration for LXD instance.") -> None:
-        self.config = config
-        self.desc = desc
-        super().__init__(self.desc)
-
-    def __str__(self) -> str:
-        return f"{self.config}: {self.message}"
+    ...
 
 
 class LXDConfigNotFoundError(Exception):
     """Raised when the requested configuration for an LXD instance is not found in the registry."""
 
-    def __init__(self, name: str, desc: str = "Configuration not found in registry.") -> None:
-        self.name = name
-        self.desc = desc
-        super().__init__(self.desc)
-
-    def __str__(self) -> str:
-        return f"{self.name}: {self.message}"
+    ...
 
 
-class LXDSource(BaseModel):
-    type: str
-    mode: str
-    server: str
-    protocol: str
-    alias: str
+class LXDSource:
+    def __init__(self, type: str, mode: str, server: str, protocol: str, alias: str) -> None:
+        self.type = type
+        self.mode = mode
+        self.server = server
+        self.protocol = protocol
+        self.alias = alias
 
 
-class LXDConfig(BaseModel):
-    name: str
-    source: LXDSource
-    project: str | None
+class LXDConfig(DictOps):
+    def __init__(self, name: str, source: LXDSource, project: Optional[str] = None) -> None:
+        self.name = name
+        self.source = source
+        self.project = project
 
 
-class Defaults(BaseModel):
+class Defaults:
     jammy_amd64: Dict[str, Any] = {
         "name": "jammy-amd64",
         "source": {
diff --git a/src/cleantest/provider/lxd.py b/src/cleantest/provider/lxd.py
index 6b84154..f825a5c 100644
--- a/src/cleantest/provider/lxd.py
+++ b/src/cleantest/provider/lxd.py
@@ -4,37 +4,29 @@
 
 """LXD test environment provider functions and utilities."""
 
-from __future__ import annotations
-
 import os
-from dataclasses import dataclass
-from typing import Any, Callable, Dict, List, Tuple
+from collections import namedtuple
+from typing import Any, Callable, Dict, List, Union
 
 from cleantest.control.configurator import Configure
 from cleantest.provider.data import EnvDataStore, LXDDataStore
 
 from ._handler import LXDProvider, Result
 
-
-@dataclass
-class LXDClientConfig:
-    endpoint: Any = None
-    version: str = "1.0"
-    cert: Tuple[str, str] = None
-    verify: bool | str = True
-    timeout: float | Tuple[float, float] = None
-    project: str = "default"
+LXDClientConfig = namedtuple(
+    "LXDClientConfig", ["endpoint", "version", "cert", "verify", "timeout", "project"]
+)
 
 
 class lxd:
     def __init__(
         self,
         name: str = "test",
-        image: str | List[str] = ["jammy-amd64"],
+        image: Union[str, List[str]] = ["jammy-amd64"],
         preserve: bool = True,
         env: EnvDataStore = EnvDataStore(),
         data: LXDDataStore = LXDDataStore(),
-        image_config: Dict[str, Any] | List[Dict[str, Any]] = None,
+        image_config: Union[Dict[str, Any], List[Dict[str, Any]]] = None,
         client_config: LXDClientConfig = None,
         parallel: bool = False,
         num_threads: int = None,
diff --git a/src/cleantest/utils/__init__.py b/src/cleantest/utils/__init__.py
index eec62b0..3dbedc6 100644
--- a/src/cleantest/utils/__init__.py
+++ b/src/cleantest/utils/__init__.py
@@ -2,4 +2,4 @@
 # Copyright 2022 Canonical Ltd.
 # See LICENSE file for licensing details.
 
-from cleantest.utils.detect_os import detect_os_variant
+from .detect_os import detect_os_variant
diff --git a/src/cleantest/utils/detect_os.py b/src/cleantest/utils/detect_os.py
index 9186221..16f01bb 100644
--- a/src/cleantest/utils/detect_os.py
+++ b/src/cleantest/utils/detect_os.py
@@ -1,5 +1,5 @@
 #!/usr/bin/env python3
-# Copyright 2022 Canonical Ltd.
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
 # See LICENSE file for licensing details.
 
 """Detect operating system of test environment."""
@@ -25,7 +25,7 @@ def detect_os_variant() -> str:
 
 
 def _determine_linux() -> str:
-    os_release_data = [l.strip() for l in open("/etc/os-release", "rt")]
-    for l in os_release_data:
-        if l.startswith("ID="):
-            return l.split("=")[-1].lower()
+    os_release_data = [line.strip() for line in open("/etc/os-release", "rt")]
+    for line in os_release_data:
+        if line.startswith("ID="):
+            return line.split("=")[-1].lower()
diff --git a/tests/lxd/parallel/test_lxd_parallel.py b/tests/lxd/parallel/test_lxd_parallel.py
index 56accec..4afad4d 100644
--- a/tests/lxd/parallel/test_lxd_parallel.py
+++ b/tests/lxd/parallel/test_lxd_parallel.py
@@ -16,7 +16,7 @@
 
 @lxd(
     image=["jammy-amd64", "focal-amd64", "bionic-amd64"],
-    preserve=True,
+    preserve=False,
     parallel=True,
     num_threads=2,
 )
@@ -38,4 +38,7 @@ class TestParallelLXD:
     def test_parallel_lxd(self) -> None:
         results = install_tabulate()
         for name, result in results.items():
-            assert result.exit_code == 0
+            try:
+                assert result.exit_code == 0
+            except AssertionError:
+                raise Exception(f"{name} failed. Result: {result}")