Repository: NucciTheBoss/cleantest (language: python)

Issue:
Add support for file object uploads and downloads from test environments
Self explanatory - to test Apptainer, I need to be able to upload definition files into the test provider. Here is what needs to be done:

- [x] Create `File` class somewhere under `cleantest.hook`.
- [x] Enable support for uploading files via the `StartEnv` hook.
- [x] Enable support for downloading files via the `StopEnv` hook.


Discussion / hints:

Fix patch:
diff --git a/requirements.txt b/requirements.txt
index 2d9a7e6..21de589 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,1 @@
-pydantic==1.9.2
 pylxd==2.3.1
diff --git a/setup.py b/setup.py
index 82c4658..7dd2402 100644
--- a/setup.py
+++ b/setup.py
@@ -19,7 +19,6 @@
     ),
     package_dir={"": "src"},
     install_requires=[
-        "pydantic",
         "pylxd",
     ],
     keywords=[
diff --git a/tox.ini b/tox.ini
index a79f073..bdf51f2 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,4 +1,4 @@
-# Copyright 2022 Canonical Ltd.
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
 # See LICENSE file for licensing details.
 
 [tox]
@@ -12,6 +12,7 @@ tst_path = {toxinidir}/tests/
 all_path = {[vars]src_path} {[vars]tst_path} 
 
 [testenv]
+basepython = python3
 setenv =
   PYTHONPATH = {toxinidir}:{toxinidir}/lib:{[vars]src_path}
   PYTHONBREAKPOINT=ipdb.set_trace
@@ -81,7 +82,6 @@ description = Run functional tests for a locally-hosted LXD cluster.
 deps =
   -r {toxinidir}/requirements.txt
   pytest
-  pylxd
 commands =
   pytest -v --tb native --log-cli-level=ERROR {[vars]tst_path}/lxd/local
 
@@ -90,7 +90,6 @@ description = Run functional tests in parallel for a locally-hosted LXD cluster.
 deps =
   -r {toxinidir}/requirements.txt
   pytest
-  pylxd
 commands =
   pytest -v --tb native --log-cli-level=ERROR {[vars]tst_path}/lxd/parallel
 
@@ -105,6 +104,5 @@ description = Run functional tests to test if cleantest supports snap packages
 deps =
   -r {toxinidir}/requirements.txt
   pytest
-  pylxd
 commands =
   pytest -v --tb native --log-cli-level=ERROR {[vars]tst_path}/packages/snap


Test patch:
diff --git a/src/cleantest/control/__init__.py b/src/cleantest/control/__init__.py
index 816949f..12260d7 100644
--- a/src/cleantest/control/__init__.py
+++ b/src/cleantest/control/__init__.py
@@ -2,4 +2,4 @@
 # Copyright 2022 Canonical Ltd.
 # See LICENSE file for licensing details.
 
-from .configurator import Configure
+from .configure import Configure
diff --git a/src/cleantest/control/configurator.py b/src/cleantest/control/configure.py
similarity index 100%
rename from src/cleantest/control/configurator.py
rename to src/cleantest/control/configure.py
diff --git a/src/cleantest/hooks/data/__init__.py b/src/cleantest/hooks/data/__init__.py
new file mode 100644
index 0000000..91ed1fb
--- /dev/null
+++ b/src/cleantest/hooks/data/__init__.py
@@ -0,0 +1,6 @@
+#!/usr/bin/env python3
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
+# See LICENSE file for licensing details.
+
+from .directory import Dir
+from .file import File
diff --git a/src/cleantest/hooks/data/directory.py b/src/cleantest/hooks/data/directory.py
new file mode 100644
index 0000000..4631351
--- /dev/null
+++ b/src/cleantest/hooks/data/directory.py
@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
+# See LICENSE file for licensing details.
+
+"""Abstractions for uploading and downloading directories from test environments."""
+
+import os
+import pathlib
+import tarfile
+import tempfile
+import zipfile
+
+from .file import File
+
+
+class DirectoryError(Exception):
+    ...
+
+
+class DirectoryExistsError(Exception):
+    ...
+
+
+class DirectoryNotFoundError(Exception):
+    ...
+
+
+class Dir(File):
+    def __init__(
+        self, src: str, dest: str, compression: str = "gz", overwrite: bool = False
+    ) -> None:
+        self.src = pathlib.Path(src)
+        self.dest = pathlib.Path(dest)
+        self.compression = compression
+        self.overwrite = overwrite
+        self._data = None
+
+        if self.src.is_file():
+            raise NotADirectoryError(f"{self.src} is a file. Use File class instead.")
+
+        if self.compression not in ["gz", "zip", "bz2", "xz", None]:
+            raise DirectoryError(
+                f"{self.compression} not valid compression format. "
+                f"Format can be one of the following: {['gz', 'zip', 'bz2', 'xz', None]}"
+            )
+
+    def dump(self) -> None:
+        if self.dest.exists() and self.overwrite is False:
+            raise DirectoryExistsError(
+                f"{self.dest} already exists. Set overwrite = True to overwrite {self.dest}."
+            )
+
+        if self._data is None:
+            DirectoryError("Nothing to write.")
+
+        if self.compression in ["gz", "bz2", "xz", None]:
+            protocol = "r" if self.compression is None else f"r:{self.compression}"
+            with tarfile.open(self._data, protocol) as tar:
+                tar.extractall(self.dest)
+        else:
+            with zipfile.ZipFile(self._data, "r") as zip_out:
+                zip_out.extractall(self.dest)
+
+    def load(self) -> None:
+        if self.src.exists() is False:
+            raise FileNotFoundError(f"Could not find {self.src}.")
+
+        old_dir = os.getcwd()
+        os.chdir(os.sep.join(str(self.src).split(os.sep)[:-1]))
+        archive_path = pathlib.Path(tempfile.gettempdir()).joinpath(self.src.name)
+        if self.compression in ["gz", "bz2", "xz", None]:
+            protocol = "w" if self.compression is None else f"w:{self.compression}"
+            with tarfile.open(archive_path, protocol) as tar:
+                tar.add(self.src.name)
+        else:
+            data = []
+            for root, directory, file in os.walk(os.getcwd()):
+                for filename in file:
+                    data.append(os.path.join(root, filename))
+            with zipfile.ZipFile(archive_path, "w") as zip_in:
+                for file in data:
+                    zip_in.write(file)
+        os.chdir(old_dir)
+        self._data = archive_path.read_bytes()
diff --git a/src/cleantest/hooks/data/file.py b/src/cleantest/hooks/data/file.py
new file mode 100644
index 0000000..a0b7160
--- /dev/null
+++ b/src/cleantest/hooks/data/file.py
@@ -0,0 +1,105 @@
+#!/usr/bin/env python3
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
+# See LICENSE file for licensing details.
+
+"""Abstractions for uploading and downloading files from test environments."""
+
+import os
+import pathlib
+import tarfile
+import tempfile
+import textwrap
+import zipfile
+
+from cleantest.meta import Injectable
+
+
+class FileError(Exception):
+    ...
+
+
+class InjectableModeError(Exception):
+    ...
+
+
+class File(Injectable):
+    def __init__(
+        self, src: str, dest: str, compression: str = "gz", overwrite: bool = False
+    ) -> None:
+        self.src = pathlib.Path(src)
+        self.dest = pathlib.Path(dest)
+        self.compression = compression
+        self.overwrite = overwrite
+        self._data = None
+
+        if self.src.is_dir():
+            raise FileError(f"{self.src} is a directory. Use Dir class instead.")
+
+        if self.compression not in ["gz", "zip", "bz2", "xz", None]:
+            raise FileError(
+                f"{self.compression} not valid compression format. "
+                f"Format can be one of the following: {['gz', 'zip', 'bz2', 'xz', None]}"
+            )
+
+    def dump(self) -> None:
+        if self.dest.exists() and self.overwrite is False:
+            raise FileExistsError(
+                f"{self.dest} already exists. Set overwrite = True to overwrite {self.dest}."
+            )
+
+        if self._data is None:
+            raise FileError(f"Nothing to write.")
+
+        if self.compression in ["gz", "bz2", "xz", None]:
+            protocol = "r" if self.compression is None else f"r:{self.compression}"
+            with tarfile.open(self._data, protocol) as tar:
+                tar.extract(self.src.name, self.dest)
+        else:
+            with zipfile.ZipFile(self._data, "r") as zip_out:
+                zip_out.extract(self.src.name, self.dest)
+
+    def load(self) -> None:
+        if self.src.exists() is False:
+            raise FileNotFoundError(f"Could not find {self.src}.")
+
+        old_dir = os.getcwd()
+        os.chdir(os.sep.join(str(self.src).split(os.sep)[:-1]))
+        archive_path = pathlib.Path(tempfile.gettempdir()).joinpath(self.src.name)
+        if self.compression in ["gz", "bz2", "xz", None]:
+            protocol = "w" if self.compression is None else f"w:{self.compression}"
+            with tarfile.open(archive_path, protocol) as tar:
+                tar.add(self.src.name)
+        else:
+            with zipfile.ZipFile(archive_path, "w") as zip_out:
+                zip_out.write(self.src.name)
+        os.chdir(old_dir)
+        self._data = archive_path.read_bytes()
+
+    def __injectable__(self, path: str, verification_hash: str, mode: str) -> str:
+        if mode == "upload":
+            return textwrap.dedent(
+                f"""
+                #!/usr/bin/env python3
+                
+                from {self.__module__} import {self.__class__.__name__}
+                
+                holder = {self.__class__.__name__}._load("{path}")
+                holder.dump()
+                """
+            ).strip("\n")
+        elif mode == "download":
+            return textwrap.dedent(
+                f"""
+                #!/usr/bin/env python3
+                
+                from {self.__module__} import {self.__class__.__name__}
+                
+                holder = {self.__class__.__name__}._load("{path}", "{verification_hash}")
+                holder.load()
+                holder._dump()
+                """
+            ).strip("\n")
+        else:
+            InjectableModeError(
+                f"Invalid mode: {mode}. Please set mode to either 'upload' or 'download'."
+            )
diff --git a/src/cleantest/hooks/startenv.py b/src/cleantest/hooks/startenv.py
index 6701279..777e7f5 100644
--- a/src/cleantest/hooks/startenv.py
+++ b/src/cleantest/hooks/startenv.py
@@ -1,13 +1,28 @@
 #!/usr/bin/env python3
-# Copyright 2022 Canonical Ltd.
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
 # See LICENSE file for licensing details.
 
 """Hook run when test environment first starts."""
 
 from typing import List
 
+from cleantest.meta import Injectable
+
 
 class StartEnvHook:
-    def __init__(self, name: str = "default", packages: List[object] = []):
+    def __init__(
+        self,
+        name: str = "default",
+        packages: List[object] = [],
+        upload: List[Injectable] = [],
+    ) -> None:
+        """Hook run at the start of the test environment.
+
+        Args:
+            name (str): Unique name of hook.
+            packages (List[object]): Packages to inject into test environment.
+            upload (List[Injectable]): Artifacts to upload into test environment.
+        """
         self.name = name
         self.packages = packages
+        self.upload = upload
diff --git a/src/cleantest/hooks/stopenv.py b/src/cleantest/hooks/stopenv.py
index a479507..64f0d62 100644
--- a/src/cleantest/hooks/stopenv.py
+++ b/src/cleantest/hooks/stopenv.py
@@ -4,9 +4,16 @@
 
 """Hook run before test environment stops."""
 
+from typing import List
+
+from cleantest.meta import Injectable
+
 
 class StopEnvHook:
-    """Not implemented yet as I do not know if hooks are the move."""
+    def __init__(self, download: List[Injectable]) -> None:
+        """Hook run before stopping test environment.
 
-    def __init__(self):
-        raise NotImplementedError("Hook not implemented yet.")
+        Args:
+            download (List[Injectable]): Artifacts to download from test environment.
+        """
+        self.download = download
diff --git a/src/cleantest/meta/__init__.py b/src/cleantest/meta/__init__.py
new file mode 100644
index 0000000..52ec68f
--- /dev/null
+++ b/src/cleantest/meta/__init__.py
@@ -0,0 +1,8 @@
+#!/usr/bin/env python3
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
+# See LICENSE file for licensing details.
+
+from .base_handler import BaseEntrypoint, BaseHandler, BaseHandlerError, Result
+from .base_package import BasePackage, BasePackageError
+from .cleantest_info import CleantestInfo
+from .injectable import Injectable, InjectableData
diff --git a/src/cleantest/meta/base_handler.py b/src/cleantest/meta/base_handler.py
new file mode 100644
index 0000000..bf164e1
--- /dev/null
+++ b/src/cleantest/meta/base_handler.py
@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
+# See LICENSE file for licensing details.
+
+"""Metaclass for objects that will serve as handlers for test environments."""
+
+import re
+import tempfile
+from abc import ABC, abstractmethod
+from collections import namedtuple
+from typing import Any, Dict, List
+
+
+class BaseHandlerError(Exception):
+    ...
+
+
+Result = namedtuple("Result", ["exit_code", "stdout", "stderr"])
+
+
+class BaseEntrypoint(ABC):
+    """Metaclass for test environment provider entrypoints.
+
+    Entrypoints define the tooling stubs needed to start running tests.
+    """
+
+    @abstractmethod
+    def run(self) -> Dict[str, Result]:
+        ...
+
+
+class BaseHandler(ABC):
+    """Metaclass for test environment handlers.
+
+    Handlers define the tooling stubs needed to run tests inside the test environment.
+    """
+
+    @abstractmethod
+    def _exists(self) -> None:
+        """Check if test environment already exists."""
+        ...
+
+    @abstractmethod
+    def _build(self) -> None:
+        """Build test environments."""
+        ...
+
+    @abstractmethod
+    def _init(self) -> None:
+        """Inject cleantest and dependencies into test environments."""
+        ...
+
+    @abstractmethod
+    def _execute(self) -> Any:
+        """Execute testlet inside container."""
+        ...
+
+    @abstractmethod
+    def _process(self) -> Result:
+        """Process result from testlet."""
+        ...
+
+    @abstractmethod
+    def _teardown(self) -> None:
+        """Destroy test environments if not told to preserve."""
+        ...
+
+    @abstractmethod
+    def _handle_start_env_hooks(self) -> None:
+        """Handle StartEnv hooks."""
+        ...
+
+    @abstractmethod
+    def _handle_stop_env_hooks(self) -> None:
+        """Handle StopEnv hooks."""
+        ...
+
+    def _make_testlet(self, src: str, name: str, remove: List[Any] = None) -> str:
+        """Construct Python source file to be run in subroutine.
+
+        Args:
+            src (str): Source code of testlet.
+            name (str): Name of testlet.
+            remove (List[Any]): String patterns to remove from source code in regex form.
+
+        Returns:
+            (str): Injectable testlet.
+
+        Future:
+            This will need more advanced logic if tests accept arguments.
+        """
+        if remove is not None:
+            for pattern in remove:
+                src = re.sub(pattern, "", src)
+
+        with tempfile.TemporaryFile(mode="w+t") as f:
+            content = [
+                "#!/usr/bin/env python3\n",
+                f"{src}\n",
+                f"{name}()\n",
+            ]
+            f.writelines(content)
+            f.seek(0)
+            testlet = f.read()
+
+        return testlet
diff --git a/src/cleantest/meta/base_package.py b/src/cleantest/meta/base_package.py
new file mode 100644
index 0000000..034522c
--- /dev/null
+++ b/src/cleantest/meta/base_package.py
@@ -0,0 +1,30 @@
+#!/usr/bin/env python3
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
+# See LICENSE file for licensing details.
+
+"""Metaclass for objects that handle installing packages inside test environments."""
+
+from abc import abstractmethod
+
+from .injectable import Injectable
+
+
+class BasePackageError(Exception):
+    ...
+
+
+class BasePackage(Injectable):
+    """Metaclass for package handlers.
+
+    Packages define tooling stubs needed to install packages inside test environments.
+    """
+
+    @abstractmethod
+    def _run(self) -> None:
+        """Run installer for package."""
+        ...
+
+    @abstractmethod
+    def _setup(self) -> None:
+        """Perform setup needed inside test environment to run installer."""
+        ...
diff --git a/src/cleantest/meta/cleantest_info.py b/src/cleantest/meta/cleantest_info.py
new file mode 100644
index 0000000..4d849e2
--- /dev/null
+++ b/src/cleantest/meta/cleantest_info.py
@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
+# See LICENSE file for licensing details.
+
+"""Metaclass for retrieving information about cleantest."""
+
+import csv
+import os
+import pathlib
+import tarfile
+import tempfile
+import textwrap
+from concurrent.futures import ProcessPoolExecutor
+from typing import Dict
+
+import pkg_resources
+
+
+class CleantestInfo:
+    def __new__(cls) -> "CleantestInfo":
+        if not hasattr(cls, "instance"):
+            cls.instance = super(CleantestInfo, cls).__new__(cls)
+        return cls.instance
+
+    @property
+    def src(self) -> Dict[str, bytes]:
+        """Source code of cleantest module.
+
+        Returns:
+            (Dict[str, bytes]): Name and source code of cleantest module.
+        """
+        old_dir = os.getcwd()
+        os.chdir(pkg_resources.get_distribution("cleantest").location)
+        tar_path = pathlib.Path(tempfile.gettempdir()).joinpath("cleantest")
+        with tarfile.open(tar_path, "w:gz") as tar:
+            tar.add("cleantest")
+        os.chdir(old_dir)
+
+        return {"cleantest": tar_path.read_bytes()}
+
+    @property
+    def dependencies(self) -> Dict[str, bytes]:
+        """Source code for dependencies of cleantest module.
+
+        Returns:
+            (Dict[str, bytes]): Name and source code of dependencies.
+        """
+        result = {}
+        with ProcessPoolExecutor(max_workers=os.cpu_count()) as pool:
+            pool_results = pool.map(
+                self._get_dependencies,
+                pkg_resources.working_set.resolve(
+                    pkg_resources.working_set.by_key["cleantest"].requires()
+                ),
+            )
+            for res in pool_results:
+                [result.update({key: value}) for key, value in res.items()]
+
+        return result
+
+    def _get_dependencies(self, dependency: pkg_resources.Distribution) -> Dict[str, bytes]:
+        """Collect source code of cleantest dependency.
+
+        Args:
+            dependency (pkg_resources.Distribution): Dependency of cleantest.
+
+        Returns:
+            (Dict[str, bytes]): Name and source code of dependency.
+        """
+        os.chdir(dependency.location)
+        tar_path = pathlib.Path(tempfile.gettempdir()).joinpath(dependency.key)
+        with tarfile.open(tar_path, "w:gz") as tar:
+            with pathlib.Path(
+                f"{dependency.key.replace('-', '_')}-{dependency.version}.dist-info"
+            ).joinpath("RECORD").open(mode="rt") as fin:
+                for row in csv.reader(fin):
+                    tar.add(row[0])
+
+        return {dependency.key: tar_path.read_bytes()}
+
+    def make_pkg_injectable(self, pkg_path: str) -> str:
+        """Create injectable to install cleantest packages inside test environment.
+
+        Args:
+            pkg_path (str): Path to package inside test environment.
+
+        Returns:
+            (str): Injectable script.
+        """
+        return textwrap.dedent(
+            f"""
+            #!/usr/bin/env python3
+            import site
+            import tarfile
+
+            site.getsitepackages()[0]
+            tarball = tarfile.open("{pkg_path}", "r:gz")
+            tarball.extractall(site.getsitepackages()[0])
+            """
+        ).strip("\n")
diff --git a/src/cleantest/meta/injectable.py b/src/cleantest/meta/injectable.py
new file mode 100644
index 0000000..da7a0f4
--- /dev/null
+++ b/src/cleantest/meta/injectable.py
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
+# See LICENSE file for licensing details.
+
+"""Metaclass for objects that will be injected into environments."""
+
+import hashlib
+import pathlib
+import pickle
+import tempfile
+import uuid
+from abc import ABC, abstractmethod
+from collections import namedtuple
+
+
+class InjectionError(Exception):
+    ...
+
+
+InjectableData = namedtuple("InjectableData", ["path", "hash"])
+
+
+class Injectable(ABC):
+    @classmethod
+    def _load(cls, data: str, verification_hash: str) -> object:
+        """Alternative constructor to load previous created object.
+
+        Args:
+            data (str): Path to file containing serialized object.
+            verification_hash (str): Hash to verify authenticity of serialized object.
+
+        Returns:
+            (object): Deserialized, verified object.
+        """
+        fin = pathlib.Path(data)
+        if type(data) == str and fin.exists() and fin.is_file():
+            if verification_hash != hashlib.sha224(fin.read_bytes()).hexdigest():
+                raise InjectionError("Hashes do not match. Will not load untrusted object.")
+
+            cls_data = pickle.loads(fin.read_bytes())
+            posargs = [
+                value for key, value in cls_data.__dict__.items() if not key.startswith("_")
+            ]
+            hiddenargs = {
+                key: value for key, value in cls_data.__dict__.items() if key.startswith("_")
+            }
+            new_cls = cls(*posargs)
+            [setattr(new_cls, key, value) for key, value in hiddenargs.items()]
+            return new_cls
+        else:
+            raise InjectionError(
+                f"Cannot load object {data}. Cannot find pickle file or {type(data)} is not str."
+            )
+
+    def _dump(self) -> InjectableData:
+        """Serialize object and generate SHA224 hash for verification.
+
+        Returns:
+            (InjectableData):
+                path (str): Path to file containing serialized object.
+                hash (str): Hash to verify authenticity of serialized object.
+        """
+        fout = pathlib.Path(tempfile.gettempdir()).joinpath(f"{uuid.uuid4()}.pkl")
+        data = pickle.dumps(self)
+        fout.write_bytes(data)
+        verification_hash = hashlib.sha224(data).hexdigest()
+        return InjectableData(str(fout), verification_hash)
+
+    @abstractmethod
+    def __injectable__(self) -> str:
+        """Code to be injected into test environment provider."""
+        ...
diff --git a/src/cleantest/pkg/__init__.py b/src/cleantest/pkg/__init__.py
index 7910daf..de25b6d 100644
--- a/src/cleantest/pkg/__init__.py
+++ b/src/cleantest/pkg/__init__.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.
 
 from .charmlib import Charmlib
diff --git a/src/cleantest/pkg/_base.py b/src/cleantest/pkg/_base.py
deleted file mode 100644
index 8aeab0a..0000000
--- a/src/cleantest/pkg/_base.py
+++ /dev/null
@@ -1,48 +0,0 @@
-#!/usr/bin/env python3
-# Copyright 2022 Canonical Ltd.
-# See LICENSE file for licensing details.
-
-"""Base package class for installing packages inside remote processes."""
-
-import hashlib
-import os
-import pathlib
-import pickle
-import tempfile
-import uuid
-from abc import ABC, abstractmethod
-from typing import Dict
-
-
-class PackageError(Exception):
-    ...
-
-
-class Package(ABC):
-    @abstractmethod
-    def _run(self) -> None:
-        ...
-
-    @abstractmethod
-    def _setup(self) -> None:
-        ...
-
-    @classmethod
-    def _load(cls, _manager: str, hash: str) -> object:
-        if type(_manager) == str and os.path.isfile(_manager):
-            fin = pathlib.Path(_manager)
-            if hash != hashlib.sha224(fin.read_bytes()).hexdigest():
-                raise PackageError("SHA224 hashes do not match. Will not load untrusted object.")
-
-            return cls(_manager=pickle.loads(fin.read_bytes()))
-        else:
-            raise PackageError(f"Invalid type {type(_manager)} received. Type must be bytes.")
-
-    def _dump(self) -> Dict[str, str]:
-        """Return a path to a pickled object and hash for verification."""
-        filepath = os.path.join(tempfile.gettempdir(), f"{uuid.uuid4()}.pkl")
-        data = pickle.dumps(self)
-        hash = hashlib.sha224(data).hexdigest()
-        fout = pathlib.Path(filepath)
-        fout.write_bytes(pickle.dumps(self))
-        return {"path": filepath, "hash": hash}
diff --git a/src/cleantest/pkg/_mixins/__init__.py b/src/cleantest/pkg/_mixins/__init__.py
new file mode 100644
index 0000000..e58aad9
--- /dev/null
+++ b/src/cleantest/pkg/_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 .snapd_support import SnapdSupport
diff --git a/src/cleantest/pkg/_mixins/snapd_support.py b/src/cleantest/pkg/_mixins/snapd_support.py
new file mode 100644
index 0000000..9b44782
--- /dev/null
+++ b/src/cleantest/pkg/_mixins/snapd_support.py
@@ -0,0 +1,37 @@
+#!/usr/bin/env python3
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
+# See LICENSE file for licensing details.
+
+"""Common operations needed by classes to support snapd and snap."""
+
+import subprocess
+from shutil import which
+
+from cleantest.utils import detect_os_variant
+
+
+class SnapdSupportError(Exception):
+    ...
+
+
+class SnapdSupport:
+    @staticmethod
+    def _install_snapd() -> None:
+        """Install snapd inside test environment."""
+        os_variant = detect_os_variant()
+
+        if which("snap") is None:
+            if os_variant == "ubuntu":
+                cmd = ["apt", "install", "-y", "snapd"]
+                try:
+                    subprocess.run(
+                        cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
+                    )
+                except subprocess.CalledProcessError:
+                    raise SnapdSupportError(
+                        f"Failed to install snapd using the following command: {' '.join(cmd)}."
+                    )
+            else:
+                raise NotImplementedError(
+                    f"Support for {os_variant.capitalize()} not available yet."
+                )
diff --git a/src/cleantest/pkg/charmlib.py b/src/cleantest/pkg/charmlib.py
index 7226c29..9e1d549 100644
--- a/src/cleantest/pkg/charmlib.py
+++ b/src/cleantest/pkg/charmlib.py
@@ -5,84 +5,51 @@
 """Manager for installing charm libraries inside remote processes."""
 
 import json
+import pathlib
 import subprocess
+import sys
+import textwrap
 from shutil import which
 from typing import List, Union
 
-from cleantest.pkg._base import Package, PackageError
-from cleantest.utils import detect_os_variant
+from cleantest.meta import BasePackage, BasePackageError, InjectableData
+from cleantest.pkg.handler import snap
 
+from ._mixins import SnapdSupport
 
-class Charmlib(Package):
+
+class CharmlibPackageError(BasePackageError):
+    ...
+
+
+class Charmlib(BasePackage, SnapdSupport):
     def __init__(
         self,
-        auth_token_path: str = None,
-        charmlibs: Union[str, List[str]] = None,
-        _manager: "Charmlib" = None,
+        auth_token_path: str,
+        charmlibs: Union[str, List[str]],
     ) -> None:
-        if _manager is None:
-            if auth_token_path is not None:
-                self._auth_token = open(auth_token_path, "rt").read()
-            else:
-                raise PackageError(
-                    (
-                        "No file path to authentication token passed. ",
-                        "Cannot authenticate with Charmhub.",
-                    )
-                )
+        self.auth_token_path = pathlib.Path(auth_token_path)
+        self.charmlibs = [charmlibs] if type(charmlibs) == str else charmlibs
+        self._auth_token = None
 
-            self._charmlib_store = set()
-            if type(charmlibs) == str:
-                self._charmlib_store.add(charmlibs)
-            elif type(charmlibs) == list:
-                for lib in charmlibs:
-                    self._charmlib_store.add(lib)
-            else:
-                raise PackageError(
-                    f"{type(charmlibs)} is invalid. charmlibs must either be str or List[str]."
-                )
-        else:
-            self._auth_token = _manager._auth_token
-            self._charmlib_store = _manager._charmlib_store
+        if auth_token_path is None:
+            raise CharmlibPackageError("No authentication token for Charmhub specified.")
+
+        if charmlibs is None:
+            raise CharmlibPackageError("No charm libraries specified.")
 
     def _run(self) -> None:
         self._setup()
         self._handle_charm_lib_install()
-        print(json.dumps({"PYTHONPATH": "/root/lib"}))
+        print(json.dumps({"PYTHONPATH": "/root/lib"}), file=sys.stdout)
 
     def _setup(self) -> None:
-        os_variant = detect_os_variant()
-
-        if which("snap") is None:
-            if os_variant == "ubuntu":
-                cmd = ["apt", "install", "-y", "snapd"]
-                try:
-                    subprocess.run(
-                        cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
-                    )
-                except subprocess.CalledProcessError:
-                    raise PackageError(
-                        f"Failed to install snapd using the following command: {' '.join(cmd)}."
-                    )
-            else:
-                raise NotImplementedError(
-                    f"Support for {os_variant.capitalize()} not available yet."
-                )
-
+        self._install_snapd()
         if which("charmcraft") is None:
-            cmd = ["snap", "install", "charmcraft", "--classic"]
-            try:
-                subprocess.run(
-                    cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
-                )
-            except subprocess.CalledProcessError:
-                raise PackageError(
-                    f"Failed to install charmcraft using the following command: {' '.join(cmd)}"
-                )
+            snap.install("charmcraft", classic=True)
 
     def _handle_charm_lib_install(self) -> None:
-        env = {"CHARMCRAFT_AUTH": self._auth_token}
-        for charm in self._charmlib_store:
+        for charm in self.charmlibs:
             cmd = ["/snap/bin/charmcraft", "fetch-lib", charm]
             try:
                 subprocess.run(
@@ -90,13 +57,33 @@ def _handle_charm_lib_install(self) -> None:
                     stdout=subprocess.DEVNULL,
                     stderr=subprocess.DEVNULL,
                     check=True,
-                    env=env,
+                    env={"CHARMCRAFT_AUTH": self._auth_token},
                     cwd="/root",
                 )
             except subprocess.CalledProcessError:
-                raise PackageError(
+                raise CharmlibPackageError(
                     (
                         f"Failed to install charm library {charm} "
                         f"using the following command: {' '.join(cmd)}"
                     )
                 )
+
+    def _dump(self) -> InjectableData:
+        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}")
+
+        self._auth_token = self.auth_token_path.read_text()
+
+        return super()._dump()
+
+    def __injectable__(self, path: str, verification_hash: str) -> str:
+        return textwrap.dedent(
+            f"""
+            #!/usr/bin/env python3
+            
+            from {self.__module__} import {self.__class__.__name__}
+            
+            holder = {self.__class__.__name__}._load("{path}", "{verification_hash}")
+            holder._run()
+            """
+        ).strip("\n")
diff --git a/src/cleantest/pkg/handler/snap_handler.py b/src/cleantest/pkg/handler/snap_handler.py
index 469078e..c182af7 100644
--- a/src/cleantest/pkg/handler/snap_handler.py
+++ b/src/cleantest/pkg/handler/snap_handler.py
@@ -20,12 +20,14 @@
 from subprocess import CalledProcessError, CompletedProcess
 from typing import Dict, Iterable, List, Optional, Union
 
-from cleantest.pkg._base import PackageError
-
 # Filter for extracting 7-bit C1 ANSI sequences from a string.
 ansi_filter = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
 
 
+class SnapHandlerError(Exception):
+    ...
+
+
 class SnapAPIError(Exception):
     """Raised when an HTTP API error occurs talking to the Snapd server."""
 
@@ -166,7 +168,7 @@ def _snap(self, command: str, optargs: Optional[Iterable[str]] = None) -> str:
         try:
             return subprocess.check_output(_cmd, universal_newlines=True)
         except CalledProcessError as e:
-            raise PackageError(
+            raise SnapHandlerError(
                 f"Snap: {self._name}; command {_cmd} failed with output = {e.output}"
             )
 
@@ -187,7 +189,7 @@ def _snap_daemons(
         try:
             return subprocess.run(_cmd, universal_newlines=True, check=True, capture_output=True)
         except CalledProcessError as e:
-            raise PackageError(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.
@@ -577,7 +579,7 @@ class SnapCache(Mapping):
 
     def __init__(self):
         if not self.snapd_installed:
-            raise PackageError("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:
@@ -605,7 +607,7 @@ def __getitem__(self, snap_name: str) -> Snap:
             try:
                 self._snap_map[snap_name] = self._load_info(snap_name)
             except SnapAPIError:
-                raise PackageError(f"Snap '{snap_name}' not found!")
+                raise SnapHandlerError(f"Snap '{snap_name}' not found!")
 
         return self._snap_map[snap_name]
 
@@ -757,11 +759,11 @@ def _wrap_snap_operations(
             else:
                 snap.ensure(state=state, classic=classic, channel=channel, cohort=cohort)
             snaps["success"].append(snap)
-        except PackageError:
+        except SnapHandlerError:
             snaps["failed"].append(s)
 
     if len(snaps["failed"]):
-        raise PackageError(
+        raise SnapHandlerError(
             f"Failed to install or refresh snap(s): {', '.join([s for s in snaps['failed']])}"
         )
 
@@ -805,7 +807,7 @@ def install_local(
 
         return c[snap_name]
     except CalledProcessError as e:
-        raise PackageError(f"Could not install snap {filename}: {e.output}")
+        raise SnapHandlerError(f"Could not install snap {filename}: {e.output}")
 
 
 def alias(alias_snap: str, app: str, alias: str, wait: bool = True) -> None:
@@ -817,7 +819,7 @@ 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 PackageError(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:
@@ -829,7 +831,7 @@ def unalias(alias_snap: str, wait: bool = True) -> None:
     try:
         subprocess.check_output(_cmd, universal_newlines=True)
     except subprocess.CalledProcessError:
-        raise PackageError(f"Failed to destroy alias. Command used: {' '.join(_cmd)}")
+        raise SnapHandlerError(f"Failed to destroy alias. Command used: {' '.join(_cmd)}")
 
 
 def connect(
@@ -849,7 +851,7 @@ def connect(
     try:
         subprocess.check_output(_cmd, universal_newlines=True)
     except subprocess.CalledProcessError:
-        raise PackageError(f"Failed to connect. Command used: {' '.join(_cmd)}")
+        raise SnapHandlerError(f"Failed to connect. Command used: {' '.join(_cmd)}")
 
 
 def disconnect(
@@ -871,6 +873,11 @@ def disconnect(
     if forget:
         _cmd.append("--forget")
 
+    try:
+        subprocess.check_output(_cmd, universal_newlines=True)
+    except subprocess.CalledProcessError:
+        raise SnapHandlerError(f"Failed to create alias. Command used: {' '.join(_cmd)}")
+
 
 def _system_set(config_item: str, value: str) -> None:
     """Helper for setting snap system config values.
@@ -883,7 +890,7 @@ def _system_set(config_item: str, value: str) -> None:
     try:
         subprocess.check_call(_cmd, universal_newlines=True)
     except CalledProcessError:
-        raise PackageError(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/src/cleantest/pkg/pip.py b/src/cleantest/pkg/pip.py
index 470944f..7f3e821 100644
--- a/src/cleantest/pkg/pip.py
+++ b/src/cleantest/pkg/pip.py
@@ -6,51 +6,50 @@
 
 import pathlib
 import subprocess
+import textwrap
 from shutil import which
 from typing import List, Union
 
-from cleantest.pkg._base import Package, PackageError
+from cleantest.meta import BasePackage, BasePackageError, InjectableData
 from cleantest.utils import detect_os_variant
 
 
-class Pip(Package):
+class PipPackageError(BasePackageError):
+    ...
+
+
+class Pip(BasePackage):
     def __init__(
         self,
         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:
-            self._lint_inputs(packages, requirements, constraints)
-
-            self._package_store = set()
-            if type(packages) == str:
-                self._package_store.add(packages)
-            elif type(packages) == list:
-                [self._package_store.add(p) for p in packages]
-
-            self._requirements_store = []
-            if type(requirements) == str:
-                self._requirements_store.append(requirements)
-            elif type(requirements) == list:
-                self._requirements_store.extend(requirements)
-
-            self._constraints_store = []
-            if type(constraints) == str:
-                self._constraints_store.append(constraints)
-            elif type(constraints) == list:
-                self._constraints_store.extend(constraints)
-
-            self._load_file_data()
-        else:
-            self._package_store = _manager._package_store
-            self._requirements_store = _manager._requirements_store
-            self._constraints_store = _manager._constraints_store
+        self.packages = [packages] if type(packages) == str else packages
+        self.requirements = [requirements] if type(requirements) == str else requirements
+        self._requirements_store = []
+        self.constraints = [constraints] if type(constraints) == str else constraints
+        self._constraints_store = []
+
+        lint_rules = [
+            lambda: True if packages is None and requirements is None else False,
+            lambda: True if requirements is None and constraints is not None else False,
+            lambda: True
+            if type(requirements) == list
+            and type(constraints) == list
+            and len(requirements) != len(constraints)
+            else False,
+        ]
+        for expr in lint_rules:
+            if expr():
+                raise PipPackageError(
+                    "Lint rule failed. ",
+                    f"Ensure passed arguments to {self.__class__.__name__} are correct.",
+                )
 
     def _run(self) -> None:
         self._setup()
-        self.__handle_pip_install()
+        self._handle_pip_install()
 
     def _setup(self) -> None:
         os_variant = detect_os_variant()
@@ -63,7 +62,7 @@ def _setup(self) -> None:
                         cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
                     )
                 except subprocess.CalledProcessError:
-                    raise PackageError(
+                    raise PipPackageError(
                         f"Failed to install pip using the following command: {' '.join(cmd)}"
                     )
             else:
@@ -71,94 +70,89 @@ def _setup(self) -> None:
                     f"Support for {os_variant.capitalize()} not available yet."
                 )
 
-    def __handle_pip_install(self) -> None:
-        for package in self._package_store:
-            cmd = ["python3", "-m", "pip", "install", package]
+    def _handle_pip_install(self) -> None:
+        if self.packages is not None:
+            cmd = ["python3", "-m", "pip", "install", " ".join(self.packages)]
             try:
                 subprocess.run(
                     cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
                 )
             except subprocess.CalledProcessError:
-                raise PackageError(
-                    f"Failed to install package {package} "
+                raise PipPackageError(
+                    f"Failed to install packages {self.packages} "
                     f"using the following command {' '.join(cmd)}"
                 )
 
         if self._constraints_store:
-            for r, c in zip(self._requirements_store, self._constraints_store):
-                r_file = pathlib.Path(pathlib.Path.home().joinpath("requirements.txt"))
-                c_file = pathlib.Path(pathlib.Path.home().joinpath("constraints.txt"))
-                r_file.touch()
-                c_file.touch()
-                with r_file.open(mode="w") as fout:
-                    fout.writelines(r)
-                with c_file.open(mode="w") as fout:
-                    fout.writelines(c)
-                cmd = ["python3", "-m", "pip", "install", "-r", str(r_file), "-c", str(c_file)]
+            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")
+                constraint_file.write_text(constraint)
+                cmd = [
+                    "python3",
+                    "-m",
+                    "pip",
+                    "install",
+                    "-r",
+                    str(requirement_file),
+                    "-c",
+                    str(constraint_file),
+                ]
                 try:
                     subprocess.run(
                         cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
                     )
                 except subprocess.CalledProcessError:
-                    raise PackageError(
+                    raise PipPackageError(
                         (
-                            f"Failed to install packages listed in requirements.txt file {r} "
-                            f"with constraints.txt file {c} using the "
+                            f"Failed to install packages listed in requirements.txt file {requirement} "
+                            f"with constraints.txt file {constraint} using the "
                             f"following command: {' '.join(cmd)}"
                         )
                     )
         else:
-            for r in self._requirements_store:
-                r_file = pathlib.Path(pathlib.Path.home().joinpath("requirements.txt"))
-                r_file.touch()
-                with r_file.open(mode="w") as fout:
-                    fout.writelines(r)
-                cmd = ["python3", "-m", "pip", "install", "-r", str(r_file)]
+            for requirement in self._requirements_store:
+                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
                     )
                 except subprocess.CalledProcessError:
-                    raise PackageError(
+                    raise PipPackageError(
                         (
-                            f"Failed to install packages listed in requirements.txt file {r} "
+                            f"Failed to install packages listed in requirements.txt file {requirement} "
                             f"using the following command: {' '.join(cmd)}"
                         )
                     )
 
-    def _lint_inputs(
-        self,
-        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,
-            lambda: True if requirements is None and constraints is not None else False,
-            lambda: True
-            if type(requirements) == list
-            and type(constraints) == list
-            and len(requirements) != len(constraints)
-            else False,
-        ]
-        for expr in lint_rules:
-            if expr():
-                raise PackageError(
-                    "Lint rule failed. ",
-                    f"Ensure passed arguments to {self.__class__.__name__} are correct.",
-                )
-
-    def _load_file_data(self) -> None:
-        for i in range(0, len(self._requirements_store)):
-            file = pathlib.Path(self._requirements_store[i])
-            if file.exists():
-                self._requirements_store[i] = [line.strip() for line in file.open()]
-            else:
-                raise PackageError(f"Requirements file {self._requirements_store[i]} not found.")
-
-        for i in range(0, len(self._constraints_store)):
-            file = pathlib.Path(self._constraints_store[i])
-            if file.exists():
-                self._constraints_store[i] = [line.strip() for line in file.open()]
-            else:
-                raise PackageError(f"Constraints file {self._constraints_store[i]} not found.")
+    def _dump(self) -> InjectableData:
+        if self.requirements is not None:
+            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}.")
+                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 requirements file {constraint}.")
+                self._constraints_store.append(fin.read_text())
+
+        return super()._dump()
+
+    def __injectable__(self, path: str, verification_hash: str) -> str:
+        return textwrap.dedent(
+            f"""
+            #!/usr/bin/env python3
+
+            from {self.__module__} import {self.__class__.__name__}
+
+            holder = {self.__class__.__name__}._load("{path}", "{verification_hash}")
+            holder._run()
+            """
+        ).strip("\n")
diff --git a/src/cleantest/pkg/snap.py b/src/cleantest/pkg/snap.py
index c226de9..389abc2 100644
--- a/src/cleantest/pkg/snap.py
+++ b/src/cleantest/pkg/snap.py
@@ -5,14 +5,18 @@
 """Manager for installing snap packages inside remote processes."""
 
 import pathlib
-import subprocess
+import textwrap
 from enum import Enum
-from shutil import which
 from typing import List, Optional, Union
 
-from cleantest.pkg._base import Package, PackageError
+from cleantest.meta import BasePackage, BasePackageError, InjectableData
 from cleantest.pkg.handler import snap
-from cleantest.utils import detect_os_variant
+
+from ._mixins import SnapdSupport
+
+
+class SnapPackageError(BasePackageError):
+    ...
 
 
 class Confinement(Enum):
@@ -44,12 +48,12 @@ def __init__(self, plug: Plug, slot: Slot = None, wait: bool = True) -> None:
 
     def _lint(self) -> None:
         if self._plug.snap is None or self._plug.name is None:
-            raise PackageError(
+            raise SnapPackageError(
                 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:
-            raise PackageError(
+            raise SnapPackageError(
                 f"Invalid slot: {self._slot.__dict__}. "
                 "Slot must at least have an associated snap or name."
             )
@@ -77,13 +81,13 @@ def _lint(self) -> None:
             holder = ", ".join(
                 [f"{key} = {value}" for key, value in self.__dict__.items() if value is None]
             )
-            raise PackageError(f"Invalid alias: {holder} cannot be None.")
+            raise SnapPackageError(f"Invalid alias: {holder} cannot be None.")
 
     def alias(self) -> None:
         snap.alias(self._snap_name, self._app_name, self._alias_name, self._wait)
 
 
-class Snap(Package):
+class Snap(BasePackage, SnapdSupport):
     def __init__(
         self,
         snaps: Union[str, List[str]] = None,
@@ -93,103 +97,79 @@ def __init__(
         cohort: str = None,
         dangerous: bool = False,
         connections: List[Connection] = None,
-        aliases: List = None,
-        _manager: "Snap" = None,
+        aliases: List[Alias] = None,
     ) -> None:
-        if _manager is None:
-            if snaps is None and local_snaps is None:
-                raise PackageError("No valid snap packages were passed.")
-            else:
-                self._snap_store = set()
-                if type(snaps) == str:
-                    self._snap_store.add(snaps)
-                elif type(snaps) == list:
-                    [self._snap_store.add(pkg) for pkg in snaps]
-
-                self._local_snap_store = set()
-                if type(local_snaps) == str:
-                    path = pathlib.Path(local_snaps)
-                    if path.exists():
-                        self._local_snap_store.add(path.read_bytes())
-                    else:
-                        raise PackageError(f"Could not find local snap package at {local_snaps}")
-                elif type(local_snaps) == list:
-                    for pkg in local_snaps:
-                        path = pathlib.Path(pkg)
-                        if path.exists():
-                            self._local_snap_store.add(path.read_bytes())
-                        else:
-                            raise PackageError(
-                                f"Could not find local snap package at {local_snaps}"
-                            )
-
-                if hasattr(Confinement, confinement.name):
-                    self._confinement = confinement
-                else:
-                    raise PackageError(
-                        f"Invalid confinement {confinement.name}. "
-                        f"Must be either {', '.join([i.name for i in Confinement])}"
-                    )
-
-                self._channel = channel
-                self._cohort = cohort
-                self._dangerous = dangerous
-                self._connections = connections
-                self._aliases = aliases
-        else:
-            self._snap_store = _manager._snap_store
-            self._local_snap_store = _manager._local_snap_store
-            self._confinement = _manager._confinement
-            self._channel = _manager._channel
-            self._cohort = _manager._cohort
-            self._dangerous = _manager._dangerous
-            self._connections = _manager._connections
-            self._aliases = _manager._aliases
+        self.snaps = [snaps] if type(snaps) == str else snaps
+        self.local_snaps = [local_snaps] if type(local_snaps) == str else local_snaps
+        self._cached_local_snaps = set()
+        self.confinement = confinement
+        self.channel = channel
+        self.cohort = cohort
+        self.dangerous = dangerous
+        self.connections = connections
+        self.aliases = aliases
+
+        if snaps is None and local_snaps is None:
+            raise SnapPackageError("No snaps specified.")
+
+        if not hasattr(Confinement, confinement.name):
+            raise SnapPackageError(
+                f"Invalid confinement {confinement.name}. "
+                f"Must be either {', '.join([i.name for i in Confinement])}"
+            )
 
     def _run(self) -> None:
         self._setup()
         self._handle_snap_install()
 
     def _setup(self) -> None:
-        os_variant = detect_os_variant()
-
-        if which("snap") is None:
-            if os_variant == "ubuntu":
-                cmd = ["apt", "install", "-y", "snapd"]
-                try:
-                    subprocess.run(
-                        cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
-                    )
-                except subprocess.CalledProcessError:
-                    raise PackageError(
-                        f"Failed to install snapd using the following command: {' '.join(cmd)}."
-                    )
-            else:
-                raise NotImplementedError(
-                    f"Support for {os_variant.capitalize()} not available yet."
-                )
+        self._install_snapd()
 
     def _handle_snap_install(self) -> None:
-        if len(self._snap_store) > 0:
+        if self.snaps is not None:
             snap.install(
-                list(self._snap_store),
-                channel=self._channel,
-                classic=True if self._confinement == Confinement.CLASSIC else False,
-                cohort=self._cohort if self._cohort is not None else "",
+                self.snaps,
+                channel=self.channel,
+                classic=True if self.confinement == Confinement.CLASSIC else False,
+                cohort=self.cohort if self.cohort is not None else "",
             )
 
-        for pkg in self._local_snap_store:
-            out = pathlib.Path("/root/tmp.snap")
-            out.write_bytes(pkg)
+        for pkg in self._cached_local_snaps:
+            path = pathlib.Path.home().joinpath("tmp.snap")
+            path.write_bytes(pkg)
             snap.install_local(
-                "/root/tmp.snap",
-                classic=True if self._confinement == Confinement.CLASSIC else False,
-                devmode=True if self._confinement == Confinement.DEVMODE else False,
-                dangerous=self._dangerous,
+                str(path),
+                classic=True if self.confinement == Confinement.CLASSIC else False,
+                devmode=True if self.confinement == Confinement.DEVMODE else False,
+                dangerous=self.dangerous,
             )
 
-        for connection in self._connections:
-            connection.connect()
+        if self.connections is not None:
+            for connection in self.connections:
+                connection.connect()
+
+        if self.aliases is not None:
+            for alias in self.aliases:
+                alias.alias()
+
+    def _dump(self) -> InjectableData:
+        if self.local_snaps is not None:
+            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}")
+                self._cached_local_snaps.add(snap_path.read_bytes())
+
+        return super()._dump()
+
+    def __injectable__(self, path: str, verification_hash: str) -> str:
+        return textwrap.dedent(
+            f"""
+            #!/usr/bin/env python3
+
+            from {self.__module__} import {self.__class__.__name__}
 
-        for alias in self._aliases:
-            alias.alias()
+            holder = {self.__class__.__name__}._load("{path}", "{verification_hash}")
+            holder._run()
+            """
+        ).strip("\n")
diff --git a/src/cleantest/provider/_handler/__init__.py b/src/cleantest/provider/_handler/__init__.py
index 0f6a42f..9c229ac 100644
--- a/src/cleantest/provider/_handler/__init__.py
+++ b/src/cleantest/provider/_handler/__init__.py
@@ -2,5 +2,4 @@
 # Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
 # See LICENSE file for licensing details.
 
-from .base_handler import Result
 from .lxd_handler import LXDProvider
diff --git a/src/cleantest/provider/_handler/base_handler.py b/src/cleantest/provider/_handler/base_handler.py
deleted file mode 100644
index 69eb7fc..0000000
--- a/src/cleantest/provider/_handler/base_handler.py
+++ /dev/null
@@ -1,140 +0,0 @@
-#!/usr/bin/env python3
-# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
-# See LICENSE file for licensing details.
-
-"""Abstractions and utilities for test environment providers."""
-
-import inspect
-import os
-import pathlib
-import re
-import tarfile
-import tempfile
-import textwrap
-from abc import ABC, abstractmethod
-from collections import namedtuple
-from typing import Any, Dict, List, Union
-
-import cleantest
-
-
-class HandlerError(Exception):
-    ...
-
-
-Result = namedtuple("Result", ["exit_code", "stdout", "stderr"])
-
-
-class Entrypoint(ABC):
-    """Abstract super-class for test environment provider entrypoints.
-
-    Entrypoints define the tooling needed by cleantest to start a test.
-    """
-
-    @abstractmethod
-    def run(self) -> Dict[str, Result]:
-        ...
-
-
-class Handler(ABC):
-    """Abstract super-class for test environment handlers.
-
-    Handlers define all the tooling needed to run tests inside the remote environment.
-    """
-
-    @abstractmethod
-    def _init(self) -> None:
-        ...
-
-    @abstractmethod
-    def _execute(self) -> Any:
-        ...
-
-    @abstractmethod
-    def _process(self) -> Result:
-        ...
-
-    @abstractmethod
-    def _handle_start_env_hooks(self) -> None:
-        ...
-
-    def _get_cleantest_source(self) -> bytes:
-        for root, directory, file in os.walk(cleantest.__path__[0]):
-            src_path = pathlib.Path(root)
-            if src_path.name == cleantest.__name__:
-                old_dir = os.getcwd()
-                os.chdir(os.sep.join(str(src_path).split(os.sep)[:-1]))
-                tar_path = pathlib.Path(tempfile.gettempdir()).joinpath(cleantest.__name__)
-                with tarfile.open(tar_path, "w:gz") as tarball:
-                    tarball.add(cleantest.__name__)
-                os.chdir(old_dir)
-
-                return tar_path.read_bytes()
-
-        raise HandlerError(f"Could not find source directory for {cleantest.__name__}.")
-
-    def _construct_cleantest_injection(self, path: str) -> str:
-        return textwrap.dedent(
-            f"""
-            #!/usr/bin/env python3
-            import site
-            import tarfile
-    
-            site.getsitepackages()[0]
-            tarball = tarfile.open("{path}", "r:gz")
-            tarball.extractall(site.getsitepackages()[0])
-            """.strip(
-                "\n"
-            )
-        )
-
-    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:
-            src (str): TODO
-            remove (List[re.Pattern]): TODO
-
-        Returns:
-            str: TODO
-
-        Future:
-            This will need more advanced logic if tests accept arguments.
-        """
-        try:
-            if remove is not None:
-                for pattern in remove:
-                    src = re.sub(pattern, "", src)
-
-            with tempfile.TemporaryFile(mode="w+t") as f:
-                content = [
-                    "#!/usr/bin/env python3\n",
-                    f"{src}\n",
-                    f"{name}()\n",
-                ]
-                f.writelines(content)
-                f.seek(0)
-                scriptlet = f.read()
-
-            return scriptlet
-        except OSError:
-            raise HandlerError(f"Could not locate source code for testlet {name}.")
-
-    def _construct_pkg_installer(self, pkg: object, file_path: str, hash: str) -> str:
-        src = inspect.getsourcefile(pkg.__class__)
-        if src is None:
-            raise HandlerError(f"Could not get the source file of object {pkg.__class__.__name__}")
-
-        with tempfile.TemporaryFile(mode="w+t") as f:
-            content = [
-                f"{open(src, 'rt').read()}\n",
-                f"holder = {pkg.__class__.__name__}._load('{file_path}', '{hash}')\n",
-                "holder._run()\n",
-            ]
-            f.writelines(content)
-            f.seek(0)
-            pkg_installer = f.read()
-
-        return pkg_installer
diff --git a/src/cleantest/provider/_handler/lxd_handler.py b/src/cleantest/provider/_handler/lxd_handler.py
index 85a5ae4..a5274c6 100644
--- a/src/cleantest/provider/_handler/lxd_handler.py
+++ b/src/cleantest/provider/_handler/lxd_handler.py
@@ -2,35 +2,34 @@
 # Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
 # See LICENSE file for licensing details.
 
-"""Handler for LXD-based test environments."""
+"""Handler for LXD test environments."""
 
 import inspect
 import json
-import os
+import pathlib
 import re
 from concurrent.futures import ProcessPoolExecutor
 from typing import Any, Callable, Dict, List
 
 from pylxd import Client
 
-from cleantest.pkg._base import Package
-from cleantest.provider._handler.base_handler import Entrypoint, Handler, Result
-from cleantest.provider.data.lxd_data import LXDConfig
+from cleantest.meta import BaseEntrypoint, BaseHandler, BasePackage, CleantestInfo, Result
 
 
-class Instance:
+class InstanceMetadata:
     def __init__(self, name: str, image: str, exists: bool = False) -> None:
         self.name = name
         self.image = image
         self.exists = exists
 
 
-class LXDHandler(Handler):
-    def _set_client(self) -> None:
+class LXDHandler(BaseHandler):
+    @property
+    def _client(self) -> Client:
         if self._client_config is None:
-            self._client = Client(project="default")
+            return Client(project="default")
         else:
-            self._client = Client(
+            return Client(
                 endpoint=self._client_config.endpoint,
                 version=self._client_config.version,
                 cert=self._client_config.cert,
@@ -39,44 +38,54 @@ def _set_client(self) -> None:
                 project=self._client_config.project,
             )
 
-    def _build(self, instance: Instance) -> None:
+    @property
+    def _instance_metadata(self) -> List[InstanceMetadata]:
+        return [InstanceMetadata(name=f"{self._name}-{i}", image=i) for i in self._image]
+
+    def _build(self, instance: InstanceMetadata) -> None:
         if instance.exists is False:
             config = self._data.get_config(instance.image)
             config.name = instance.name
             self._client.instances.create(config.dict(), wait=True)
             instance = self._client.instances.get(instance.name)
             instance.start(wait=True)
-            self._init(instance, config)
+            self._init(instance)
         else:
             if self._client.instances.get(instance.name).status.lower() == "stopped":
                 self._client.instances.get(instance.name).start(wait=True)
 
-    def _init(self, instance: Any, config: LXDConfig) -> None:
-        if "ubuntu" in config.source.alias:
-            cleantest_src = self._get_cleantest_source()
-            injectable = self._construct_cleantest_injection("/root/cleantest_src.tar.gz")
-            instance.files.put("/root/cleantest_src.tar.gz", cleantest_src)
-            instance.files.put("/root/init_cleantest", injectable)
-            instance.execute(["chmod", "+x", "/root/init_cleantest"])
-            instance.execute(["/root/init_cleantest"])
-            instance.execute(["apt", "update"])
-            instance.execute(["apt", "install", "-y", "python3-pip"])
-            instance.execute(["pip", "install", "pylxd", "pydantic"])
-        else:
-            NotImplementedError(f"{config.source.alias} injection not supported yet.")
+    def _init(self, instance: Any) -> None:
+        meta = CleantestInfo()
+        instance.execute(["mkdir", "-p", "/root/init"])
+        for module, src in {**meta.src, **meta.dependencies}.items():
+            instance.files.put(f"/root/init/{module}.tar.gz", src)
+            instance.files.put(
+                f"/root/init/install_{module}",
+                meta.make_pkg_injectable(f"/root/init/{module}.tar.gz"),
+            )
+            instance.execute(["python3", f"/root/init/install_{module}"])
 
-    def _execute(self, test: str, instance: Instance) -> Any:
+    def _execute(self, test: str, instance: InstanceMetadata) -> Any:
         instance = self._client.instances.get(instance.name)
         instance.files.put("/root/test", test)
         instance.execute(["chmod", "+x", "/root/test"])
         return instance.execute(["/root/test"], environment=self._env.dump())
 
-    def _teardown(self, instance: Instance) -> None:
+    def _teardown(self, instance: InstanceMetadata) -> None:
         instance = self._client.instances.get(instance.name)
         instance.stop(wait=True)
         instance.delete(wait=True)
 
-    def _handle_start_env_hooks(self, instance: Instance) -> None:
+    def _process(self, result: Any) -> Result:
+        return Result(exit_code=result.exit_code, stdout=result.stdout, stderr=result.stderr)
+
+    def _exists(self, instance: InstanceMetadata) -> InstanceMetadata:
+        if self._client.instances.exists(instance.name):
+            instance.exists = True
+
+        return instance
+
+    def _handle_start_env_hooks(self, instance: InstanceMetadata) -> None:
         start_env_hooks = self._clean_config.get_start_env_hooks()
         while len(start_env_hooks) > 0:
             hook = start_env_hooks.pop()
@@ -85,36 +94,27 @@ def _handle_start_env_hooks(self, instance: Instance) -> None:
                 for pkg in hook.packages:
                     self._handle_package_install(instance, pkg)
 
-    def _handle_package_install(self, instance: Any, pkg: Package) -> None:
+    def _handle_stop_env_hooks(self) -> None:
+        ...
+
+    def _handle_package_install(self, instance: Any, pkg: BasePackage) -> None:
         dispatch = {"charmlib": lambda x: self._env.add(json.loads(x))}
 
         dump_data = pkg._dump()
-        remote_file_path = f"/root/{os.path.basename(dump_data['path'])}"
-        instance.files.put(remote_file_path, open(dump_data["path"], "rb").read())
+        data_path = pathlib.Path(dump_data.path)
+        instance.execute(["mkdir", "-p", "/root/init/pkg"])
+        instance.files.put(f"/root/init/pkg/{data_path.name}", data_path.read_bytes())
         instance.files.put(
-            "/root/install",
-            self._construct_pkg_installer(pkg, remote_file_path, dump_data["hash"]),
+            "/root/init/pkg/install",
+            pkg.__injectable__(f"/root/init/pkg/{data_path.name}", dump_data.hash),
         )
-        instance.execute(["chmod", "+x", "/root/install"])
-        result = instance.execute(["/root/install"])
+        result = instance.execute(["python3", "/root/init/pkg/install"])
 
         if pkg.__class__.__name__.lower() in dispatch:
             dispatch[pkg.__class__.__name__.lower()](result.stdout)
 
-    def _process(self, result: Any) -> Result:
-        return Result(exit_code=result.exit_code, stdout=result.stdout, stderr=result.stderr)
-
-    def _check_exists(self, instance: Instance) -> Instance:
-        if self._client.instances.exists(instance.name):
-            instance.exists = True
-
-        return instance
-
-    def _construct_instance_metaclasses(self) -> List[Instance]:
-        return [Instance(name=f"{self._name}-{i}", image=i) for i in self._image]
-
 
-class Serial(Entrypoint, LXDHandler):
+class Serial(BaseEntrypoint, LXDHandler):
     def __init__(self, attr: Dict[str, Any], func: Callable) -> None:
         [setattr(self, k, v) for k, v in attr.items()]
         self._func = inspect.getsource(func)
@@ -122,14 +122,11 @@ def __init__(self, attr: Dict[str, Any], func: Callable) -> None:
 
     def run(self) -> Dict[str, Result]:
         results = {}
-        self._set_client()
-        for i in self._construct_instance_metaclasses():
-            self._build(self._check_exists(i))
+        for i in self._instance_metadata:
+            self._build(self._exists(i))
             self._handle_start_env_hooks(i)
             result = self._execute(
-                self._construct_testlet(
-                    self._func, self._func_name, [re.compile(r"^@lxd\(([^)]+)\)")]
-                ),
+                self._make_testlet(self._func, self._func_name, [re.compile(r"^@lxd\(([^)]+)\)")]),
                 i,
             )
             if self._preserve is False:
@@ -139,7 +136,7 @@ def run(self) -> Dict[str, Result]:
         return results
 
 
-class Parallel(Entrypoint, LXDHandler):
+class Parallel(BaseEntrypoint, LXDHandler):
     def __init__(self, attr: Dict[str, Any], func: Callable) -> None:
         [setattr(self, k, v) for k, v in attr.items()]
         self._func = inspect.getsource(func)
@@ -150,20 +147,17 @@ def run(self) -> Dict[str, Result]:
         with ProcessPoolExecutor(
             max_workers=self._num_threads,
         ) as pool:
-            pool_results = pool.map(self._target, self._construct_instance_metaclasses())
+            pool_results = pool.map(self._target, self._instance_metadata)
             for res in pool_results:
                 [results.update({key: value}) for key, value in res.items()]
 
         return results
 
-    def _target(self, i: Instance) -> Dict[str, Result]:
-        self._set_client()
-        self._build(self._check_exists(i))
+    def _target(self, i: InstanceMetadata) -> Dict[str, Result]:
+        self._build(self._exists(i))
         self._handle_start_env_hooks(i)
         result = self._execute(
-            self._construct_testlet(
-                self._func, self._func_name, [re.compile(r"^@lxd\(([^)]+)\)")]
-            ),
+            self._make_testlet(self._func, self._func_name, [re.compile(r"^@lxd\(([^)]+)\)")]),
             i,
         )
         if self._preserve is False:
diff --git a/src/cleantest/provider/lxd.py b/src/cleantest/provider/lxd.py
index f825a5c..954f4b1 100644
--- a/src/cleantest/provider/lxd.py
+++ b/src/cleantest/provider/lxd.py
@@ -8,10 +8,11 @@
 from collections import namedtuple
 from typing import Any, Callable, Dict, List, Union
 
-from cleantest.control.configurator import Configure
+from cleantest.control.configure import Configure
+from cleantest.meta import Result
 from cleantest.provider.data import EnvDataStore, LXDDataStore
 
-from ._handler import LXDProvider, Result
+from ._handler import LXDProvider
 
 LXDClientConfig = namedtuple(
     "LXDClientConfig", ["endpoint", "version", "cert", "verify", "timeout", "project"]
diff --git a/tests/lxd/parallel/test_lxd_parallel.py b/tests/lxd/parallel/test_lxd_parallel.py
index 4afad4d..59f7402 100644
--- a/tests/lxd/parallel/test_lxd_parallel.py
+++ b/tests/lxd/parallel/test_lxd_parallel.py
@@ -2,7 +2,7 @@
 # Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
 # See LICENSE file for licensing details.
 
-"""Test parallel testing capabilites of LXD provider using local LXD cluster."""
+"""Test parallel testing capabilities of LXD provider using local LXD cluster."""
 
 from cleantest import Configure
 from cleantest.hooks import StartEnvHook