Repository: NucciTheBoss/cleantest (language: python)

Issue:
License page on GitHub Pages is not optimized for mobile
The title describes the issue; the license page on mobile is not formatted correctly. Not a huge issue for now, but it should be something that is addressed in the future. This will require changing the the HTML tag from `<pre></pre>` to something different. Preferably, I can use basic markdown, but certain sections will need to be encapsulated with `<p></p>` tags.
Documentation needs to be enhanced before 0.3.0 release
Title explains it all - the documentation needs to be improved before the 0.3.0 release. Why you say? SO MANY CHANGES. A lot of things have been added, refactored, deleted, etc. since 0.2.0. 0.3.0 is also a more serious release of cleantest, and when I hope to start attracting some more users.

What needs to be added?

- [ ] Docstrings to all functions, classes, and class methods/properties.
- [ ] New doc pages for web documentation.


Discussion / hints:

Should also add an actual `CONTRIBUTING.md` file instead of what I have currently. The current file just says TODO.

I could probably use a modified copy of [Jespipe's CONTRIBUTING.md](https://github.com/NucciTheBoss/jespipe/blob/main/CONTRIBUTING.md). Should use a blend of [Jespipe's code of conduct](https://github.com/NucciTheBoss/jespipe/blob/main/CODE_OF_CONDUCT.md) and the Ubuntu code of conduct for cleantest's code of conduct.

Fix patch:
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..1e4152f
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,147 @@
+# Community
+
+cleantest is about providing an easy way to test software using open-source container and virtual machine hypervisors
+to create "clean" testing environments.
+
+We want a productive, happy and agile community that can welcome new ideas in a complex field, improve every process 
+every year, and foster collaboration between groups with very different needs, interests and skills.
+
+We gain strength from diversity, and actively seek participation from those who enhance it. This code of conduct exists 
+to ensure that diverse groups collaborate to mutual advantage and enjoyment. We will challenge prejudice that could 
+jeopardise the participation of any person in the project.
+
+The Code of Conduct governs how we behave in public or in private whenever the project will be judged by our actions. 
+We expect it to be honoured by everyone who represents the project officially or informally, claims affiliation with 
+the project, or participates directly.
+
+# We strive to:
+
+## Be considerate
+
+Our work will be used by other people, and we in turn will depend on the work of others. Any decision we take will 
+affect users and colleagues, and we should consider them when making decisions.
+
+## Be respectful
+
+Disagreement is no excuse for poor manners. We work together to resolve conflict, assume good intentions and do our 
+best to act in an empathic fashion. We don’t allow frustration to turn into a personal attack. A community where people 
+feel uncomfortable or threatened is not a productive one.
+
+## Take responsibility for our words and our actions
+
+We can all make mistakes; when we do, we take responsibility for them. If someone has been harmed or offended, we 
+listen carefully and respectfully, and work to right the wrong.
+
+## Be collaborative
+
+What we produce is a complex whole made of many parts, it is the sum of many dreams. Collaboration between teams that 
+each have their own goal and vision is essential; for the whole to be more than the sum of its parts, each part must 
+make an effort to understand the whole.
+
+Collaboration reduces redundancy and improves the quality of our work. Internally and externally, we celebrate good 
+collaboration. Wherever possible, we work closely with upstream projects and others in the free software community to 
+coordinate our efforts. We prefer to work transparently and involve interested parties as early as possible.
+
+## Value decisiveness, clarity, and consensus
+
+Disagreements, social and technical, are normal, but we do not allow them to persist and fester leaving others 
+uncertain of the agreed direction.
+
+We expect participants in the project to resolve disagreements constructively. When they cannot, we escalate the 
+matter to structures with designated leaders to arbitrate and provide clarity and direction.
+
+## Ask for help when unsure
+
+Nobody is expected to be perfect in this community. Asking questions early avoids many problems later, so questions 
+are encouraged, though they may be directed to the appropriate forum. Those who are asked should be responsive 
+and helpful.
+
+## Step down considerately
+
+When somebody leaves or disengages from the project, we ask that they do so in a way that minimises disruption to the 
+project. They should tell people they are leaving and take the proper steps to ensure that others can pick up 
+where they left off.
+
+## Leadership, authority, and responsibility
+
+We all lead by example, in debate and in action. We encourage new participants to feel empowered to lead, to take 
+action, and to experiment when they feel innovation could improve the project. Leadership can be exercised by anyone 
+simply by taking action, there is no need to wait for recognition when the opportunity to lead presents itself.
+
+## Delegation from the top
+
+Responsibility for the project starts with the “benevolent dictator”, who delegates specific responsibilities and the 
+corresponding authority to a series of teams, councils and individuals, starting with the Community Council (“CC”). 
+That Council or its delegated representative will arbitrate in any dispute.
+
+We are a meritocracy; we delegate decision-making, governance and leadership from senior bodies to the most able
+and engaged candidates.
+
+## Support for delegation is measured
+
+Nominations to the boards and councils are at the discretion of the Community Council, however the Community Council 
+will seek the input of the community before confirming appointments.
+
+Leadership is not an award, right, or title; it is a privilege, a responsibility and a mandate. A leader will only 
+retain their authority as long as they retain the support of those who delegated that authority to them.
+
+## We value discussion, data, and decisiveness
+
+We gather opinions, data and commitments from concerned parties before taking a decision. We expect leaders to help 
+teams come to a decision in a reasonable time, to seek guidance or be willing to take the decision themselves when 
+consensus is lacking, and to take responsibility for implementation.
+
+The poorest decision of all is no decision: clarity of direction has value in itself. Sometimes all the data are not 
+available, or consensus is elusive. A decision must still be made. There is no guarantee of a perfect decision 
+every time - we prefer to err, learn, and err less in future than to postpone action indefinitely.
+
+We recognise that the project works better when we trust the teams closest to a problem to make the decision for the 
+project. If we learn of a decision that we disagree with, we can engage the relevant team to find common ground, 
+and failing that, we have a governance structure that can review the decision. Ultimately, if a decision has been 
+taken by the people responsible for it, and is supported by the project governance, it will stand. None of us expects 
+to agree with every decision, and we value highly the willingness to stand by the project and help it deliver even 
+on the occasions when we ourselves may prefer a different route.
+
+## Open meritocracy
+
+We invite anybody, from any company, to participate in any aspect of the project. Our community is open, and any 
+responsibility can be carried by any contributor who demonstrates the required capacity and competence.
+
+## Teamwork
+
+A leader’s foremost goal is the success of the team.
+
+“A virtuoso is judged by their actions; a leader is judged by the actions of their team.” A leader knows when to 
+act and when to step back. They know when to delegate work, and when to take it upon themselves.
+
+## Credit
+
+A good leader does not seek the limelight, but celebrates team members for the work they do. Leaders may be more 
+visible than members of the team, good ones use that visibility to highlight the great work of others.
+
+## Courage and considerateness
+
+Leadership occasionally requires bold decisions that will not be widely understood, consensual or popular. We 
+value the courage to take such decisions, because they enable the project as a whole to move forward faster than 
+we could if we required complete consensus. Nevertheless, boldness demands considerateness; take bold decisions, 
+but do so mindful of the challenges they present for others, and work to soften the impact of those decisions on 
+them. Communicating changes and their reasoning clearly and early on is as important as the implementation of 
+the change itself.
+
+## Conflicts of interest
+
+We expect leaders to be aware when they are conflicted due to employment or other projects they are involved in, 
+and abstain or delegate decisions that may be seen to be self-interested. We expect that everyone who participates 
+in the project does so with the goal of making life better for its users.
+
+When in doubt, ask for a second opinion. Perceived conflicts of interest are important to address; as a leader, act 
+to ensure that decisions are credible even if they must occasionally be unpopular, difficult or favourable to the 
+interests of one group over another.
+
+This Code is not exhaustive or complete. It is not a rulebook; it serves to distil our common understanding of a 
+collaborative, shared environment and goals. We expect it to be followed in spirit as much as in the letter.
+
+# Attribution
+
+This code of conduct is a slightly modified version of the 
+[Ubuntu Code of Conduct v2.0](https://ubuntu.com/community/code-of-conduct).
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4640904..471ee79 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,1 +1,228 @@
-# TODO
+# Contributing to cleantest
+
+Do you have something that you wish to contribute to cleantest? **If so, here is how you can help!**
+
+Please take a moment to review this document so that the contribution process will be easy and effective for everyone
+involved.
+
+Following these guidelines helps you communicate that you respect the developers managing and developing cleantest. 
+In return, they should reciprocate that respect while they are addressing your issue or assessing your submitted 
+patches and features.
+
+### Table of Contents
+
+* [Using the issue tracker](#using-the-issue-tracker)
+* [Issues and Labels](#issues-and-labels)
+* [Bug Reports](#bug-reports)
+* [Feature Requests](#feature-requests)
+* [Pull Requests](#pull-requests)
+* [Discussions](#discussions)
+* [Code Guidelines](#code-guidelines)
+* [License](#license)
+
+## Using the issue tracker
+
+The issue tracker is the preferred way for tracking [bug reports](#bug-reports), [feature requests](#feature-requests),
+and [submitted pull requests](#pull-requests), but please follow these guidelines for the issue tracker:
+
+* Please **do not** use the issue tracker for personal issues and/or support requests. 
+The [Discussions](#discussions) page is a better place to get help for personal support requests.
+
+* Please **do not** derail or troll issues. Keep the discussion on track and have respect for the other 
+users/contributors of cleantest.
+
+* Please **do not** post comments consisting soley of "+1", ":thumbsup:", or something similar. 
+Use [GitHub's "reactions" feature](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) 
+instead.
+  * The maintainers of cleantest reserve the right to delete comments that violate this rule.
+
+* Please **do not** repost or reopen issues that have been closed. Please either 
+submit a new issue or browser through previous issues.
+  * The maintainers of cleantest reserve the right to delete issues that violate this rule.
+
+## Issues and Labels
+
+The cleantest issue tracker uses a variety of labels to help organize and identify issues. 
+Here is a list of some of these labels, and how the maintainers of cleantest use them:
+
+* `Type: Bug` - Issues reported in the cleantest source code that either produce errors or unexpected behavior.
+
+* `Status: Confirmed` - Issues marked `Type: Bug` that have be confirmed to be reproducible on a separate system.
+
+* `Type: Documentation` - Issues for improving or updating cleantest's documentation. 
+Can also be used for pull requests.
+
+* `Type: Refactor` - Issues that pertain to improving the existing cleantest code base.
+
+* `Type: Idea Bank` - Issues that pertain to proposing potential improvement to cleantest.
+
+* `Type: Enchancement` - Issues marked as an agreed upon enhancement to cleantest. Can also be used for pull requests.
+
+* `Statues: Help wanted` - Issues where we need help from the greater cleantest community to solve.
+
+For a complete look at cleantest's labels, see the 
+[project labels page](https://github.com/NucciTheBoss/cleantest/labels).
+
+## Bug Reports
+
+A bug is a *demonstrable problem* that is caused by cleantest. Good bug reports make cleantest more robust, so thank 
+you for taking the time to report issues in the source code!
+
+Guidelines for reporting bugs in cleantest:
+
+1. **Validate your testlet** &mdash; ensure that your issue is not being caused by either a semantic or syntactic 
+error in your testlet's code.
+
+2. **Validate your hypervisor** &mdash; ensure that your issue is not being caused by a misconfiguration in the
+test environment provider that you are using with cleantest.
+
+3. **Use the GitHub issue search** &mdash; check if the issue you are encountering has already been reported by 
+someone else.
+
+4. **Check if the issue has already been fixed** &mdash; try to reproduce your issue using the latest `main` 
+in the cleantest repository.
+
+5. **Isolate the problem** &mdash; the more pinpointed the issue is, the easier time the cleantest developers 
+will have fixing it.
+
+A good bug report should not leave others needing to chase you up for more information. 
+Please try to be as detailed as possible in your report. What is your environment? What steps will reproduce the issue? 
+What operating system are you experiencing the problem on? Have you had the same results on a different 
+operating system? What would you expect to be the outcome? All these details will help the developers fix any 
+potential bugs.
+
+Example Bug Report:
+
+> Short and descriptive bug report title
+> 
+> ### System Info:
+>  - Operating system: [e.g. Ubuntu 22.04 LTS (Jammy Jellyfish)]
+>  - Cleantest version: [e.g. 0.3.0]
+>  - Hypervisor version: [e.g. LXD 5.9] 
+>  - Python version: [e.g. 3.10.1]
+> 
+> ### Describe the bug:
+> A clear and concise description of the bug you are encountering.
+> 
+> ### Copy of stacktrace:
+> ```
+> Copy of stack printed out to command line or log file by cleantest.
+> ```
+> 
+> ### Steps to Reproduce:
+> Steps to reproduce the behavior:
+> 1. Configuration options passed
+> 2. Testlets run
+> 3. Stage of pipeline
+> 4. See error
+> 
+> ### Expected Behavior:
+> A clear and concise description of what you expected to happen.
+> 
+> ### Screenshots:
+> If applicable, add screenshots to help explain your problem.
+> 
+> #### Additional context:
+> Add any other context about the problem here.
+
+## Feature Requests
+
+All feature requests should be posted to GitHub Discussions and tagged as `Type: Idea Bank`. The maintainers of
+cleantest already know the features they want to incorporate into cleantest, but they are always open to 
+new ideas and potential improvements. GitHub Discussions is the best place to post these types of requests 
+because it allows for feedback from the entire community and does not bloat the issue tracker. Please note that not 
+all feature requests will be incorporated into cleantest. Also, feature requests posted on the issue tracker 
+will be tagged as `Type: Invalid` and closed. Lastly, please note that spamming the maintainers to incorporate a
+feature you want into cleantest will not improve its likelihood of being implemented; it may result in you receiving
+a temporary ban from the repository.
+
+## Pull Requests
+
+Good pull requests &mdash; patches, improvements, new features &mdash; are a huge help. These pull requests should 
+remain focused in scope and should not contain unrelated commits.
+
+**Ask first** before embarking on any **significant** pull request (eg. implementing new features, refactoring code, 
+incorporating a new test environment provider, etc.), otherwise you risk spending a lot of time working on something 
+that cleantest's developers might not want to merge into the project! For trivial things, or things that do not require 
+a lot of your time, you can go ahead and make a pull request.
+
+Adhering to the following process is the best way to get your work
+included in the project:
+
+1. [Fork](https://help.github.com/articles/fork-a-repo/) the project, clone your fork,
+   and configure the remotes:
+
+   ```bash
+   # Clone your fork of the repo into the current directory
+   git clone https://github.com/<your-username>/cleantest.git
+   # Navigate to the newly cloned directory
+   cd cleantest
+   # Assign the original repo to a remote called "upstream"
+   git remote add upstream https://github.com/NucciTheBoss/jespipe.git
+   ```
+
+2. If you cloned a while ago, pull the latest changes from the upstream cleantest repository:
+
+   ```bash
+   git checkout main
+   git pull upstream main
+   ```
+
+3. Create a new topic branch (off the main project development branch) to
+   contain your feature, change, or fix:
+
+   ```bash
+   git checkout -b <topic-branch-name>
+   ```
+
+4. Commit your changes in logical chunks. Please adhere to these [git commit
+   message guidelines](https://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html)
+   or your code is unlikely be merged into the main project. Use Git's
+   [interactive rebase](https://help.github.com/articles/about-git-rebase/)
+   feature to tidy up your commits before making them public.
+
+5. Locally merge (or rebase) the upstream development branch into your topic branch:
+
+   ```bash
+   git pull [--rebase] upstream main
+   ```
+
+6. Push your topic branch up to your fork:
+
+   ```bash
+   git push origin <topic-branch-name>
+   ```
+
+7. [Open a Pull Request](https://help.github.com/articles/about-pull-requests/)
+    with a clear title and description against the `main` branch.
+
+**IMPORTANT**: By submitting a patch, improvement, or new feature, you agree to allow the owners of cleantest to 
+license your contributions under the terms of the [Apache 2.0 license](./LICENSE).
+
+## Discussions
+
+GitHub's discussions are a great place to connect with other users of cleantest as well as discuss potential 
+features and resolve personal support questions. It is expected that the users of cleantest remain respectful of 
+each other. Discussion moderators reserve the right the suspend discussions and/or delete posts that do not follow 
+this rule.
+
+## Code guidelines
+
+### Python
+
+The following guidelines must be adhered to if you are writing code to be merged into the main cleantest code base:
+
+* Adhere to the Python code style guidelines outlined in [Python Enhancement Proposal 8](https://pep8.org/).
+
+* Adhere to the Python docstring conventions outlined in 
+[Python Enhancement Proposal 257](https://www.python.org/dev/peps/pep-0257/).
+  * *cleantest docstrings follow the 
+  [Google docstring format](https://github.com/google/styleguide/blob/gh-pages/pyguide.md#38-comments-and-docstrings)*.
+  
+* Adhere to the Python type hint guidelines outlined in 
+[Python Enhancement Proposal 484](https://www.python.org/dev/peps/pep-0484/)
+
+## License
+
+By contributing your code to cleantest, you agree to license your contribution under the 
+[Apache 2.0 license](https://www.apache.org/licenses/LICENSE-2.0.html).
\ No newline at end of file
diff --git a/README.md b/README.md
index 6a1a15c..6acbd7e 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ Below is an outline of currently supported operating systems, Python versions, a
 
 |||
 | :--- | :---: |
-| Supported operating systems | ![Linux - yes](https://img.shields.io/badge/Linux-yes-green) ![Windows - ot tested](https://img.shields.io/badge/Windows-not%20tested-red) ![Mac - not tested](https://img.shields.io/badge/Mac-not%20tested-red) |
+| Supported operating systems | ![Linux - yes](https://img.shields.io/badge/Linux-yes-green) ![Windows - not tested](https://img.shields.io/badge/Windows-not%20tested-red) ![Mac - not tested](https://img.shields.io/badge/Mac-not%20tested-red) |
 | Supported python versions | ![Python 3.8, 3.9, and 3.10](https://img.shields.io/pypi/pyversions/cleantest) ![Wheel - yes](https://img.shields.io/pypi/wheel/cleantest)|
 | Supported hypervisors | ![LXD - yes](https://img.shields.io/badge/LXD-yes-green) |
 
@@ -129,7 +129,6 @@ Here are my (NucciTheBoss's) goals to get cleantest to release version 1.0.0:
   * ~~Pip~~
   * ~~Charm libraries~~
   * Debs, Rpms, Pacs, etc.
-  * Apptainer
+  * ~~Apptainer~~
 * Robust hook mechanism -> i.e. an actual specification for hooks.
-* Destructive (local) test environment provider
-* Comprehensive documentation
\ No newline at end of file
+* Comprehensive documentation
diff --git a/docs/about/license.md b/docs/about/license.md
new file mode 100644
index 0000000..537fde8
--- /dev/null
+++ b/docs/about/license.md
@@ -0,0 +1,170 @@
+Apache License
+==============
+
+_Version 2.0, January 2004_  
+_&lt;<http://www.apache.org/licenses/>&gt;_
+
+### Terms and Conditions for use, reproduction, and distribution
+
+#### 1. Definitions
+
+“License” shall mean the terms and conditions for use, reproduction, and
+distribution as defined by Sections 1 through 9 of this document.
+
+“Licensor” shall mean the copyright owner or entity authorized by the copyright
+owner that is granting the License.
+
+“Legal Entity” shall mean the union of the acting entity and all other entities
+that control, are controlled by, or are under common control with that entity.
+For the purposes of this definition, “control” means **(i)** the power, direct or
+indirect, to cause the direction or management of such entity, whether by
+contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the
+outstanding shares, or **(iii)** beneficial ownership of such entity.
+
+“You” (or “Your”) shall mean an individual or Legal Entity exercising
+permissions granted by this License.
+
+“Source” form shall mean the preferred form for making modifications, including
+but not limited to software source code, documentation source, and configuration
+files.
+
+“Object” form shall mean any form resulting from mechanical transformation or
+translation of a Source form, including but not limited to compiled object code,
+generated documentation, and conversions to other media types.
+
+“Work” shall mean the work of authorship, whether in Source or Object form, made
+available under the License, as indicated by a copyright notice that is included
+in or attached to the work (an example is provided in the Appendix below).
+
+“Derivative Works” shall mean any work, whether in Source or Object form, that
+is based on (or derived from) the Work and for which the editorial revisions,
+annotations, elaborations, or other modifications represent, as a whole, an
+original work of authorship. For the purposes of this License, Derivative Works
+shall not include works that remain separable from, or merely link (or bind by
+name) to the interfaces of, the Work and Derivative Works thereof.
+
+“Contribution” shall mean any work of authorship, including the original version
+of the Work and any modifications or additions to that Work or Derivative Works
+thereof, that is intentionally submitted to Licensor for inclusion in the Work
+by the copyright owner or by an individual or Legal Entity authorized to submit
+on behalf of the copyright owner. For the purposes of this definition,
+“submitted” means any form of electronic, verbal, or written communication sent
+to the Licensor or its representatives, including but not limited to
+communication on electronic mailing lists, source code control systems, and
+issue tracking systems that are managed by, or on behalf of, the Licensor for
+the purpose of discussing and improving the Work, but excluding communication
+that is conspicuously marked or otherwise designated in writing by the copyright
+owner as “Not a Contribution.”
+
+“Contributor” shall mean Licensor and any individual or Legal Entity on behalf
+of whom a Contribution has been received by Licensor and subsequently
+incorporated within the Work.
+
+#### 2. Grant of Copyright License
+
+Subject to the terms and conditions of this License, each Contributor hereby
+grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
+irrevocable copyright license to reproduce, prepare Derivative Works of,
+publicly display, publicly perform, sublicense, and distribute the Work and such
+Derivative Works in Source or Object form.
+
+#### 3. Grant of Patent License
+
+Subject to the terms and conditions of this License, each Contributor hereby
+grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
+irrevocable (except as stated in this section) patent license to make, have
+made, use, offer to sell, sell, import, and otherwise transfer the Work, where
+such license applies only to those patent claims licensable by such Contributor
+that are necessarily infringed by their Contribution(s) alone or by combination
+of their Contribution(s) with the Work to which such Contribution(s) was
+submitted. If You institute patent litigation against any entity (including a
+cross-claim or counterclaim in a lawsuit) alleging that the Work or a
+Contribution incorporated within the Work constitutes direct or contributory
+patent infringement, then any patent licenses granted to You under this License
+for that Work shall terminate as of the date such litigation is filed.
+
+#### 4. Redistribution
+
+You may reproduce and distribute copies of the Work or Derivative Works thereof
+in any medium, with or without modifications, and in Source or Object form,
+provided that You meet the following conditions:
+
+* **(a)** You must give any other recipients of the Work or Derivative Works a copy of
+this License; and
+* **(b)** You must cause any modified files to carry prominent notices stating that You
+changed the files; and
+* **(c)** You must retain, in the Source form of any Derivative Works that You distribute,
+all copyright, patent, trademark, and attribution notices from the Source form
+of the Work, excluding those notices that do not pertain to any part of the
+Derivative Works; and
+* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any
+Derivative Works that You distribute must include a readable copy of the
+attribution notices contained within such NOTICE file, excluding those notices
+that do not pertain to any part of the Derivative Works, in at least one of the
+following places: within a NOTICE text file distributed as part of the
+Derivative Works; within the Source form or documentation, if provided along
+with the Derivative Works; or, within a display generated by the Derivative
+Works, if and wherever such third-party notices normally appear. The contents of
+the NOTICE file are for informational purposes only and do not modify the
+License. You may add Your own attribution notices within Derivative Works that
+You distribute, alongside or as an addendum to the NOTICE text from the Work,
+provided that such additional attribution notices cannot be construed as
+modifying the License.
+
+You may add Your own copyright statement to Your modifications and may provide
+additional or different license terms and conditions for use, reproduction, or
+distribution of Your modifications, or for any such Derivative Works as a whole,
+provided Your use, reproduction, and distribution of the Work otherwise complies
+with the conditions stated in this License.
+
+#### 5. Submission of Contributions
+
+Unless You explicitly state otherwise, any Contribution intentionally submitted
+for inclusion in the Work by You to the Licensor shall be under the terms and
+conditions of this License, without any additional terms or conditions.
+Notwithstanding the above, nothing herein shall supersede or modify the terms of
+any separate license agreement you may have executed with Licensor regarding
+such Contributions.
+
+#### 6. Trademarks
+
+This License does not grant permission to use the trade names, trademarks,
+service marks, or product names of the Licensor, except as required for
+reasonable and customary use in describing the origin of the Work and
+reproducing the content of the NOTICE file.
+
+#### 7. Disclaimer of Warranty
+
+Unless required by applicable law or agreed to in writing, Licensor provides the
+Work (and each Contributor provides its Contributions) on an “AS IS” BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
+including, without limitation, any warranties or conditions of TITLE,
+NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
+solely responsible for determining the appropriateness of using or
+redistributing the Work and assume any risks associated with Your exercise of
+permissions under this License.
+
+#### 8. Limitation of Liability
+
+In no event and under no legal theory, whether in tort (including negligence),
+contract, or otherwise, unless required by applicable law (such as deliberate
+and grossly negligent acts) or agreed to in writing, shall any Contributor be
+liable to You for damages, including any direct, indirect, special, incidental,
+or consequential damages of any character arising as a result of this License or
+out of the use or inability to use the Work (including but not limited to
+damages for loss of goodwill, work stoppage, computer failure or malfunction, or
+any and all other commercial damages or losses), even if such Contributor has
+been advised of the possibility of such damages.
+
+#### 9. Accepting Warranty or Additional Liability
+
+While redistributing the Work or Derivative Works thereof, You may choose to
+offer, and charge a fee for, acceptance of support, warranty, indemnity, or
+other liability obligations and/or rights consistent with this License. However,
+in accepting such obligations, You may act only on Your own behalf and on Your
+sole responsibility, not on behalf of any other Contributor, and only if You
+agree to indemnify, defend, and hold each Contributor harmless for any liability
+incurred by, or claims asserted against, such Contributor by reason of your
+accepting any such warranty or additional liability.
+
+_END OF TERMS AND CONDITIONS_
\ No newline at end of file
diff --git a/docs/examples/coming-soon.md b/docs/examples/coming-soon.md
new file mode 100644
index 0000000..d6aac3b
--- /dev/null
+++ b/docs/examples/coming-soon.md
@@ -0,0 +1,9 @@
+# Coming soon
+
+!!! example "This page is currently under construction"
+
+    Hey there from NucciTheBoss! I am in the process of creating some examples to demonstrate some of the cool things
+    that you can do with cleantest such as creating a continuous integration pipeline on GitHub to test packages 
+    or building your own micro-HPC cluster to test that your simulation works. Stay tuned!
+
+    __Examples will be added in cleantest 0.5.0__
\ No newline at end of file
diff --git a/docs/home/index.md b/docs/home/index.md
new file mode 100644
index 0000000..6962bb0
--- /dev/null
+++ b/docs/home/index.md
@@ -0,0 +1,28 @@
+# Welcome to cleantest's documentation!
+
+`cleantest` is a testing framework for developers who need clean testing environments in a hurry.
+
+## History
+
+The first version of `cleantest` was authored by Jason Nucciarone when he was having trouble finding a safe way to 
+test destructive code locally on his system. He needed to test some code that he wrote to install and manage 
+the Slurm Workload Manager, but he did not want to install the Debian packages directly on his system, and he did not 
+want to go through the environment of setting up a CI/CD pipeline on GitHub. Irked by the idea of potentially needing 
+to run his IDE out of a virtual machine and test his code there, he had an idea: what if there was library that could 
+grab the body of a test, bring up a container, run the test inside that container, and then report back the results as 
+if it never left the current process?
+
+With his idea in mind, he set out to scratch his itch, and eventually decided to name the collection of Python 
+decorators he created `cleantest`. Over time, and after lots of feedback from colleagues and friends, `cleantest` is 
+now the library you see before you.
+
+## What is cleantest?
+
+The sales pitch for `cleantest` is _a testing framework for developers who need clean testing environments in a hurry_,
+but is more than just that. It aims to be an easy way to test code, whether you are on your laptop or an
+exa-scale high-performance computing cluster. It provides tools to work with popular packaging formats, utilities 
+for pushing or pulling artifacts, and ways to test code across multiple Linux distributions. It also enables you to 
+simulate high-performance computing clusters to test software deployments and installation scripts.
+
+If you are interested in learning more, then head on over to [installation](../user-guide/installation.md) page of this 
+documentation!
\ No newline at end of file
diff --git a/docs/index.md b/docs/index.md
deleted file mode 100644
index b61c9e9..0000000
--- a/docs/index.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# Welcome to the cleantest documentation
-
-> This documentation is currently a work-in-progress.
\ No newline at end of file
diff --git a/docs/license.md b/docs/license.md
deleted file mode 100644
index d353ea6..0000000
--- a/docs/license.md
+++ /dev/null
@@ -1,204 +0,0 @@
-<pre>
-                            Apache License
-                        Version 2.0, January 2004
-                    http://www.apache.org/licenses/
-
-        TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
-1. Definitions.
-
-    "License" shall mean the terms and conditions for use, reproduction,
-    and distribution as defined by Sections 1 through 9 of this document.
-
-    "Licensor" shall mean the copyright owner or entity authorized by
-    the copyright owner that is granting the License.
-
-    "Legal Entity" shall mean the union of the acting entity and all
-    other entities that control, are controlled by, or are under common
-    control with that entity. For the purposes of this definition,
-    "control" means (i) the power, direct or indirect, to cause the
-    direction or management of such entity, whether by contract or
-    otherwise, or (ii) ownership of fifty percent (50%) or more of the
-    outstanding shares, or (iii) beneficial ownership of such entity.
-
-    "You" (or "Your") shall mean an individual or Legal Entity
-    exercising permissions granted by this License.
-
-    "Source" form shall mean the preferred form for making modifications,
-    including but not limited to software source code, documentation
-    source, and configuration files.
-
-    "Object" form shall mean any form resulting from mechanical
-    transformation or translation of a Source form, including but
-    not limited to compiled object code, generated documentation,
-    and conversions to other media types.
-
-    "Work" shall mean the work of authorship, whether in Source or
-    Object form, made available under the License, as indicated by a
-    copyright notice that is included in or attached to the work
-    (an example is provided in the Appendix below).
-
-    "Derivative Works" shall mean any work, whether in Source or Object
-    form, that is based on (or derived from) the Work and for which the
-    editorial revisions, annotations, elaborations, or other modifications
-    represent, as a whole, an original work of authorship. For the purposes
-    of this License, Derivative Works shall not include works that remain
-    separable from, or merely link (or bind by name) to the interfaces of,
-    the Work and Derivative Works thereof.
-
-    "Contribution" shall mean any work of authorship, including
-    the original version of the Work and any modifications or additions
-    to that Work or Derivative Works thereof, that is intentionally
-    submitted to Licensor for inclusion in the Work by the copyright owner
-    or by an individual or Legal Entity authorized to submit on behalf of
-    the copyright owner. For the purposes of this definition, "submitted"
-    means any form of electronic, verbal, or written communication sent
-    to the Licensor or its representatives, including but not limited to
-    communication on electronic mailing lists, source code control systems,
-    and issue tracking systems that are managed by, or on behalf of, the
-    Licensor for the purpose of discussing and improving the Work, but
-    excluding communication that is conspicuously marked or otherwise
-    designated in writing by the copyright owner as "Not a Contribution."
-
-    "Contributor" shall mean Licensor and any individual or Legal Entity
-    on behalf of whom a Contribution has been received by Licensor and
-    subsequently incorporated within the Work.
-
-2. Grant of Copyright License. Subject to the terms and conditions of
-    this License, each Contributor hereby grants to You a perpetual,
-    worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-    copyright license to reproduce, prepare Derivative Works of,
-    publicly display, publicly perform, sublicense, and distribute the
-    Work and such Derivative Works in Source or Object form.
-
-3. Grant of Patent License. Subject to the terms and conditions of
-    this License, each Contributor hereby grants to You a perpetual,
-    worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-    (except as stated in this section) patent license to make, have made,
-    use, offer to sell, sell, import, and otherwise transfer the Work,
-    where such license applies only to those patent claims licensable
-    by such Contributor that are necessarily infringed by their
-    Contribution(s) alone or by combination of their Contribution(s)
-    with the Work to which such Contribution(s) was submitted. If You
-    institute patent litigation against any entity (including a
-    cross-claim or counterclaim in a lawsuit) alleging that the Work
-    or a Contribution incorporated within the Work constitutes direct
-    or contributory patent infringement, then any patent licenses
-    granted to You under this License for that Work shall terminate
-    as of the date such litigation is filed.
-
-4. Redistribution. You may reproduce and distribute copies of the
-    Work or Derivative Works thereof in any medium, with or without
-    modifications, and in Source or Object form, provided that You
-    meet the following conditions:
-
-    (a) You must give any other recipients of the Work or
-        Derivative Works a copy of this License; and
-
-    (b) You must cause any modified files to carry prominent notices
-        stating that You changed the files; and
-
-    (c) You must retain, in the Source form of any Derivative Works
-        that You distribute, all copyright, patent, trademark, and
-        attribution notices from the Source form of the Work,
-        excluding those notices that do not pertain to any part of
-        the Derivative Works; and
-
-    (d) If the Work includes a "NOTICE" text file as part of its
-        distribution, then any Derivative Works that You distribute must
-        include a readable copy of the attribution notices contained
-        within such NOTICE file, excluding those notices that do not
-        pertain to any part of the Derivative Works, in at least one
-        of the following places: within a NOTICE text file distributed
-        as part of the Derivative Works; within the Source form or
-        documentation, if provided along with the Derivative Works; or,
-        within a display generated by the Derivative Works, if and
-        wherever such third-party notices normally appear. The contents
-        of the NOTICE file are for informational purposes only and
-        do not modify the License. You may add Your own attribution
-        notices within Derivative Works that You distribute, alongside
-        or as an addendum to the NOTICE text from the Work, provided
-        that such additional attribution notices cannot be construed
-        as modifying the License.
-
-    You may add Your own copyright statement to Your modifications and
-    may provide additional or different license terms and conditions
-    for use, reproduction, or distribution of Your modifications, or
-    for any such Derivative Works as a whole, provided Your use,
-    reproduction, and distribution of the Work otherwise complies with
-    the conditions stated in this License.
-
-5. Submission of Contributions. Unless You explicitly state otherwise,
-    any Contribution intentionally submitted for inclusion in the Work
-    by You to the Licensor shall be under the terms and conditions of
-    this License, without any additional terms or conditions.
-    Notwithstanding the above, nothing herein shall supersede or modify
-    the terms of any separate license agreement you may have executed
-    with Licensor regarding such Contributions.
-
-6. Trademarks. This License does not grant permission to use the trade
-    names, trademarks, service marks, or product names of the Licensor,
-    except as required for reasonable and customary use in describing the
-    origin of the Work and reproducing the content of the NOTICE file.
-
-7. Disclaimer of Warranty. Unless required by applicable law or
-    agreed to in writing, Licensor provides the Work (and each
-    Contributor provides its Contributions) on an "AS IS" BASIS,
-    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
-    implied, including, without limitation, any warranties or conditions
-    of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
-    PARTICULAR PURPOSE. You are solely responsible for determining the
-    appropriateness of using or redistributing the Work and assume any
-    risks associated with Your exercise of permissions under this License.
-
-8. Limitation of Liability. In no event and under no legal theory,
-    whether in tort (including negligence), contract, or otherwise,
-    unless required by applicable law (such as deliberate and grossly
-    negligent acts) or agreed to in writing, shall any Contributor be
-    liable to You for damages, including any direct, indirect, special,
-    incidental, or consequential damages of any character arising as a
-    result of this License or out of the use or inability to use the
-    Work (including but not limited to damages for loss of goodwill,
-    work stoppage, computer failure or malfunction, or any and all
-    other commercial damages or losses), even if such Contributor
-    has been advised of the possibility of such damages.
-
-9. Accepting Warranty or Additional Liability. While redistributing
-    the Work or Derivative Works thereof, You may choose to offer,
-    and charge a fee for, acceptance of support, warranty, indemnity,
-    or other liability obligations and/or rights consistent with this
-    License. However, in accepting such obligations, You may act only
-    on Your own behalf and on Your sole responsibility, not on behalf
-    of any other Contributor, and only if You agree to indemnify,
-    defend, and hold each Contributor harmless for any liability
-    incurred by, or claims asserted against, such Contributor by reason
-    of your accepting any such warranty or additional liability.
-
-END OF TERMS AND CONDITIONS
-
-APPENDIX: How to apply the Apache License to your work.
-
-    To apply the Apache License to your work, attach the following
-    boilerplate notice, with the fields enclosed by brackets "[]"
-    replaced with your own identifying information. (Don't include
-    the brackets!)  The text should be enclosed in the appropriate
-    comment syntax for the file format. We also recommend that a
-    file or class name and description of purpose be included on the
-    same "printed page" as the copyright notice for easier
-    identification within third-party archives.
-
-Copyright [yyyy] [name of copyright owner]
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-
-</pre>
diff --git a/docs/user-guide/artifacts/directory.md b/docs/user-guide/artifacts/directory.md
new file mode 100644
index 0000000..730d8bd
--- /dev/null
+++ b/docs/user-guide/artifacts/directory.md
@@ -0,0 +1,83 @@
+# Working with directories
+
+Directories can be uploaded to and downloaded from test environment instances as artifacts.
+
+## Dir class
+
+The `Dir` class represents a directory. Its constructor accepts three arguments:
+
+* `src (str)`: File path to load directory from.
+* `dest (str)`: File path for where to dump directory.
+* `overwrite (bool)`: Overwrite the directory if it already exists at _dest_. Defaults to _False_.
+
+???+ info "`Dir` versus `File`"
+
+    When the `Dir` class's _load_ method is invoked, an exception will be raised if _src_ is determined to be a file
+    and not a directory. This exception is raised because cleantest handles directories differently than files when
+    dumping out to _dest_. If you are working with files, not directories, you should use the [`File`](./file.md)
+    class instead.
+
+### Supported hooks
+
+The `Dir` class's behavior changes depending on the hook it is used with. Here is a list of hooks that support `Dir`
+and how `Dir` behaves when accessed by them:
+
+[`StartEnvHook`](../hooks.md#startenvhook) 
+
+:   _src_ is loaded from local system and _dest_ is the location to dump the directory
+    inside the test environment instance.
+
+[`StopEnvHook`](../hooks.md#stopenvhook)
+
+:   _src_ is loaded from the test environment instance and _dest_ is the location to dump the directory on the
+    local system.
+
+## Example usage
+
+```python
+#!/usr/bin/env python3
+
+"""Example usage of Dir class."""
+
+import os
+import pathlib
+import tempfile
+
+from cleantest import Configure
+from cleantest.hooks import StartEnvHook, StopEnvHook
+from cleantest.hooks.data import Dir
+from cleantest.provider import lxd
+
+root = os.path.dirname(os.path.realpath(__file__))
+config = Configure()
+start_hook = StartEnvHook(
+    name="upload_artifact",
+    upload=[
+        Dir(os.path.join(root, "greetings"), "/root/greetings"),
+    ],
+)
+stop_hook = StopEnvHook(
+    name="download_artifact",
+    download=[
+        Dir("/root/dump", os.path.join(tempfile.gettempdir(), "dump"), overwrite=True),
+    ],
+)
+config.register_hook(start_hook, stop_hook)
+
+
+@lxd(image="jammy-amd64", preserve=True)
+def work_on_artifacts():
+    import os
+    import pathlib
+    import sys
+
+    print(pathlib.Path("/root/dump").is_dir(), file=sys.stdout)
+    os.mkdir("/root/dump")
+    pathlib.Path("/root/dump/dump_1.txt").write_text("Oh I have been dumped again!")
+
+
+class TestUploadDownload:
+    def test_upload_download(self) -> None:
+        work_on_artifacts()
+        assert pathlib.Path(tempfile.gettempdir()).joinpath("dump").is_dir() is True
+```
\ No newline at end of file
diff --git a/docs/user-guide/artifacts/file.md b/docs/user-guide/artifacts/file.md
new file mode 100644
index 0000000..e3c3340
--- /dev/null
+++ b/docs/user-guide/artifacts/file.md
@@ -0,0 +1,81 @@
+# Working with files
+
+Files can be uploaded to and downloaded from test environment instances as artifacts.
+
+## File class
+
+The `File` class represents a file. Its constructor accepts three arguments:
+
+* `src (str)`: File path to load file from.
+* `dest (str)`: File path for where to dump file.
+* `overwrite (bool)`: Overwrite the file if it already exists at `dest`. Defaults to _False_.
+
+???+ info "`File` versus `Dir`"
+
+    When the `File` class's load method is invoked, an exception will be raised if src is determined to be a directory 
+    and not a file. This exception is raised because cleantest handles files differently than directories when 
+    dumping out to _dest_. If you are working with directories, not files, you should use the [`Dir`](./directory.md) 
+    class instead.
+
+### Supported hooks
+
+The `File` class's behavior changes depending on the hook it is used with. Here is a list of hooks that support `File`
+and how `File` behaves when accessed by them:
+
+[`StartEnvHook`](../hooks.md#startenvhook) 
+
+:   _src_ is loaded from local system and _dest_ is the location to dump the file
+    inside the test environment instance.
+
+[`StopEnvHook`](../hooks.md#stopenvhook)
+
+:   _src_ is loaded from the test environment instance and _dest_ is the location to dump the file on the
+    local system.
+
+## Example usage
+
+```python
+#!/usr/bin/env python3
+
+"""Example usage of File class."""
+
+import os
+import pathlib
+import tempfile
+
+from cleantest import Configure
+from cleantest.hooks import StartEnvHook, StopEnvHook
+from cleantest.hooks.data import File
+from cleantest.provider import lxd
+
+root = os.path.dirname(os.path.realpath(__file__))
+config = Configure()
+start_hook = StartEnvHook(
+    name="upload_artifact",
+    upload=[
+        File(os.path.join(root, "greeting.txt"), "/root/greeting.txt"),
+    ],
+)
+stop_hook = StopEnvHook(
+    name="download_artifact",
+    download=[
+        File("/root/dump.txt", os.path.join(tempfile.gettempdir(), "dump.txt"), overwrite=True),
+    ],
+)
+config.register_hook(start_hook, stop_hook)
+
+
+@lxd(image="jammy-amd64", preserve=True)
+def work_on_artifacts():
+    import pathlib
+    import sys
+
+    print(pathlib.Path("/root/greeting.txt").is_file(), file=sys.stdout)
+    pathlib.Path("/root/dump.txt").write_text("Dumped like a sack of rocks")
+
+
+class TestUploadDownload:
+    def test_upload_download(self) -> None:
+        work_on_artifacts()
+        assert pathlib.Path(tempfile.gettempdir()).joinpath("dump.txt").is_file() is True
+```
diff --git a/docs/user-guide/getting-started.md b/docs/user-guide/getting-started.md
index 6ee5843..b15a78a 100644
--- a/docs/user-guide/getting-started.md
+++ b/docs/user-guide/getting-started.md
@@ -1,8 +1,11 @@
 # Getting started with cleantest
 
-Now that you have the `cleantest` framework installed on your system, let's get you introduced to writing some basic tests. This example assumes that you have LXD installed and configured, and `cleantest` is installed correctly on your system. If not, please revisit the [installation](./installation.md) documentation.
+Now that you have the cleantest framework installed on your system, let's get you introduced to writing some basic 
+tests. This example assumes that you have LXD installed and configured, and cleantest is installed correctly on 
+your system. If not, please revisit the [installation](./installation.md) documentation.
 
-Also, you will need to have [__pytest__](https://docs.pytest.org/en/7.1.x/) installed on your system as well. You can install it using the following command:
+Also, you will need to have [pytest](https://docs.pytest.org/en/7.1.x/) installed on your system as well. 
+You can install it using the following command:
 
 ```text
 pip install pytest
@@ -10,17 +13,20 @@ pip install pytest
 
 ## Some background
 
-The goal of the `cleantest` framework is to provide an easy way to bring up clean testing environments without a hassle. A "simple test" is a test written with `cleantest` that can drop on top of a pre-existing testing framework such as __pytest__ or __unittest__. These "simple tests" can be broken down into three parts: 
+The goal of the cleantest framework is to provide an easy way to bring up clean testing environments without a 
+hassle. A "clean test" is a test written with cleantest that can drop on top of a pre-existing testing framework 
+such as pytest or unittest. These "clean tests" can be broken down into three parts: 
 
 1. Configuration statements
 2. A collection of Testlets
 3. One or more Test Suites
 
-*Configuration statements* control the state and flow of `cleantest`, *testlets* are the tests to be run in the testing environment, and the *test suites* define the order in which the testlets are executed.
+*Configuration statements* control the state and flow of cleantest, _testlets_ are the tests to be run in the testing environment, and the *test suites* define the order in which the testlets are executed.
 
 ## Defining a testlet
 
-A *testlet* is essentially an entire Python file wrapped in a function; it contains all the imports, definitions, and logic a program needs to run. Here is an example of a testlet:
+A *testlet* is essentially an entire Python script encapsulated in a function; it contains all the imports, 
+definitions, and logic a program needs to run. Here is an example of a testlet below:
 
 ```python
 from cleantest.provider import lxd
@@ -37,20 +43,26 @@ def do_something():
         sys.exit(1)
 ``` 
 
-An important thing to note about testlets is that they are not run in the environment as the test suite. The testlets are actually picked up by the interpreter and run somewhere else, and a `Result` object is returned containing an exit code, stdout, and stderr. Therefore, you should always import the Python models and assets you need within the body of the testlet.
+An important thing to note about testlets is that they are not run in the same Python interpreter as the test suite. 
+The testlets are actually picked up by the interpreter and run somewhere else, and a `Result` object is returned 
+containing an exit code, stdout, and stderr. Therefore, you should always import the Python modules and assets you 
+need within the body of the testlet, not in the main test files.
 
 ## Writing a test suite
 
-To evaluate the results of a testlet, you need a *test suite*. This part should be invoked by your testing framework of choice. In our case, we used __pytest__:
+To evaluate the results of a testlet, you need a *test suite*. This part should be invoked by your testing framework 
+of choice. In our case, we used __pytest__:
 
 ```python
 class TestSuite:
     def test_do_something(self) -> None:
-        result = do_something()
-        assert result.exit_code == 0
+        results = do_something()
+        for name, result in results.items()
+            assert result.exit_code == 0
 ```
 
-The test suite should be focused solely on launching testlets and evaulating the results. You should never define testlets inside a test suite. They should always be a top-level, global function.
+The test suite should be focused solely on launching testlets and evaluating the results. You should never define 
+testlets inside a test suite. They should always be a top-level, globally accessible function.
 
 ## Bringing it all together
 
@@ -59,7 +71,7 @@ To bring it all together, combine the testlet and test suite combined into a sin
 ```python
 #!/usr/bin/env python3
 
-"""A basic test"""
+"""A basic test."""
 
 from cleantest.provider import lxd
 
@@ -77,8 +89,9 @@ def do_something():
 
 class TestSuite:
     def test_do_something(self) -> None:
-        result = do_something()
-        assert result.exit_code == 0
+        results = do_something()
+        for name, result in results.items()
+            assert result.exit_code == 0
 ```
 
 Now use __pytest__ to run the test:
@@ -102,8 +115,10 @@ basic_test.py .
 
 ```
 
-Congrats, you have written your first simple test!
+Congrats, you have written your first clean test!
 
 ## Next steps
 
-Now that you have taken your first steps with `cleantest`, proceed to the next section on controlling your test environment with hooks.
+Now that you have taken your first introductory steps with cleantest, you should now go through the rest of the
+documentation and examples to learn about all the things that you can do with cleantest! Of course, learning
+through trial and error also works.
diff --git a/docs/user-guide/hooks.md b/docs/user-guide/hooks.md
new file mode 100644
index 0000000..af68a0d
--- /dev/null
+++ b/docs/user-guide/hooks.md
@@ -0,0 +1,126 @@
+# Using hooks
+
+Hooks are used to run actions at various stages of the cleantest lifecycle. They can be used to configure test
+environments after they have been created, upload dependencies into the test environment, or download artifacts
+after the test has finished. Currently, there are two supported hook types. Their usage is described below.
+
+## StartEnvHook
+
+`StartEnvHook`, or __start environment hook__, is a hook that is run after a test environment instance has been 
+created and initialized. It has two main usages:
+
+1. Install dependencies needed by the testlet.
+2. Uploading artifacts needed by the testlet to run.
+
+Start environment hooks accept the following arguments:
+
+* `name (str)`: Name of the hook. ___Must be unique.___
+* `packages (List[Injectable])`: List of packages to install inside the test environment instance before running the 
+   testlet.
+* `upload (List[Injectable])`: List of artifacts to upload from the local system to the test environment instance.
+
+### Example usage
+
+```python
+#!/usr/bin/env python3
+
+"""Example usage of StartEnvHook."""
+
+import os
+
+from cleantest import Configure
+from cleantest.hooks import StartEnvHook
+from cleantest.pkg import Connection, Plug, Slot, Snap
+from cleantest.provider import lxd
+
+root = os.path.dirname(os.path.realpath(__file__))
+config = Configure()
+start_hook = StartEnvHook(
+    name="test_snaps",
+    packages=[
+        Snap(
+            snaps="pypi-server",
+            connections=[
+                Connection(Plug("pypi-server", "removable-media"), Slot(name="removable-media"))
+            ],
+        ),
+        Snap(local_snaps=os.path.join(root, "hello-world-gtk_0.1_amd64.snap"), dangerous=True),
+    ],
+)
+config.register_hook(start_hook)
+
+
+@lxd(image="jammy-amd64", preserve=False)
+def functional_snaps():
+    import sys
+    from shutil import which
+
+    if which("pypi-server") is None:
+        sys.exit(1)
+    elif which("hello-world-gtk") is None:
+        sys.exit(1)
+    else:
+        sys.exit(0)
+
+
+class TestLocalLXD:
+    def test_snap_package(self) -> None:
+        results = functional_snaps()
+        for name, result in results.items():
+            assert result.exit_code == 0
+```
+
+## StopEnvHook
+
+`StopEnvHook`, or __stop environment hook__, is a hook that is run after the testlet has completed 
+inside the test environment instance. It has one main usage:
+
+1. Downloading artifacts after the testlet has completed.
+
+Stop environment hooks accept the following arguments:
+
+* `name (str)`: Name of the hook. ___Must be unique.___
+* `download (List[Injectable])`: List of artifacts to download from the test environment instance to the local system.
+
+### Example usage
+
+```python
+#!/usr/bin/env python3
+
+"""Example usage of StopEnvHook."""
+
+import os
+import pathlib
+import tempfile
+
+from cleantest import Configure
+from cleantest.hooks import StopEnvHook
+from cleantest.hooks.data import Dir, File
+from cleantest.provider import lxd
+
+config = Configure()
+stop_hook = StopEnvHook(
+    name="download_artifact",
+    download=[
+        File("/root/dump.txt", os.path.join(tempfile.gettempdir(), "dump.txt"), overwrite=True),
+        Dir("/root/dump", os.path.join(tempfile.gettempdir(), "dump"), overwrite=True),
+    ],
+)
+config.register_hook(stop_hook)
+
+@lxd(image="jammy-amd64", preserve=True)
+def work_on_artifacts():
+    import os
+    import pathlib
+
+    pathlib.Path("/root/dump.txt").write_text("Dumped like a sack of rocks")
+    os.mkdir("/root/dump")
+    pathlib.Path("/root/dump/dump_1.txt").write_text("Oh I have been dumped again!")
+
+
+class TestUploadDownload:
+    def test_upload_download(self) -> None:
+        work_on_artifacts()
+        assert pathlib.Path(tempfile.gettempdir()).joinpath("dump.txt").is_file() is True
+        assert pathlib.Path(tempfile.gettempdir()).joinpath("dump").is_dir() is True
+```
\ No newline at end of file
diff --git a/docs/user-guide/installation.md b/docs/user-guide/installation.md
index 5a09636..2cd0bde 100644
--- a/docs/user-guide/installation.md
+++ b/docs/user-guide/installation.md
@@ -2,21 +2,46 @@
 
 ## Install cleantest
 
-`cleantest` can be installed using __pip__:
+The latest stable release of `cleantest` can be installed using __pip__:
 
-```text
+```commandline
 pip install cleantest
 ```
 
-Before you can start writing tests using the `cleantest` framework, you also need to set up a testing environment provider.
+You can also install the latest, bleeding-edge, and potentially _unstable_ development branch
+from GitHub using the following commands:
+
+```commandline
+git clone https://github.com/NucciTheBoss/cleantest.git
+cd cleantest
+python3 -m pip install .
+```
+
+???+ warning
+
+    Before you can start writing tests using cleantest, you must also set up a test environment provider.
+
+## Setting up testing environment providers
 
-## Set up testing environment providers
+You can use the following instructions to set up a supported test environment provider of your choice to be used with 
+`cleantest`. Note that __you do not need to install every testing environment provider listed below__; you only 
+need to install the providers you wish to use for testing.
 
-You can use the following instructions to set up a supported testing environment provider to be used with `cleantest`. Some providers need to be installed before they can be used by `cleantest`, while other providers can be used without any extra set up. Note that __you do not need to install every testing environment provider listed below__; you only need to install the providers you wish to use for testing.
+???+ info "About supported test environment providers"
+
+    LXD is currently the only supported test environment provider. For the best experience, I encourage you to use LXD on
+    Ubuntu. You can connect to the LXD server from other machines such as Mac, Windows, and Linux* using the LXC client. 
+    How to set up the LXC client on Mac and Windows is beyond the scope of this documentation for now. I only
+    currently have the means to use Ubuntu (and it is my favorite distro; sorry Arch/Fedora.) 
+    
+    I have plans to add more test environment provides (ssh, libvirt/kvm, vagrant, etc.) in the future, but for now I am
+    focused on LXD.
 
 ### LXD
 
-To use [LXD](https://ubuntu.com/lxd) as a test environment provider with `cleantest`, you will need to have the [__snap__](https://snapcraft.io/about) package manager and __snapd__ installed on your host system. Once you have snapd and the snap package manager on your host system, use the following command to install LXD:
+To use [LXD](https://ubuntu.com/lxd) as a test environment provider with `cleantest`, you will need to have the
+[snap](https://snapcraft.io/about) package manager and snapd service installed on your host system. Once you have 
+snapd and the snap package manager on your host system, use the following command to install LXD:
 
 ```text
 sudo snap install lxd
@@ -29,11 +54,3 @@ lxd init --auto
 ```
 
 Once the LXD cluster finishes initializing, you can now use LXD as a test environment provider with `cleantest`.
-
-### SSH
-
-> Support for using remote environments via ssh is currently a work-in-progress.
-
-### Local
-
-> Support for destructive, local testing is currently a work-in-progress.
diff --git a/docs/user-guide/packages/charmlib.md b/docs/user-guide/packages/charmlib.md
new file mode 100644
index 0000000..35280a6
--- /dev/null
+++ b/docs/user-guide/packages/charmlib.md
@@ -0,0 +1,97 @@
+# Working with charm libraries
+
+Charm libraries are special Python modules used in charmed operators deployed by [__Juju__](https://juju.is). They
+are an easy way to distribute reusable code without needing to involve any particular package build system.
+Fundamentally, charm libraries are used to provide a means for charm developers to make the implementation of any 
+relation they define as simple as possible for other charm developers.
+
+???+ note
+
+    Comphrensive documentation on how to write/develop/use charm libraries are beyond the scope of this documentation.
+    If you are interested in learning more about charm libraries, please refer to their official documentation
+    here: [https://juju.is/docs/sdk/libraries](https://juju.is/docs/sdk/libraries)
+
+## Charmlib class
+
+`Charmlib` is a package metaclass that represents the charm library installation command _charmcraft fetch-lib_.
+Its constructor accepts two arguments:
+
+1. `auth_token_path (str)`: File path to a Charmhub authentication token. This token is needed to download charm
+    libraries from [charmhub.io](https://charmhub.io).
+2. `charmlibs (List[str])`: List of charm libraries to install inside the test environment instance.
+
+Charm libraries are not installed a special directory such as site-packages or dist-packages; they are directly
+installed to a _lib/_ directory under your current working directory. Therefore, the `Charmlib` class modifies the
+_PYTHONPATH_ environment variable to inform the Python interpreter that there are importable modules under _lib/_.
+
+## Example usage
+
+First, you need to create a Charmhub authentication token. This can be accomplished by using the following command
+in your shell:
+
+```commandline
+charmcraft login --export charmhub.secret
+```
+
+After authenticating with Charmhub (you may need to create an Ubuntu One account), you can now use the example
+test script below: 
+
+```python
+#!/usr/bin/env python3
+
+"""Example usage of Charmlib package metaclass."""
+
+import os
+
+from cleantest import Configure
+from cleantest.hooks import StartEnvHook
+from cleantest.pkg import Charmlib
+from cleantest.provider import lxd
+
+root = os.path.dirname(os.path.realpath(__file__))
+config = Configure()
+start_hook = StartEnvHook(
+    name="charmlib_start_hook",
+    packages=[
+        Charmlib(
+            auth_token_path=os.path.join(root, "charmhub.secret"),
+            charmlibs=["charms.operator_libs_linux.v0.apt"],
+        ),
+    ],
+)
+config.register_hook(start_hook)
+
+
+@lxd(image="jammy-amd64", preserve=False)
+def install_snapd():
+    import sys
+
+    import charms.operator_libs_linux.v0.apt as apt
+
+    try:
+        apt.update()
+        apt.add_package("snapd")
+        print("snapd installed.", file=sys.stdout)
+    except apt.PackageNotFoundError:
+        print("Package could not be found in cache or system.", file=sys.stderr)
+        sys.exit(1)
+    except apt.PackageError as e:
+        print(f"Could not install package. Reason: {e}", file=sys.stderr)
+        sys.exit(1)
+
+    try:
+        snapd = apt.DebianPackage.from_installed_package("snapd")
+        print(f"snapd version {snapd.fullversion} is installed.", file=sys.stdout)
+    except apt.PackageNotFoundError:
+        print("Snapd failed to install.", file=sys.stderr)
+        sys.exit(1)
+
+    sys.exit(0)
+
+    
+class TestCharmlib:
+    def test_charmlib(self) -> None:
+        results = install_snapd()
+        for name, result in results.items():
+            assert result.exit_code == 0
+```
\ No newline at end of file
diff --git a/docs/user-guide/packages/pip.md b/docs/user-guide/packages/pip.md
new file mode 100644
index 0000000..bd2b50e
--- /dev/null
+++ b/docs/user-guide/packages/pip.md
@@ -0,0 +1,68 @@
+# Working with pip packages
+
+Pip is the package installer for the Python programming language. You can use it to install packages from the
+[Python Package Index (PYPI)](https://pypi.org).
+
+## Pip class
+
+`Pip` is a package metaclass that represents the Python package installation command _pip install_. Its constructor
+accepts three arguments:
+
+* `packages (List[str])`: List of packages to install. Packages are pulled from PYPI. Defaults to _None_.
+* `requirements (List[str])`: List of paths to requirements.txt files. Defaults to _None_.
+* `constraints (List[str])`: List of paths to constraints.txt files. Defaults to _None_.
+
+???+ warning "Warning about using multiple requirements and constraints files"
+
+    The requirements and constraints fields in `Pip` are __one-to-one__. If you define multiple requirements files to
+    use and want to use a constraints file with one of the requirements files, then you will need to define a
+    constraints file for each of the requirements files in the list. __If the length of requirements files list does not
+    match the length of the constraints files list, then an exception will be raised.__
+
+    Therefore, if you have multiple requirements files and only one uses a constraints file, it is better to use
+    instantiate two instances of `Pip` class rather than one. One instance can be used for the requirements and 
+    constraints file pairing while the other can be used for the rest of the requirements files.
+
+## Example usage
+
+```python
+#!/usr/bin/env python3
+
+"""Example usage of Pip package metaclass."""
+
+from cleantest import Configure
+from cleantest.hooks import StartEnvHook
+from cleantest.pkg import Pip
+from cleantest.provider import lxd
+
+config = Configure()
+start_hook = StartEnvHook(
+    name="install_tabulate",
+    packages=[
+        Pip(packages=["tabulate"]),
+    ],
+)
+config.register_hook(start_hook)
+
+
+@lxd(image="jammy-amd64", preserve=False)
+def install_tabulate():
+    import sys
+
+    try:
+        from tabulate import tabulate
+
+        print("tabulate is installed.", file=sys.stdout)
+    except ImportError:
+        print("Failed to import tabulate package.", file=sys.stderr)
+        sys.exit(1)
+
+    sys.exit(0)
+
+
+class TestLocalLXD:
+    def test_local_lxd(self) -> None:
+        results = install_tabulate()
+        for name, result in results.items():
+            assert result.exit_code == 0
+```
\ No newline at end of file
diff --git a/docs/user-guide/packages/snaps.md b/docs/user-guide/packages/snaps.md
new file mode 100644
index 0000000..7774b77
--- /dev/null
+++ b/docs/user-guide/packages/snaps.md
@@ -0,0 +1,148 @@
+# Working with snap packages
+
+Snap packages are a bundle of an app and its dependencies that work across a variety of Linux distributions
+without modification. They are automatically managed and maintained by the [snapd](https://snapcraft.io/snapd) 
+service running as a daemon in the background. Snaps can be run on servers, desktops, or internet-of-things devices; 
+they greatly reduce the time-to-market of deploying applications to devices running Linux.
+
+???+ note
+    
+    Comprehensive documentation on how to write and/or develop snap packages are beyond the scope of this
+    documentation. If you are interested in learning more about snap packages, please refer to their official
+    documentation here: [https://snapcraft.io/docs](https://snapcraft.io/docs)
+
+## Confinement class
+
+`Confinement` is an enum that represents the three possible confinement modes for a snap: _strict_, _classic_, 
+and _devmode_. Snaps are commonly run in strict confinement, but certain snaps are granted classic confinement which
+gives the snap unfettered access to the underlying host system. devmode is used when developing snaps to determine
+which interfaces need to be defined and connected.
+
+The class takes no arguments, but it has the following attributes:
+
+* `STRICT`: Represents strict confinement.
+* `CLASSIC`: Represents classic confinement.
+* `DEVMODE`: Represents devmode confinement.
+
+## Plug class
+
+`Plug` is a metaclass that represents a snap plug. Plugs are used to connect a snap package to another snap package. Its
+constructor accepts two arguments:
+
+* `snap (str)`: Name of the snap that provides the plug. Defaults to _None_.
+* `name (str)`: Name of the plug. Defaults to _None_.
+
+## Slot class
+
+`Slot` is a metaclass that represents a snap slot. Slots are to accept connections from other snap packages. Its
+constructor accepts two arguments:
+
+* `snap (str)`: Name of the snap that provides the slot.
+* `name (str)`: Name of the slot.
+
+## Connection class
+
+`Connection` is a metaclass that represents the _snap connect_ command. It is used to connect plugs to slots after
+the snap packages have been installed. Its constructor accepts three arguments:
+
+* `plug (Plug)`: Plug to connect.
+* `slot (Slot)`: Slot to connect to. Defaults to _None_.
+* `wait (bool)`: Wait for _snap connect_ operation to complete before proceeding. Defaults to _True_.
+
+`Connection` provides one private method:
+
+* `_lint`: Lint inputs passed to the constructor to ensure that _snap connect_ will be a valid operation.
+
+`Connection` provides one public method:
+
+* `connect`: Execute _snap connect_ operation. Even though this method is public, __it should not be used when 
+    configuring your hooks.__
+
+## Alias class
+
+`Alias` is a metaclass that represents the _snap alias_ command. It is used to create aliases after a snap package
+has been installed. Its constructor accepts four arguments:
+
+* `snap_name (str)`: Name of the snap that provides the app.
+* `app_name (str)`: Name of the app to create an alias for.
+* `alias_name (str)`: Name of alias to create.
+* `wait (bool)`: Wait for _snap alias_ operation to complete before proceeding. Defaults to _True_.
+
+`Alias` provides one private method:
+
+* `_lint`: Lint inputs passed to the constructor to ensure that _snap alias_ will be a valid operation.
+
+`Alias` provides one public method:
+
+* `alias`: Execute _snap alias_ operation. Even though this method is public, __it should not be used when
+    configuring your hooks.__
+
+## Snap class
+
+`Snap` is a package metaclass that represents the snap installation command `snap install`. Its constructor accepts
+eight arguments:
+
+* `snaps (List[str])`: List of snaps to install inside the test environment instance. These snaps are pulled from
+    the public [Snap Store](https://snapcraft.io/store). Defaults to _None_.
+* `local_snaps (List[str])`: List of file paths to local snap packages to be installed inside the test environment
+    instance. Defaults to _None_.
+* `confinement (Confinement)`: Confinement level to install snaps with. Defaults to _Confinement.STRICT_.
+* `channel (str)`: Channel to install snap from. Only valid for snaps being pulled from store. Defaults to _None_.
+* `cohort (str)`: Key of cohort that snap belongs to/should be installed with. Defaults to _None_.
+* `dangerous (bool)`: Install unsigned snaps. Only valid for local snaps. Defaults to _False_.
+* `connections (List[Connection])`: List of connections to set up after snaps have been installed. Defaults to _None_.
+* `aliases (List[Alias])`: List of aliases to create after snaps have been installed. Defaults to _None_.
+
+The `Snap` class will attempt to install snapd inside the test environment instance if the service is not detected
+when the class goes to install the listed snap packages.
+
+## Example usage
+
+```python
+#!/usr/bin/env python3
+
+"""Example usage of Snap package metaclass with related classes."""
+
+import os
+
+from cleantest import Configure
+from cleantest.hooks import StartEnvHook
+from cleantest.pkg import Connection, Plug, Slot, Snap
+from cleantest.provider import lxd
+
+root = os.path.dirname(os.path.realpath(__file__))
+config = Configure()
+start_hook = StartEnvHook(
+    name="test_snaps",
+    packages=[
+        Snap(
+            snaps="pypi-server",
+            connections=[
+                Connection(Plug("pypi-server", "removable-media"), Slot(name="removable-media"))
+            ],
+        ),
+        Snap(local_snaps=os.path.join(root, "hello-world-gtk_0.1_amd64.snap"), dangerous=True),
+    ],
+)
+config.register_hook(start_hook)
+
+
+@lxd(image="jammy-amd64", preserve=False)
+def functional_snaps():
+    import sys
+    from shutil import which
+
+    if which("pypi-server") is None:
+        sys.exit(1)
+    elif which("hello-world-gtk") is None:
+        sys.exit(1)
+    else:
+        sys.exit(0)
+
+
+class TestLocalLXD:
+    def test_snap_package(self) -> None:
+        results = functional_snaps()
+        for name, result in results.items():
+            assert result.exit_code == 0
+```
\ No newline at end of file
diff --git a/docs/user-guide/parallelization.md b/docs/user-guide/parallelization.md
new file mode 100644
index 0000000..21a16dd
--- /dev/null
+++ b/docs/user-guide/parallelization.md
@@ -0,0 +1,90 @@
+# Running tests in parallel
+
+cleantest has the ability to run test environment instances in parallel to increase the efficiency of test runs.
+You can define the number of threads to use when running test environment instances in parallel as either arguments to 
+the test environment provider, or you can pass the number of threads to use when the test run is started using 
+the `CLEANTEST_NUM_THREADS` environment variable:
+
+```shell
+CLEANTEST_NUM_THREADS=$(nproc) pytest cleantest_test_suite.py
+```
+
+This is the flow that cleantest follows when parallel testing is enabled for a testlet. Rather than proceeding through
+each image sequentially to bring up a test environment instance and inject the testlet, each instance is brought up
+independently in a separate thread and the testlet is injected there. cleantest blocks until all threads have 
+completed. 
+
+```mermaid
+stateDiagram-v2
+    start : @testenvprovider(name="test", image=["jammy-amd64", "focal-amd64", "bionic-amd64"])
+    instances : ["test-jammy-amd64", "test-focal-amd64", "test-bionic-amd64"]
+    jammy : test-jammy-amd64
+    focal : test-focal-amd64
+    bionic : test-bionic-amd64
+    
+    [*] --> start: Test run is started
+    start --> instances: cleantest identifies instances to be created
+    instances --> jammy: Thread #1
+    instances --> focal: Thread #2
+    instances --> bionic: Thread #3
+    jammy --> Results: Result of testlet
+    focal --> Results: Result of testlet
+    bionic --> Results: Result of testlet
+    Results --> Evaluation: Result from each instance is evaluated on local system
+    Evaluation --> [*]
+```
+
+cleantest's built-in parallel testing support is best suited for spread testing; you have a testlet
+that you need to test across different types of test environment instances. For testing multiple testlets in parallel,
+you need to use one of Python's many parallel computing libraries, or third-party tool such as xargs or GNU parallel.
+
+## Examples
+
+### LXD
+
+```python
+#!/usr/bin/env python3
+
+"""Parallel testing example using LXD as test environment provider."""
+
+import os
+
+from cleantest import Configure
+from cleantest.hooks import StartEnvHook
+from cleantest.pkg import Pip
+from cleantest.provider import lxd
+
+config = Configure()
+start_hook = StartEnvHook(name="pip_injection", packages=[Pip(packages="tabulate")])
+config.register_hook(start_hook)
+
+
+@lxd(
+    image=["jammy-amd64", "focal-amd64", "bionic-amd64"],
+    preserve=False,
+    parallel=True,
+    num_threads=os.cpu_count(),
+)
+def install_tabulate():
+    import sys
+
+    try:
+        from tabulate import tabulate
+
+        print("tabulate is installed.", file=sys.stdout)
+    except ImportError:
+        print("Failed to import tabulate package.", file=sys.stderr)
+        sys.exit(1)
+
+    sys.exit(0)
+
+
+class TestParallelLXD:
+    def test_parallel_lxd(self) -> None:
+        results = install_tabulate()
+        for name, result in results.items():
+            try:
+                assert result.exit_code == 0
+            except AssertionError:
+                raise Exception(f"{name} failed. Result: {result}")
+```
\ No newline at end of file
diff --git a/docs/user-guide/using-diff-linux-distro.md b/docs/user-guide/using-diff-linux-distro.md
new file mode 100644
index 0000000..1a88653
--- /dev/null
+++ b/docs/user-guide/using-diff-linux-distro.md
@@ -0,0 +1,16 @@
+# Using different Linux distributions
+
+!!! example "This page is currently under construction"
+
+    Hey there from NucciTheBoss! cleantest is not yet able to support multiple Linux distributions like Debian,
+    CentOS Stream, Alma Linux, Fedora, etc. The only distro officially supported right now is Ubuntu, mostly because
+    Ubuntu is my distro of choice, and well... I work at Canonical. You can to pull your own images with the LXD test
+    environment provider, but I have not gotten around to making that example yet. 
+
+    Mostly what needs to be done to support multiple distros is to refactor the `LXDDataStore` class. Currently I have
+    the defaults hardcoded in, but I would like to potentially make cleantest more intelligent. Rather than hardcode,
+    I would like to enable querying of the `images:` endpoint and other endpoints. That information can then be loaded
+    in at runtime so that users will always have an update-to-date image list. Please bear with me while I work 
+    to make cleantest even better!
+
+    __Multidistro support will be added in cleantest 0.4.0__
\ No newline at end of file
diff --git a/mkdocs.yml b/mkdocs.yml
index 5cefc1e..6e8e714 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -1,9 +1,49 @@
-site_name: Cleantest Framework
+site_name: cleantest
+site_description: |
+  Documentation for cleantest - a testing framework for developers who need clean testing environments in a hurry.
+repo_name: NucciTheBoss/cleantest
+repo_url: https://github.com/NucciTheBoss/cleantest
+
+theme:
+  name: material
+  icon:
+    repo: fontawesome/brands/github
+  palette:
+    primary: black
+  features:
+    - navigation.expand
+    - navigation.tabs
+
+markdown_extensions:
+  - admonition
+  - def_list
+  - pymdownx.details
+  - pymdownx.highlight:
+      anchor_linenums: true
+  - pymdownx.inlinehilite
+  - pymdownx.snippets
+  - pymdownx.superfences:
+      custom_fences:
+        - name: mermaid
+          class: mermaid
+          format: !!python/name:pymdownx.superfences.fence_code_format
+
 nav:
-  - Home: 'index.md'
+  - Home: 'home/index.md'
   - 'User Guide':
     - 'Installation': 'user-guide/installation.md'
-    - 'Getting Started': 'user-guide/getting-started.md'
+    - 'Getting started': 'user-guide/getting-started.md'
+    - 'Test environment providers': 'user-guide/test-env-providers.md'
+    - 'Using hooks': 'user-guide/hooks.md'
+    - 'Working with packages':
+        - 'Charm libraries': 'user-guide/packages/charmlib.md'
+        - 'Pip': 'user-guide/packages/pip.md'
+        - 'Snaps': 'user-guide/packages/snaps.md'
+    - 'Working with artifacts':
+        - 'Directories': 'user-guide/artifacts/directory.md'
+        - 'Files': 'user-guide/artifacts/file.md'
+    - 'Using different Linux distributions': 'user-guide/using-diff-linux-distro.md'
+    - 'Parallel testing': 'user-guide/parallelization.md'
+  - Examples: 'examples/coming-soon.md'
   - About:
-    - 'License': 'license.md'
-theme: readthedocs
+    - 'License': 'about/license.md'
diff --git a/setup.py b/setup.py
index 7dd2402..7b5827d 100644
--- a/setup.py
+++ b/setup.py
@@ -1,18 +1,26 @@
 #!/usr/bin/env python3
-# Copyright 2022 Canonical Ltd.
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
 # See LICENSE file for licensing details.
 
-from setuptools import setup, find_packages
+import pathlib
+
+from setuptools import find_packages, setup
+
+top_dir = pathlib.Path(__file__).parent
+long_description = top_dir.joinpath("README.md").read_text()
 
 
 setup(
     name="cleantest",
-    version="0.2.0",
+    version="0.3.0",
     description="Clean tests for developers in a hurry",
+    long_description=long_description,
+    long_description_content_type="text/markdown",
     author="Jason C. Nucciarone",
     author_email="jason.nucciarone@canonical.com",
     license="Apache-2.0",
-    python_requires=">=3.8",
+    url="https://github.com/NucciTheBoss/cleantest",
+    python_requires=">=3.6",
     packages=find_packages(
         where="src",
         include=["cleantest*"],
@@ -32,6 +40,8 @@
         "Intended Audience :: Science/Research",
         "License :: OSI Approved :: Apache Software License",
         "Operating System :: POSIX :: Linux",
+        "Programming Language :: Python :: 3.6",
+        "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: 3.10",
diff --git a/tox.ini b/tox.ini
index 242e746..9bb8905 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,7 +4,7 @@
 [tox]
 skipsdist=True
 skip_missing_interpreters = True
-envlist = lint, unit
+envlist = fmt, lint
 
 [vars]
 src_path = {toxinidir}/src/
@@ -14,84 +14,92 @@ all_path = {[vars]src_path} {[vars]tst_path}
 [testenv]
 basepython = python3
 setenv =
-  PYTHONPATH = {toxinidir}:{toxinidir}/lib:{[vars]src_path}
-  PYTHONBREAKPOINT=ipdb.set_trace
-  PY_COLORS=1
+    PYTHONPATH = {toxinidir}:{toxinidir}/lib:{[vars]src_path}
+    PYTHONBREAKPOINT=ipdb.set_trace
+    PY_COLORS=1
 passenv =
-  PYTHONPATH
-  CHARM_BUILD_DIR
-  MODEL_SETTINGS
+    PYTHONPATH
 
 [testenv:fmt]
 description = Apply coding style standards to code.
 deps =
-  black
-  isort
+    black
+    isort
 commands =
-  isort {[vars]all_path}
-  black {[vars]all_path}
+    isort {[vars]all_path}
+    black {[vars]all_path}
 
 [testenv:lint]
 description = Check code against coding style standards.
 deps =
-  black
-  flake8
-  flake8-docstrings
-  flake8-copyright
-  flake8-builtins
-  pyproject-flake8
-  pep8-naming
-  isort
-  codespell
+    black
+    flake8
+    flake8-docstrings
+    flake8-copyright
+    flake8-builtins
+    pyproject-flake8
+    pep8-naming
+    isort
+    codespell
 commands =
-  codespell {toxinidir}/. --skip {toxinidir}/.git --skip {toxinidir}/.tox \
-    --skip {toxinidir}/build --skip {toxinidir}/lib --skip {toxinidir}/venv \
-    --skip {toxinidir}/.mypy_cache --skip {toxinidir}/icon.svg
-  # pflake8 wrapper supports config from pyproject.toml
-  pflake8 {[vars]all_path}
-  isort --check-only --diff {[vars]all_path}
-  black --check --diff {[vars]all_path}
+    codespell {toxinidir}/. --skip {toxinidir}/.git --skip {toxinidir}/.tox \
+        --skip {toxinidir}/build --skip {toxinidir}/lib --skip {toxinidir}/venv \
+        --skip {toxinidir}/.mypy_cache --skip {toxinidir}/icon.svg
+    # pflake8 wrapper supports config from pyproject.toml
+    pflake8 {[vars]all_path}
+    isort --check-only --diff {[vars]all_path}
+    black --check --diff {[vars]all_path}
+
+[testenv:docs]
+description = Serve cleantest documentation locally.
+deps =
+    mkdocs
+    mkdocs-material
+commands =
+    mkdocs serve
 
 [testenv:build-docs]
-description = Build simpletest documentation using mkdocs.
+description = Build cleantest documentation.
 deps =
-  mkdocs
+    mkdocs
+    mkdocs-material
+commands =
+    mkdocs build
 
-[testenv:serve-docs]
-description = Serve simpletest documentation locally.
+[testenv:publish-docs]
+description = Publish cleantest documentation to GitHub Pages.
 deps =
-  mkdocs
+    mkdocs
+    mkdocs-material
 commands =
-  mkdocs serve
+    mkdocs gh-deploy
 
 [testenv:publish]
 description = Publish cleantest to PyPI.
 deps = 
-  twine
-  setuptools
-  wheel
+    twine
+    setuptools
+    wheel
 commands =
-  rm -rf {toxinidir}/dist
-  python setup.py sdist bdist_wheel
-  twine upload {toxinidir}/dist/*
-
-[testenv:local]
+    rm -rf {toxinidir}/dist
+    python setup.py sdist bdist_wheel
+    twine upload {toxinidir}/dist/*
 
 [testenv:lxd-local]
 description = Run functional tests for a locally-hosted LXD cluster.
 deps =
-  -r {toxinidir}/requirements.txt
-  pytest
+    -r {toxinidir}/requirements.txt
+    pytest
 commands =
-  pytest -v --tb native --log-cli-level=ERROR {[vars]tst_path}/lxd/local
+    pytest -v --tb native --log-cli-level=ERROR {[vars]tst_path}/lxd/local
 
 [testenv:lxd-local-parallel]
 description = Run functional tests in parallel for a locally-hosted LXD cluster.
 deps =
-  -r {toxinidir}/requirements.txt
-  pytest
+    -r {toxinidir}/requirements.txt
+    pytest
 commands =
-  pytest -v --tb native --log-cli-level=ERROR {[vars]tst_path}/lxd/parallel
+    pytest -v --tb native --log-cli-level=ERROR {[vars]tst_path}/lxd/parallel
 
 [testenv:lxd-remote]
 description = Run functional tests for a remotely-hosted LXD cluster.
@@ -102,15 +110,15 @@ description = Run functional tests in parallel for a remotely-hosted LXD cluster
 [testenv:snaps]
 description = Run functional tests to test if cleantest supports snap packages
 deps =
-  -r {toxinidir}/requirements.txt
-  pytest
+    -r {toxinidir}/requirements.txt
+    pytest
 commands =
-  pytest -v --tb native --log-cli-level=ERROR {[vars]tst_path}/packages/snap
+    pytest -v --tb native --log-cli-level=ERROR {[vars]tst_path}/packages/snap
 
 [testenv:files]
 description = Run functional tests to test file and directory injections.
 deps =
-  -r {toxinidir}/requirements.txt
-  pytest
+    -r {toxinidir}/requirements.txt
+    pytest
 commands =
-  pytest -v --tb native --log-cli-level=ERROR {[vars]tst_path}/files
+    pytest -v --tb native --log-cli-level=ERROR {[vars]tst_path}/files


Test patch:
diff --git a/docs/user-guide/test-env-providers.md b/docs/user-guide/test-env-providers.md
new file mode 100644
index 0000000..2026ec2
--- /dev/null
+++ b/docs/user-guide/test-env-providers.md
@@ -0,0 +1,96 @@
+# Test environment providers
+
+Test environment providers are the backbone of cleantest; they provide the containers or virtual machines that
+cleantest injects the testlets into. Test environment providers can be thought of as hypervisors that can be
+controlled from Python. To control test environment providers, cleantest uses Python decorators. These decorators
+accept arguments from the user and capture the body of the testlet.
+
+The following is a list of all the supported test environment providers in cleantest and how to control them.
+
+## LXD
+
+The `lxd` decorator handles running testlets inside of containers and/or virtual machines controlled by the LXD
+hypervisor. The decorator accepts the following arguments:
+
+* `name (str)`: Prefix to use in name of test environment instances. Defaults to _"test"_.
+* `image (List[str])`: List of LXD images to use for created test environments. Defaults to _["jammy-amd64"]_.
+* `preserve (bool)`: Preserve test environment after testlet has completed. Defaults to _True_.
+* `env (EnvDataStore)`: Environment to use in test environment. Defaults to base instance of _EnvDataStore_.
+* `data (LXDDataStore)`: Data necessary for LXD hypervisor. Defaults to base instance of _LXDDataStore_.
+* `image_config (List[Dict[str, Any]])`: List of image configurations to add to _LXDDataStore_.
+* `client_config (LXDClientConfig)`: Configuration to use for LXD client. Defaults to _None_.
+* `parallel (bool)`: Run test environments in parallel. Defaults to `False`.
+* `num_threads (int)`: Number of threads to use when running test environment in parallel. Defaults to _None_.
+
+### Example usage
+
+```python
+#!/usr/bin/env python3
+
+"""Example usage of LXD test environment provider."""
+
+import os
+
+from cleantest import Configure
+from cleantest.hooks import StartEnvHook
+from cleantest.pkg import Charmlib, Pip
+from cleantest.provider import lxd
+
+# Define the hooks and register them.
+root = os.path.dirname(os.path.realpath(__file__))
+config = Configure()
+start_hook = StartEnvHook(
+    name="my_start_hook",
+    packages=[
+        Charmlib(
+            auth_token_path=os.path.join(root, "charmhub.secret"),
+            charmlibs=["charms.operator_libs_linux.v0.apt"],
+        ),
+        Pip(requirements=os.path.join(root, "requirements.txt")),
+    ],
+)
+config.register_hook(start_hook)
+
+
+# Define the testlets.
+@lxd(image="jammy-amd64", preserve=False)
+def install_snapd():
+    import sys
+
+    import charms.operator_libs_linux.v0.apt as apt
+
+    try:
+        apt.update()
+        apt.add_package("snapd")
+        print("snapd installed.", file=sys.stdout)
+    except apt.PackageNotFoundError:
+        print("Package could not be found in cache or system.", file=sys.stderr)
+        sys.exit(1)
+    except apt.PackageError as e:
+        print(f"Could not install package. Reason: {e}", file=sys.stderr)
+        sys.exit(1)
+
+    try:
+        snapd = apt.DebianPackage.from_installed_package("snapd")
+        print(f"snapd version {snapd.fullversion} is installed.", file=sys.stdout)
+    except apt.PackageNotFoundError:
+        print("Snapd failed to install.", file=sys.stderr)
+        sys.exit(1)
+
+    try:
+        from tabulate import tabulate
+
+        print("tabulate is installed.", file=sys.stdout)
+    except ImportError:
+        print("Failed to import tabulate package.", file=sys.stderr)
+        sys.exit(1)
+
+    sys.exit(0)
+
+    
+class TestLocalLXD:
+    def test_local_lxd(self) -> None:
+        results = install_snapd()
+        for name, result in results.items():
+            assert result.exit_code == 0
+```
diff --git a/src/cleantest/control/configure.py b/src/cleantest/control/configure.py
index f0a37be..4f94a67 100644
--- a/src/cleantest/control/configure.py
+++ b/src/cleantest/control/configure.py
@@ -12,10 +12,14 @@
 
 
 class DuplicateHookNameError(Exception):
+    """Raised when more than one hook in the same classification share the same name."""
+
     ...
 
 
 class HookRegistry:
+    """Dataclass to store hooks based on hook classification."""
+
     start_env: Deque[StartEnvHook] = deque()
     stop_env: Deque[StopEnvHook] = deque()
     start_testlet: Deque[StartTestletHook] = deque()
@@ -23,6 +27,7 @@ class HookRegistry:
 
 
 class Configure:
+    """Configure cleantest."""
 
     _hook_registry = HookRegistry()
     _metadata = set()
@@ -35,6 +40,12 @@ def __new__(cls) -> "Configure":
     def register_hook(
         self, *hooks: Union[StartEnvHook, StopEnvHook, StartTestletHook, StopTestletHook]
     ) -> None:
+        """Register hooks in the hook registry.
+
+        Args:
+            *hooks (Union[StartEnvHook, StopEnvHook, StartTestletHook, StopTestletHook]):
+                Hooks to register.
+        """
         dispatch = {
             StartEnvHook.__name__: self._hook_registry.start_env,
             StopEnvHook.__name__: self._hook_registry.stop_env,
@@ -53,6 +64,11 @@ def register_hook(
             self._metadata.add((hook.name, hook.__class__.__name__))
 
     def deregister_hook(self, *hooks: str) -> None:
+        """Deregister hooks from the hook registry.
+
+        Args:
+            *hooks (str): Name of hook to deregister.
+        """
         dispatch = {
             StartEnvHook.__name__: self._hook_registry.start_env,
             StopEnvHook.__name__: self._hook_registry.stop_env,
@@ -65,13 +81,39 @@ def deregister_hook(self, *hooks: str) -> None:
                     dispatch[hook[1]].remove(hook_name)
 
     def get_start_env_hooks(self) -> Deque[StartEnvHook]:
+        """Get hooks that will run when the test environment starts.
+
+        Returns:
+            (Deque[StartEnvHook]): Deque containing start environment hooks.
+        """
         return copy.deepcopy(self._hook_registry.start_env)
 
     def get_stop_env_hooks(self) -> Deque[StopEnvHook]:
+        """Get hooks that will run when the test environment stops.
+
+        Returns:
+            (Deque[StopEnvHook]): Deque containing stop environment hooks.
+        """
         return copy.deepcopy(self._hook_registry.stop_env)
 
     def get_start_testlet_hooks(self) -> Deque[StartTestletHook]:
+        """Get hooks that will run before the testlet is started.
+
+        Returns:
+            (Deque[StartTestletHook]): Deque containing start testlet hooks.
+
+        Warnings:
+            This hook may be deleted in a later version of cleantest.
+        """
         return copy.deepcopy(self._hook_registry.start_testlet)
 
     def get_stop_testlet_hooks(self) -> Deque[StopTestletHook]:
+        """Get hooks that will run after the testlet has completed.
+
+        Returns:
+            (Deque[StopTestletHook]): Deque containing stop testlet hooks.
+
+        Warnings:
+            This hook may be deleted in a later version of cleantest.
+        """
         return copy.deepcopy(self._hook_registry.stop_testlet)
diff --git a/src/cleantest/hooks/data/directory.py b/src/cleantest/hooks/data/directory.py
index 491e29c..6c0503f 100644
--- a/src/cleantest/hooks/data/directory.py
+++ b/src/cleantest/hooks/data/directory.py
@@ -15,18 +15,33 @@
 
 
 class DirectoryError(Exception):
+    """Base error for Dir class."""
+
     ...
 
 
 class DirectoryExistsError(Exception):
+    """Raised when a directory already exists on the host."""
+
     ...
 
 
 class DirectoryNotFoundError(Exception):
+    """Raised when a directory is not found on the host."""
+
     ...
 
 
 def _strip_tar(tar: tarfile.TarFile, n_components: int = 1) -> Iterable[tarfile.TarInfo]:
+    """Strip components from a tarfile to a specified level.
+
+    Args:
+        tar (tarfile.TarFile): Opened tar archive to strip.
+        n_components (int): Number of levels to strip from tar file (Default: 1).
+
+    Yields:
+        (Iterable[tarfile.TarInfo]): TarInfo object with its path property modified.
+    """
     for member in tar.getmembers():
         member_path = pathlib.Path(member.path)
         member.path = member_path.relative_to(*member_path.parts[:n_components])
@@ -34,10 +49,26 @@ def _strip_tar(tar: tarfile.TarFile, n_components: int = 1) -> Iterable[tarfile.
 
 
 class Dir(File):
+    """Represents a directory that can be shared between host and test environment.
+
+    Args:
+        src (str): Where to load directory from.
+        dest (str): Where to dump directory to.
+        overwrite (bool):
+            True - overwrite directory if it already exists when dumping.
+            False - raise error if directory already exists when dumping.
+    """
+
     def __init__(self, src: str, dest: str, overwrite: bool = False) -> None:
         super().__init__(src, dest, overwrite)
 
     def dump(self) -> None:
+        """Dump directory to specified destination.
+
+        Raises:
+            DirectoryExistsError: Raised if directory already exists and overwrite=False.
+            DirectoryError: Raised if no data has been loaded prior to calling dump().
+        """
         if self.dest.exists() and self.overwrite is False:
             raise DirectoryExistsError(
                 f"{self.dest} already exists. Set overwrite = True to overwrite {self.dest}."
@@ -50,8 +81,14 @@ def dump(self) -> None:
             tar.extractall(self.dest, members=_strip_tar(tar))
 
     def load(self) -> None:
+        """Load directory from specified source.
+
+        Raises:
+            DirectoryNotFoundError: Raised if directory is not found.
+            NotADirectoryError: Raised if source is a file rather than a directory.
+        """
         if self.src.exists() is False:
-            raise FileNotFoundError(f"Could not find {self.src}.")
+            raise DirectoryNotFoundError(f"Could not find {self.src}.")
 
         if self.src.is_file():
             raise NotADirectoryError(f"{self.src} is a file. Use File class instead.")
diff --git a/src/cleantest/hooks/data/file.py b/src/cleantest/hooks/data/file.py
index 34712d4..67ccb74 100644
--- a/src/cleantest/hooks/data/file.py
+++ b/src/cleantest/hooks/data/file.py
@@ -15,14 +15,28 @@
 
 
 class FileError(Exception):
+    """Base error for File class."""
+
     ...
 
 
 class InjectableModeError(Exception):
+    """Raised when an invalid injection mode has been passed to __injectable__()."""
+
     ...
 
 
 class File(Injectable):
+    """Represents a file that can be shared between host and test environment.
+
+    Args:
+        src (pathlib.Path): Where to load file from.
+        dest (pathlib.Path): Where to dump file to.
+        overwrite (bool):
+            True - overwrite file if it already exists when dumping.
+            False - raise error if file already exists when dumping.
+    """
+
     def __init__(self, src: str, dest: str, overwrite: bool = False) -> None:
         self.src = pathlib.Path(src)
         self.dest = pathlib.Path(dest)
@@ -30,6 +44,12 @@ def __init__(self, src: str, dest: str, overwrite: bool = False) -> None:
         self._data = None
 
     def dump(self) -> None:
+        """Dump directory to specified destination.
+
+        Raises:
+            FileExistsError: Raised if directory already exists and overwrite=False.
+            FileError: Raised if no data has been loaded prior to calling dump().
+        """
         if self.dest.exists() and self.overwrite is False:
             raise FileExistsError(
                 f"{self.dest} already exists. Set overwrite = True to overwrite {self.dest}."
@@ -45,6 +65,12 @@ def dump(self) -> None:
             self.dest.write_bytes(pathlib.Path(tmp_dir).joinpath(self.src.name).read_bytes())
 
     def load(self) -> None:
+        """Load file from specified source.
+
+        Raises:
+            FileNotFoundError: Raised if file is not found.
+            FileError: Raised if source is a directory rather than a file.
+        """
         if self.src.exists() is False:
             raise FileNotFoundError(f"Could not find {self.src}.")
 
@@ -60,6 +86,21 @@ def load(self) -> None:
         self._data = archive_path.read_bytes()
 
     def __injectable__(self, path: str, verification_hash: str, mode: str) -> str:
+        """Generate injectable script that will be run inside the test environment.
+
+        Args:
+            path (str): Path to pickled object inside the test environment.
+            verification_hash (str): Hash to verify authenticity of pickled object.
+            mode (str):
+                "upload" - Uploading artifact into test environment.
+                "download" - Downloading artifact from the test environment.
+
+        Raises:
+            InjectableModeError: Raised if invalid mode has been passed.
+
+        Returns:
+            (str): Injectable script.
+        """
         if mode == "upload":
             return textwrap.dedent(
                 f"""
diff --git a/src/cleantest/hooks/startenv.py b/src/cleantest/hooks/startenv.py
index a2ac6a0..a88eb4b 100644
--- a/src/cleantest/hooks/startenv.py
+++ b/src/cleantest/hooks/startenv.py
@@ -20,7 +20,7 @@ def __init__(
 
         Args:
             name (str): Unique name of hook.
-            packages (List[object]): Packages to inject into test environment.
+            packages (List[Injectable]): Packages to inject into test environment.
             upload (List[Injectable]): Artifacts to upload into test environment.
         """
         self.name = name
diff --git a/src/cleantest/meta/base_handler.py b/src/cleantest/meta/base_handler.py
index bf164e1..8f2667a 100644
--- a/src/cleantest/meta/base_handler.py
+++ b/src/cleantest/meta/base_handler.py
@@ -12,9 +12,12 @@
 
 
 class BaseHandlerError(Exception):
+    """Base error for test environment handlers."""
+
     ...
 
 
+# Base result class that should be used by all test environments.
 Result = namedtuple("Result", ["exit_code", "stdout", "stderr"])
 
 
@@ -26,6 +29,7 @@ class BaseEntrypoint(ABC):
 
     @abstractmethod
     def run(self) -> Dict[str, Result]:
+        """Run handler for test environment."""
         ...
 
 
diff --git a/src/cleantest/meta/base_package.py b/src/cleantest/meta/base_package.py
index 034522c..bae534c 100644
--- a/src/cleantest/meta/base_package.py
+++ b/src/cleantest/meta/base_package.py
@@ -10,6 +10,8 @@
 
 
 class BasePackageError(Exception):
+    """Base error for package handlers."""
+
     ...
 
 
diff --git a/src/cleantest/meta/cleantest_info.py b/src/cleantest/meta/cleantest_info.py
index 4d849e2..483fe12 100644
--- a/src/cleantest/meta/cleantest_info.py
+++ b/src/cleantest/meta/cleantest_info.py
@@ -17,6 +17,8 @@
 
 
 class CleantestInfo:
+    """Metaclass for getting information about cleantest module."""
+
     def __new__(cls) -> "CleantestInfo":
         if not hasattr(cls, "instance"):
             cls.instance = super(CleantestInfo, cls).__new__(cls)
@@ -79,7 +81,7 @@ def _get_dependencies(self, dependency: pkg_resources.Distribution) -> Dict[str,
         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.
+        """Generate injectable script to install cleantest packages inside test environment.
 
         Args:
             pkg_path (str): Path to package inside test environment.
diff --git a/src/cleantest/meta/injectable.py b/src/cleantest/meta/injectable.py
index da7a0f4..b1ed981 100644
--- a/src/cleantest/meta/injectable.py
+++ b/src/cleantest/meta/injectable.py
@@ -14,16 +14,22 @@
 
 
 class InjectionError(Exception):
+    """Base error for classes that inherit from Injectable."""
+
     ...
 
 
+# Namedtuple returned by the _dump() method that contains
+# the path to the dumped object and its verification hash for future loading.
 InjectableData = namedtuple("InjectableData", ["path", "hash"])
 
 
 class Injectable(ABC):
+    """Abstract metaclass that provides core methods needed by all injectable objects."""
+
     @classmethod
     def _load(cls, data: str, verification_hash: str) -> object:
-        """Alternative constructor to load previous created object.
+        """Alternative constructor to load previously created object.
 
         Args:
             data (str): Path to file containing serialized object.
@@ -68,5 +74,5 @@ def _dump(self) -> InjectableData:
 
     @abstractmethod
     def __injectable__(self) -> str:
-        """Code to be injected into test environment provider."""
+        """Generate script that will be injected into test environment provider."""
         ...
diff --git a/src/cleantest/pkg/_mixins/snapd_support.py b/src/cleantest/pkg/_mixins/snapd_support.py
index 9b44782..030404f 100644
--- a/src/cleantest/pkg/_mixins/snapd_support.py
+++ b/src/cleantest/pkg/_mixins/snapd_support.py
@@ -11,13 +11,26 @@
 
 
 class SnapdSupportError(Exception):
+    """Base error for SnapdSupport mixin."""
+
     ...
 
 
 class SnapdSupport:
+    """
+    Mixin that provides methods needed by pkg classes that
+    require snapd to be installed inside the test environment.
+    """
+
     @staticmethod
     def _install_snapd() -> None:
-        """Install snapd inside test environment."""
+        """Install snapd inside test environment.
+
+        Raises:
+            SnapdSupportError: Raised if snapd fails to install inside test environment.
+            NotImplementedError: Raised if unsupported operating system is
+                being used for a test environment.
+        """
         os_variant = detect_os_variant()
 
         if which("snap") is None:
diff --git a/src/cleantest/pkg/charmlib.py b/src/cleantest/pkg/charmlib.py
index 9e1d549..401c71d 100644
--- a/src/cleantest/pkg/charmlib.py
+++ b/src/cleantest/pkg/charmlib.py
@@ -19,10 +19,19 @@
 
 
 class CharmlibPackageError(BasePackageError):
+    """Base error for Charmlib package handler."""
+
     ...
 
 
 class Charmlib(BasePackage, SnapdSupport):
+    """Represents `charmcraft fetch-lib`. See: https://juju.is/docs/sdk/charm-libraries.
+
+    Args:
+        auth_token_path (pathlib.Path): Path to authentication token for Charmhub.
+        charmlibs (List[str]): List of charmlibs to install.
+    """
+
     def __init__(
         self,
         auth_token_path: str,
@@ -39,16 +48,23 @@ def __init__(
             raise CharmlibPackageError("No charm libraries specified.")
 
     def _run(self) -> None:
+        """Run Charmlib package handler."""
         self._setup()
         self._handle_charm_lib_install()
         print(json.dumps({"PYTHONPATH": "/root/lib"}), file=sys.stdout)
 
     def _setup(self) -> None:
+        """Set up and install dependencies needed for charm libraries."""
         self._install_snapd()
         if which("charmcraft") is None:
             snap.install("charmcraft", classic=True)
 
     def _handle_charm_lib_install(self) -> None:
+        """Install charm libraries inside test environment.
+
+        Raises:
+            CharmlibPackageError: Raised if charm library fails to install.
+        """
         for charm in self.charmlibs:
             cmd = ["/snap/bin/charmcraft", "fetch-lib", charm]
             try:
@@ -69,6 +85,14 @@ def _handle_charm_lib_install(self) -> None:
                 )
 
     def _dump(self) -> InjectableData:
+        """Dump Charmlib package handler object.
+
+        Raises:
+            FileNotFoundError: Raised if authentication token is not found.
+
+        Returns:
+            (InjectableData): Path to dumped object and verification hash.
+        """
         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}")
 
@@ -77,6 +101,15 @@ def _dump(self) -> InjectableData:
         return super()._dump()
 
     def __injectable__(self, path: str, verification_hash: str) -> str:
+        """Generate injectable script that will be used to install charm libraries.
+
+        Args:
+            path (str): Path to pickled object inside the test environment.
+            verification_hash: Hash to verify authenticity of pickled object.
+
+        Returns:
+            (str): Injectable script.
+        """
         return textwrap.dedent(
             f"""
             #!/usr/bin/env python3
diff --git a/src/cleantest/pkg/handler/snap_handler.py b/src/cleantest/pkg/handler/snap_handler.py
index c182af7..786076d 100644
--- a/src/cleantest/pkg/handler/snap_handler.py
+++ b/src/cleantest/pkg/handler/snap_handler.py
@@ -18,13 +18,15 @@
 from datetime import datetime, timedelta, timezone
 from enum import Enum
 from subprocess import CalledProcessError, CompletedProcess
-from typing import Dict, Iterable, List, Optional, Union
+from typing import Any, Callable, Dict, Iterable, List, Union
 
 # Filter for extracting 7-bit C1 ANSI sequences from a string.
 ansi_filter = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
 
 
 class SnapHandlerError(Exception):
+    """Raised when snap handler encounters any errors."""
+
     ...
 
 
@@ -53,7 +55,7 @@ class SnapState(Enum):
     AVAILABLE = "available"
 
 
-def _cache_init(func):
+def _cache_init(func: Callable):
     def inner(*args, **kwargs):
         if _Cache.cache is None:
             _Cache.cache = SnapCache()
@@ -89,8 +91,8 @@ class SnapService:
 
     def __init__(
         self,
-        daemon: Optional[str] = None,
-        daemon_scope: Optional[str] = None,
+        daemon: str = None,
+        daemon_scope: str = None,
         enabled: bool = False,
         active: bool = False,
         activators: List[str] = [],
@@ -121,8 +123,8 @@ def __init__(
         channel: str,
         revision: str,
         confinement: str,
-        apps: Optional[List[Dict[str, str]]] = None,
-        cohort: Optional[str] = "",
+        apps: List[Dict[str, str]] = None,
+        cohort: str = "",
     ) -> None:
         self._name = name
         self._state = state
@@ -152,16 +154,19 @@ def __str__(self):
         """A human-readable representation of the snap."""
         return f"<{self.__class__.__name__}: {self._name}-{self._revision}.{self._channel} -- {str(self._state)}>"
 
-    def _snap(self, command: str, optargs: Optional[Iterable[str]] = None) -> str:
+    def _snap(self, command: str, optargs: Iterable[str] = None) -> str:
         """Perform a snap operation.
 
         Args:
-          command: the snap command to execute
-          optargs: an (optional) list of additional arguments to pass,
-            commonly confinement or channel
+            command (str): The snap command to execute
+            optargs (Iterable[str]): A list of additional arguments to pass,
+                commonly confinement or channel
 
         Raises:
-          PackageError: If there is a problem encountered.
+          SnapHandlerError: Raised there is a problem encountered when executing snap command.
+
+        Returns:
+            (str): Captured output of operation performed on snap.
         """
         optargs = optargs or []
         _cmd = ["snap", command, self._name, *optargs]
@@ -175,9 +180,17 @@ def _snap(self, command: str, optargs: Optional[Iterable[str]] = None) -> str:
     def _snap_daemons(
         self,
         command: List[str],
-        services: Optional[List[str]] = None,
+        services: List[str] = None,
     ) -> CompletedProcess:
+        """Execute command on a list of snap services.
+
+        Args:
+            command (List[str]): Command to execute on snap services.
+            services (List[str]): Services to operate on (Default: None).
 
+        Returns:
+            (CompletedProcess): Captured output of operation on snap services.
+        """
         if services:
             # an attempt to keep the command constrained to the snap instance's services
             services = [f"{self._name}.{service}" for service in services]
@@ -195,7 +208,10 @@ def get(self, key) -> str:
         """Gets a snap configuration value.
 
         Args:
-            key: The key to retrieve
+            key (str): The key to retrieve.
+
+        Returns:
+            (str): Captured output of get operation.
         """
         return self._snap("get", [key]).strip()
 
@@ -203,71 +219,79 @@ def set(self, config: Dict) -> str:
         """Sets a snap configuration value.
 
         Args:
-           config: A dictionary containing keys and values specifying the config to set.
+           config (Dict): A dictionary containing keys and values specifying the config to set.
+
+        Returns:
+            (str): Captured output of set operation.
         """
         args = [f'{key}="{val}"' for key, val in config.items()]
 
         return self._snap("set", [*args])
 
-    def unset(self, key) -> str:
+    def unset(self, key: str) -> str:
         """Unsets a snap configuration value.
 
         Args:
-            key: the key to unset.
+            key (str): The key to unset.
+
+        Returns:
+            (str): Captured output of unset operation.
         """
         return self._snap("unset", [key])
 
-    def start(self, services: Optional[List[str]] = None, enable: Optional[bool] = False) -> None:
+    def start(self, services: List[str] = None, enable: bool = False) -> None:
         """Starts a snap's services.
 
         Args:
-            services (list): (Optional) List of individual snap services to start (otherwise all).
-            enable (bool): (Optional) Flag to enable snap services on start. Default `false`.
+            services (List[str]): List of individual snap services to start,
+                otherwise all (Default: None).
+            enable (bool): Flag to enable snap services on start (Default: False).
         """
         args = ["start", "--enable"] if enable else ["start"]
         self._snap_daemons(args, services)
 
-    def stop(self, services: Optional[List[str]] = None, disable: Optional[bool] = False) -> None:
+    def stop(self, services: List[str] = None, disable: bool = False) -> None:
         """Stops a snap's services.
 
         Args:
-            services (list): (Optional) List of individual snap services to stop (otherwise all).
-            disable (bool): (Optional) Flag to disable snap services on stop. Default `False`.
+            services (List[str]): List of individual snap services to stop,
+                otherwise all (Default: None).
+            disable (bool): Flag to disable snap services on stop (Default: False).
         """
         args = ["stop", "--disable"] if disable else ["stop"]
         self._snap_daemons(args, services)
 
-    def logs(self, services: Optional[List[str]] = None, num_lines: Optional[int] = 10) -> str:
+    def logs(self, services: List[str] = None, num_lines: int = 10) -> str:
         """Shows a snap services' logs.
 
         Args:
-            services (List[str]): (Optional) List of individual snap services to show logs from
-                (otherwise all).
-            num_lines (int): (Optional) Integer number of log lines to return. Default `10`.
+            services (List[str]): List of individual snap services to show logs from,
+                otherwise all (Default: None).
+            num_lines (int): Integer number of log lines to return (Default: 10).
+
+        Returns:
+            (str): Captured output of logs operation.
         """
         args = ["logs", f"-n={num_lines}"] if num_lines else ["logs"]
         return self._snap_daemons(args, services).stdout
 
-    def restart(
-        self, services: Optional[List[str]] = None, reload: Optional[bool] = False
-    ) -> None:
+    def restart(self, services: List[str] = None, reload: bool = False) -> None:
         """Restarts a snap's services.
 
         Args:
-            services (list): (Optional) List of individual snap services to show logs from
-                (otherwise all).
-            reload (bool): (Optional) Flag to use the service reload command, if available.
-                Default `False`.
+            services (list): List of individual snap services to restart,
+                otherwise all (Default: None).
+            reload (bool): Flag to use the service reload command, if available (Default: False).
         """
         args = ["restart", "--reload"] if reload else ["restart"]
         self._snap_daemons(args, services)
 
-    def _install(self, channel: Optional[str] = "", cohort: Optional[str] = "") -> None:
+    def _install(self, channel: str = "", cohort: str = "") -> None:
         """Add a snap to the system.
 
         Args:
-          channel: The channel to install from.
-          cohort: Optional, the key of a cohort that this snap belongs to.
+            channel (str): The channel to install from (Default: "").
+            cohort (str): The key of a cohort that this snap belongs to (Default: "").
         """
         cohort = cohort or self._cohort
 
@@ -283,16 +307,16 @@ def _install(self, channel: Optional[str] = "", cohort: Optional[str] = "") -> N
 
     def _refresh(
         self,
-        channel: Optional[str] = "",
-        cohort: Optional[str] = "",
-        leave_cohort: Optional[bool] = False,
+        channel: str = "",
+        cohort: str = "",
+        leave_cohort: bool = False,
     ) -> None:
         """Refresh a snap.
 
         Args:
-          channel: The channel to install from.
-          cohort: optionally, specify a cohort.
-          leave_cohort: leave the current cohort.
+            channel (str): The channel to install from (Default: "").
+            cohort (str): Specify a cohort (Default: "").
+            leave_cohort (bool): Leave the current cohort (Default: False).
         """
         channel = f'--channel="{channel}"' if channel else ""
         args = [channel]
@@ -309,31 +333,41 @@ def _refresh(
         self._snap("refresh", args)
 
     def _remove(self) -> str:
-        """Removes a snap from the system."""
+        """Removes a snap from the system.
+
+        Returns:
+            (str): Captured output of remove operation.
+        """
         return self._snap("remove")
 
     @property
     def name(self) -> str:
-        """Returns the name of the snap."""
+        """Get the name of the snap.
+
+        Returns:
+            (str): Name of the snap.
+        """
         return self._name
 
     def ensure(
         self,
         state: SnapState,
-        classic: Optional[bool] = False,
-        channel: Optional[str] = "",
-        cohort: Optional[str] = "",
+        classic: bool = False,
+        channel: str = "",
+        cohort: str = "",
     ):
-        """Ensures that a snap is in a given state.
+        """Ensures that the snap is in a given state.
 
         Args:
-          state: A `SnapState` to reconcile to.
-          classic: An (Optional) boolean indicating whether classic confinement should be used.
-          channel: The channel to install from.
-          cohort: Optional. Specify the key of a snap cohort.
+            state (SnapState): A `SnapState` to reconcile to.
+            classic (bool): A boolean indicating whether classic confinement should
+                be used (Default: False).
+            channel (str): The channel to install from (Default: "").
+            cohort (str): Specify the key of a snap cohort (Default: "").
 
         Raises:
-          PackageError: If an error is encountered.
+            SnapHandlerError: Raised if an error is encountered while
+                ensuring that the snap is in a given state.
         """
         self._confinement = "classic" if classic or self._confinement == "classic" else ""
 
@@ -366,17 +400,30 @@ def _update_snap_apps(self) -> None:
 
     @property
     def present(self) -> bool:
-        """Returns whether a snap is present."""
+        """Get whether a snap is present.
+
+        Returns:
+            (bool): True - snap is present. False - snap is not present.
+        """
         return self._state in (SnapState.PRESENT, SnapState.LATEST)
 
     @property
     def latest(self) -> bool:
-        """Returns whether the snap is the most recent version."""
+        """Get whether the snap is the most recent version.
+
+        Returns:
+            (bool): True - snap is the most recent version.
+                False - snap is not the most recent version.
+        """
         return self._state is SnapState.LATEST
 
     @property
     def state(self) -> SnapState:
-        """Returns the current snap state."""
+        """Get the current snap state.
+
+        Returns:
+            (SnapState): Current snap state.
+        """
         return self._state
 
     @state.setter
@@ -384,10 +431,11 @@ def state(self, state: SnapState) -> None:
         """Sets the snap state to a given value.
 
         Args:
-          state: A `SnapState` to reconcile the snap to.
+          state (SnapState): A `SnapState` to reconcile the snap to.
 
         Raises:
-          PackageError: If an error is encountered.
+          SnapHandlerError: Raised if an error is encountered while setting
+            the state of the snap.
         """
         if self._state is not state:
             self.ensure(state)
@@ -395,28 +443,48 @@ def state(self, state: SnapState) -> None:
 
     @property
     def revision(self) -> str:
-        """Returns the revision for a snap."""
+        """Get the revision of the snap.
+
+        Returns:
+            (str): Revision of the snap.
+        """
         return self._revision
 
     @property
     def channel(self) -> str:
-        """Returns the channel for a snap."""
+        """Get the channel of the snap.
+
+        Returns:
+            (str): Channel of the snap.
+        """
         return self._channel
 
     @property
     def confinement(self) -> str:
-        """Returns the confinement for a snap."""
+        """Get the confinement of the snap.
+
+        Returns:
+            (str): Confinement of the snap.
+        """
         return self._confinement
 
     @property
     def apps(self) -> List:
-        """Returns (if any) the installed apps of the snap."""
+        """Get the installed apps of the snap.
+
+        Returns:
+            (List): Installed apps of the snap, if any.
+        """
         self._update_snap_apps()
         return self._apps
 
     @property
     def services(self) -> Dict:
-        """Returns (if any) the installed services of the snap."""
+        """Get the installed services of the snap.
+
+        Returns:
+            (Dict): Installed services of the snap, if any.
+        """
         self._update_snap_apps()
         services = {}
         for app in self._apps:
@@ -429,14 +497,14 @@ def services(self) -> Dict:
 class _UnixSocketConnection(http.client.HTTPConnection):
     """Implementation of HTTPConnection that connects to a named Unix socket."""
 
-    def __init__(self, host, timeout=None, socket_path=None):
+    def __init__(self, host: str, timeout: float = None, socket_path: Any = None) -> None:
         if timeout is None:
             super().__init__(host)
         else:
             super().__init__(host, timeout=timeout)
         self.socket_path = socket_path
 
-    def connect(self):
+    def connect(self) -> None:
         """Override connect to use Unix socket (instead of TCP socket)."""
         if not hasattr(socket, "AF_UNIX"):
             raise NotImplementedError(f"Unix sockets not supported on {sys.platform}")
@@ -469,18 +537,21 @@ class SnapClient:
     def __init__(
         self,
         socket_path: str = "/run/snapd.socket",
-        opener: Optional[urllib.request.OpenerDirector] = None,
+        opener: urllib.request.OpenerDirector = None,
         base_url: str = "http://localhost/v2/",
         timeout: float = 5.0,
     ):
         """Initialize a client instance.
 
         Args:
-            socket_path: a path to the socket on the filesystem. Defaults to /run/snap/snapd.socket
-            opener: specifies an opener for unix socket, if unspecified a default is used
-            base_url: base url for making requests to the snap client. Defaults to
-                http://localhost/v2/
-            timeout: timeout in seconds to use when making requests to the API. Default is 5.0s.
+            socket_path (str): A path to the socket on the filesystem
+                (Default: "/run/snap/snapd.socket").
+            opener (urllib.request.OpenerDirector): Specifies an opener for unix socket.
+                If set to None, a default is used.
+            base_url (str): Base url for making requests to the snap client. Defaults to
+                (Default: "http://localhost/v2/")
+            timeout (float): Timeout in seconds to use when making requests to the API.
+                (Default: 5.0).
         """
         if opener is None:
             opener = self._get_default_opener(socket_path)
@@ -489,7 +560,7 @@ def __init__(
         self.timeout = timeout
 
     @classmethod
-    def _get_default_opener(cls, socket_path):
+    def _get_default_opener(cls, socket_path: str) -> urllib.request.OpenerDirector:
         """Build the default opener to use for requests (HTTP over Unix socket)."""
         opener = urllib.request.OpenerDirector()
         opener.add_handler(_UnixSocketHandler(socket_path))
@@ -647,11 +718,11 @@ def _load_installed_snaps(self) -> None:
             )
             self._snap_map[snap.name] = snap
 
-    def _load_info(self, name) -> Snap:
+    def _load_info(self, name: str) -> Snap:
         """Load info for snaps which are not installed if requested.
 
         Args:
-            name: A string representing the name of the snap.
+            name (str): A string representing the name of the snap.
         """
         info = self._snap_client.get_snap_information(name)
 
@@ -669,23 +740,26 @@ def _load_info(self, name) -> Snap:
 def install(
     snap_names: Union[str, List[str]],
     state: Union[str, SnapState] = SnapState.LATEST,
-    channel: Optional[str] = "latest",
-    classic: Optional[bool] = False,
-    cohort: Optional[str] = "",
+    channel: str = "latest",
+    classic: bool = False,
+    cohort: str = "",
 ) -> Union[Snap, List[Snap]]:
     """Install a snap or snaps in a remote process.
 
     Args:
-        snap_names: The name or names of the snaps to install.
-        state: A string or `SnapState` representation of the desired state, one of
-            [`PRESENT` or `LATEST`].
-        channel: An (Optional) channel as a string. Defaults to 'latest'.
-        classic: An (Optional) boolean specifying whether it should be added with classic
-            confinement. Default `False`.
-        cohort: Optional, the key of a cohort that this snap belongs to.
+        snap_names (Union[str, List[str]]): The name or names of the snaps to install.
+        state (SnapState): A string or `SnapState` representation of the desired state, one of
+            [`PRESENT` or `LATEST`] (Default: SnapState.LATEST).
+        channel (str): A channel as a string (Default: "latest").
+        classic (bool): A boolean specifying whether it should be added with classic
+            confinement (Default: False).
+        cohort (str): The key of a cohort that this snap belongs to (Default: "").
 
     Raises:
-        PackageError: If some snaps failed to install or were not found.
+        SnapHandlerError: Raised if some snaps failed to install or were not found.
+
+    Returns:
+        (Union[Snap, List[Snap]]): Result of operation.
     """
     snap_names = [snap_names] if type(snap_names) is str else snap_names
     if not snap_names:
@@ -702,10 +776,13 @@ def remove(snap_names: Union[str, List[str]]) -> Union[Snap, List[Snap]]:
     """Removes a snap from the system.
 
     Args:
-        snap_names: The name or names of the snaps to install.
+        snap_names (Union[str, List[str]]): The name or names of the snaps to remove.
 
     Raises:
-        PackageError: If some snaps failed to install.
+        SnapHandlerError: Raised if some snaps failed to be removed.
+
+    Returns:
+        (Union[Snap, List[Snap]]): Result of operation.
     """
     snap_names = [snap_names] if type(snap_names) is str else snap_names
     if not snap_names:
@@ -718,22 +795,22 @@ def remove(snap_names: Union[str, List[str]]) -> Union[Snap, List[Snap]]:
 def ensure(
     snap_names: Union[str, List[str]],
     state: str,
-    channel: Optional[str] = "latest",
-    classic: Optional[bool] = False,
-    cohort: Optional[str] = "",
+    channel: str = "latest",
+    classic: bool = False,
+    cohort: str = "",
 ) -> Union[Snap, List[Snap]]:
     """Ensures a snap is in a given state to the system.
 
     Args:
-        snap_names: The name(s) of the snaps to operate on.
-        state: A string representation of the desired state, from `SnapState`.
-        channel: An (Optional) channel as a string. Defaults to 'latest'.
-        classic: An (Optional) boolean specifying whether it should be added with classic.
-            confinement. Default `False`
-        cohort: Optional, the key of a cohort that this snap belongs to.
+        snap_names (Union[str, List[str]]): The name(s) of the snaps to operate on.
+        state (str): A string representation of the desired state, from `SnapState`.
+        channel (str): A channel as a string (Default: "latest").
+        classic (bool): A boolean specifying whether it should be added with classic.
+            confinement (Default: False).
+        cohort (str): The key of a cohort that this snap belongs to (Default: "").
 
     Raises:
-        PackageError: If the snap is not in the cache.
+        SnapHandlerError: Raised if the snap is not in the cache.
     """
     if state in ("present", "latest"):
         return install(snap_names, SnapState(state), channel, classic, cohort)
@@ -746,9 +823,20 @@ def _wrap_snap_operations(
     state: SnapState,
     channel: str,
     classic: bool,
-    cohort: Optional[str] = "",
+    cohort: str = "",
 ) -> Union[Snap, List[Snap]]:
-    """Wrap common operations for bare commands."""
+    """Wrap common operations for bare snap commands.
+
+    Args:
+        snap_names (List[str]): Snaps to perform operations on.
+        state (SnapState): State to set snap in.
+        channel (str): Channel to track.
+        classic (bool): Ensure snap is in classic confinement or not.
+        cohort (str): Key of cohort to ensure snap belongs to (Default: "").
+
+    Raises:
+        SnapHandlerError: Raised if install or refresh operation fails.
+    """
     snaps = {"success": [], "failed": []}
 
     for s in snap_names:
@@ -772,20 +860,23 @@ def _wrap_snap_operations(
 
 def install_local(
     filename: str,
-    classic: Optional[bool] = False,
-    devmode: Optional[bool] = False,
-    dangerous: Optional[bool] = False,
+    classic: bool = False,
+    devmode: bool = False,
+    dangerous: bool = False,
 ) -> Snap:
-    """Perform a snap operation.
+    """Install snap package using local .snap file.
 
     Args:
-        filename: The path to a local .snap file to install.
-        classic: Whether to use classic confinement.
-        dangerous: Whether --dangerous should be passed to install snaps without a signature.
-        devmode: Whether --devmode should be passed to install snap with devmode confinement.
+        filename (str): The path to a local .snap file to install.
+        classic (bool): Whether --classic should be passed to
+            install snap with classic confinement (Default: False).
+        dangerous (bool): Whether --dangerous should be passed to
+            install snap without a signature (Default: False).
+        devmode (bool): Whether --devmode should be passed to
+            install snap with devmode confinement (Default: False).
 
     Raises:
-        PackageError: If there is a problem encountered.
+        SnapHandlerError: Raised if local .snap package fails to install.
     """
     _cmd = [
         "snap",
@@ -811,7 +902,19 @@ def install_local(
 
 
 def alias(alias_snap: str, app: str, alias: str, wait: bool = True) -> None:
-    """Add an alias for a snap command."""
+    """Add an alias for a snap command.
+
+    Args:
+        alias_snap (str): Snap that provides the app that an alias will be created for.
+        app (str): App inside snap package to create alias for.
+        alias (str): Alias to create.
+        wait (bool):
+            True - wait for alias operation to finish.
+            False - do not wait for alias operation to finish (Default: True).
+
+    Raises:
+        SnapHandlerError: Raised if alias operation fails.
+    """
     _cmd = ["snap", "alias", f"{alias_snap}.{app}", alias]
     if not wait:
         _cmd.append("--no-wait")
@@ -823,8 +926,18 @@ def alias(alias_snap: str, app: str, alias: str, wait: bool = True) -> None:
 
 
 def unalias(alias_snap: str, wait: bool = True) -> None:
-    """Remove an alias for a snap command."""
-    _cmd = ["snap", "alias", alias_snap]
+    """Remove an alias for a snap command.
+
+    Args:
+        alias_snap (str): Snap to remove aliases from..
+        wait (bool):
+            True - wait for unalias operation to finish.
+            False - do not wait for unalias operation to finish (Default: True).
+
+    Raises:
+        SnapHandlerError: Raised if unalias operation fails.
+    """
+    _cmd = ["snap", "unalias", alias_snap]
     if not wait:
         _cmd.append("--no-wait")
 
@@ -835,9 +948,22 @@ def unalias(alias_snap: str, wait: bool = True) -> None:
 
 
 def connect(
-    plug_snap: str, plug: str, slot_snap: str = None, slot: str = None, wait: bool = True
+    plug: str, plug_snap: str, slot_snap: str = None, slot: str = None, wait: bool = True
 ) -> None:
-    """Connect a snap plug to a slot."""
+    """Connect a snap plug to a slot.
+
+    Args:
+        plug (str): Plug to connect to slot.
+        plug_snap (str): Snap that provides plug.
+        slot_snap (str): Snap that provides slot (Default: None).
+        slot (str): Slot to connect to (Default: None).
+        wait (bool):
+            True - wait for connect operation to finish.
+            False - do not wait for connect operation to finish (Default: True).
+
+    Raises:
+        SnapHandlerError: Raised if connect operation fails.
+    """
     _cmd = ["snap", "connect", f"{plug_snap}:{plug}"]
     if slot_snap is not None and slot is not None:
         _cmd.append(f"{slot_snap}:{slot}")
@@ -862,7 +988,23 @@ def disconnect(
     wait: bool = True,
     forget: bool = False,
 ) -> None:
-    """Disconnect a snap plug from a slot"""
+    """Disconnect a snap plug from a slot.
+
+    Args:
+        plug (str): Plug to disconnect from slot.
+        plug_snap (str): Snap that provides plug (Default: None).
+        slot_snap (str): Snap that provides slot (Default: None).
+        slot (str): Slot to disconnect from (Default: None).
+        wait (bool):
+            True - wait for disconnect operation to finish.
+            False - do not wait for disconnect operation to finish (Default: True).
+        forget (bool):
+            True - forget remembered state about connection.
+            False - do not forget remember state about connection (Default: False).
+
+    Raises:
+        SnapHandlerError: Raised if disconnect operation fails.
+    """
     _cmd = ["snap", "disconnect", f":{plug}"]
     if plug_snap is not None:
         _cmd[2] = f"{plug_snap}:{plug}"
@@ -883,8 +1025,11 @@ def _system_set(config_item: str, value: str) -> None:
     """Helper for setting snap system config values.
 
     Args:
-        config_item: Name of snap system setting. E.g. 'refresh.hold'.
-        value: Value to assign.
+        config_item (str): Name of snap system setting. E.g. 'refresh.hold'.
+        value (str): Value to assign.
+
+    Raises:
+        SnapHandlerError: Raised if system set operation fails.
     """
     _cmd = ["snap", "set", "system", f"{config_item}={value}"]
     try:
@@ -897,9 +1042,12 @@ def hold_refresh(days: int = 90) -> None:
     """Set the system-wide snap refresh hold.
 
     Args:
-        days: Number of days to hold system refreshes for. Maximum 90. Set to zero to remove hold.
+        days (int): Number of days to hold system refreshes for. Maximum 90.
+            Set to zero to remove hold.
+
+    Warnings:
+        This method does not currently support snap's experimental `--hold` feature.
     """
-    # Currently the snap daemon can only hold for a maximum of 90 days
     if not type(days) == int or days > 90:
         raise ValueError(f"Days must be an int between 1 and 90. Not {days}.")
     elif days == 0:
diff --git a/src/cleantest/pkg/pip.py b/src/cleantest/pkg/pip.py
index 7f3e821..bafbcf9 100644
--- a/src/cleantest/pkg/pip.py
+++ b/src/cleantest/pkg/pip.py
@@ -15,10 +15,23 @@
 
 
 class PipPackageError(BasePackageError):
+    """Base error for Pip package handler."""
+
     ...
 
 
 class Pip(BasePackage):
+    """Represents `pip install`.
+
+    Args:
+        packages (List[str]): List of packages to install (Default: None).
+        requirements (List[str]): List of paths to requirements.txt files (Default: None).
+        constraints (List[str]): List of paths to constraints.txt files (Default: None).
+
+    Raises:
+        PipPackageError: Raised if lint rule fails at class creation.
+    """
+
     def __init__(
         self,
         packages: Union[str, List[str]] = None,
@@ -48,10 +61,18 @@ def __init__(
                 )
 
     def _run(self) -> None:
+        """Run Pip package handler."""
         self._setup()
         self._handle_pip_install()
 
     def _setup(self) -> None:
+        """Set up and install dependencies needed by pip.
+
+        Raises:
+            PipPackageError: Raised if an error is encountered when installing pip.
+            NotImplementedError: Raised if unsupported operating system is
+                being used for a test environment.
+        """
         os_variant = detect_os_variant()
 
         if which("pip") is None:
@@ -71,6 +92,11 @@ def _setup(self) -> None:
                 )
 
     def _handle_pip_install(self) -> None:
+        """Install packages inside test environment using pip.
+
+        Raises:
+            PipPackageError: Raised if error is encountered when installing packages with pip.
+        """
         if self.packages is not None:
             cmd = ["python3", "-m", "pip", "install", " ".join(self.packages)]
             try:
@@ -129,6 +155,14 @@ def _handle_pip_install(self) -> None:
                     )
 
     def _dump(self) -> InjectableData:
+        """Dump Pip package handler object.
+
+        Raises:
+            FileNotFoundError: Raised if a requirements or constraints file is not found.
+
+        Returns:
+            (InjectableData): Path to dumped object and verification hash.
+        """
         if self.requirements is not None:
             for requirement in self.requirements:
                 fin = pathlib.Path(requirement)
@@ -140,12 +174,21 @@ def _dump(self) -> InjectableData:
             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}.")
+                    raise FileNotFoundError(f"Could not find constraints file {constraint}.")
                 self._constraints_store.append(fin.read_text())
 
         return super()._dump()
 
     def __injectable__(self, path: str, verification_hash: str) -> str:
+        """Generate injectable script that will be used to install packages with pip.
+
+        Args:
+            path (str): Path to pickled object inside the test environment.
+            verification_hash: Hash to verify authenticity of pickled object.
+
+        Returns:
+            (str): Injectable script.
+        """
         return textwrap.dedent(
             f"""
             #!/usr/bin/env python3
diff --git a/src/cleantest/pkg/snap.py b/src/cleantest/pkg/snap.py
index 389abc2..82ab6dd 100644
--- a/src/cleantest/pkg/snap.py
+++ b/src/cleantest/pkg/snap.py
@@ -7,7 +7,7 @@
 import pathlib
 import textwrap
 from enum import Enum
-from typing import List, Optional, Union
+from typing import List, Union
 
 from cleantest.meta import BasePackage, BasePackageError, InjectableData
 from cleantest.pkg.handler import snap
@@ -16,6 +16,8 @@
 
 
 class SnapPackageError(BasePackageError):
+    """Base error for Snap package handler."""
+
     ...
 
 
@@ -28,18 +30,43 @@ class Confinement(Enum):
 
 
 class Plug:
+    """Represents a snap plug.
+
+    Args:
+        snap (str): Name of snap that provides plug.
+        name (str): Name of plug.
+    """
+
     def __init__(self, snap: str, name: str) -> None:
         self.snap = snap
         self.name = name
 
 
 class Slot:
-    def __init__(self, snap: Optional[str] = None, name: Optional[str] = None) -> None:
+    """Represents a snap slot.
+
+    Args:
+        snap (str): Name of snap that provides slot.
+        name (str): Name of slot.
+    """
+
+    def __init__(self, snap: str = None, name: str = None) -> None:
         self.snap = snap
         self.name = name
 
 
 class Connection:
+    """Represents `snap connect`.
+
+    Args:
+        plug (Plug): Plug to connect.
+        slot (Slot): Slot to connect to (Default: None).
+        wait (bool): Wait for `snap connect` operation to complete (Default: True).
+
+    Raises:
+        SnapPackageError: Raised if lint rule fails.
+    """
+
     def __init__(self, plug: Plug, slot: Slot = None, wait: bool = True) -> None:
         self._plug = plug
         self._slot = slot
@@ -47,6 +74,7 @@ def __init__(self, plug: Plug, slot: Slot = None, wait: bool = True) -> None:
         self._lint()
 
     def _lint(self) -> None:
+        """Lint inputs passed to class constructor."""
         if self._plug.snap is None or self._plug.name is None:
             raise SnapPackageError(
                 f"Invalid plug: {self._plug.__dict__}. "
@@ -59,6 +87,7 @@ def _lint(self) -> None:
             )
 
     def connect(self) -> None:
+        """Perform `snap connect` operation."""
         snap.connect(
             self._plug.snap,
             self._plug.name,
@@ -69,6 +98,18 @@ def connect(self) -> None:
 
 
 class Alias:
+    """Represents `snap alias`.
+
+    Args:
+        snap_name (str): Name of snap that provides the app to alias.
+        app_name (str): Name of the app to alias.
+        alias_name (str): Name of alias to create.
+        wait (bool): Wait for `snap alias` operation to complete (Default: True).
+
+    Raises:
+        SnapPackageError: Raised if lint rule fails.
+    """
+
     def __init__(self, snap_name: str, app_name: str, alias_name: str, wait: bool = True) -> None:
         self._snap_name = snap_name
         self._app_name = app_name
@@ -77,6 +118,7 @@ def __init__(self, snap_name: str, app_name: str, alias_name: str, wait: bool =
         self._lint()
 
     def _lint(self) -> None:
+        """Lint inputs passed to class constructor."""
         if self._snap_name is None or self._app_name is None or self._alias_name is None:
             holder = ", ".join(
                 [f"{key} = {value}" for key, value in self.__dict__.items() if value is None]
@@ -84,10 +126,29 @@ def _lint(self) -> None:
             raise SnapPackageError(f"Invalid alias: {holder} cannot be None.")
 
     def alias(self) -> None:
+        """Perform `snap alias` operation."""
         snap.alias(self._snap_name, self._app_name, self._alias_name, self._wait)
 
 
 class Snap(BasePackage, SnapdSupport):
+    """Represents `snap install`.
+
+    Args:
+        snaps (List[str]): List of snaps to install (Default: None).
+        local_snaps (List[str]): List of file paths to local snaps to install (Default: None).
+        confinement (Confinement): Confinement mode to install snaps in
+            (Default: Confinement.STRICT).
+        channel (str): Channel to install snaps from (Default: None).
+        cohort (str): Key of cohort that snap belongs to (Default: None).
+        dangerous (bool): Install unsigned snaps (Default: False).
+        connections (List[Connection]): List of connections to set up after snap is installed
+            (Default: None).
+        aliases (List[Alias]): List aliases to create after snap is installed (Default: None).
+
+    Raises:
+        SnapPackageError: Raised if class creation fails.
+    """
+
     def __init__(
         self,
         snaps: Union[str, List[str]] = None,
@@ -119,13 +180,16 @@ def __init__(
             )
 
     def _run(self) -> None:
+        """Run Snap package handler."""
         self._setup()
         self._handle_snap_install()
 
     def _setup(self) -> None:
+        """Install snapd inside test environment."""
         self._install_snapd()
 
     def _handle_snap_install(self) -> None:
+        """Install snap packages inside test environment."""
         if self.snaps is not None:
             snap.install(
                 self.snaps,
@@ -153,6 +217,14 @@ def _handle_snap_install(self) -> None:
                 alias.alias()
 
     def _dump(self) -> InjectableData:
+        """Dump Snap package handler object.
+
+        Raises:
+            FileNotFoundError: Raised if local snap package is not found.
+
+        Returns:
+            (InjectableData): Path to dumped object and verification hash.
+        """
         if self.local_snaps is not None:
             for local_snap in self.local_snaps:
                 snap_path = pathlib.Path(local_snap)
@@ -163,6 +235,15 @@ def _dump(self) -> InjectableData:
         return super()._dump()
 
     def __injectable__(self, path: str, verification_hash: str) -> str:
+        """Generate injectable script that will be used to install snap packages.
+
+        Args:
+            path (str): Path to pickled object inside the test environment.
+            verification_hash: Hash to verify authenticity of pickled object.
+
+        Returns:
+            (str): Injectable script.
+        """
         return textwrap.dedent(
             f"""
             #!/usr/bin/env python3
diff --git a/src/cleantest/provider/__init__.py b/src/cleantest/provider/__init__.py
index 20734ec..951e8c0 100644
--- a/src/cleantest/provider/__init__.py
+++ b/src/cleantest/provider/__init__.py
@@ -1,6 +1,5 @@
 #!/usr/bin/env python3
-# Copyright 2022 Canonical Ltd.
+# Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
 # See LICENSE file for licensing details.
 
-from .local import local
 from .lxd import lxd
diff --git a/src/cleantest/provider/_handler/lxd_handler.py b/src/cleantest/provider/_handler/lxd_handler.py
index 1a1db58..0d67b33 100644
--- a/src/cleantest/provider/_handler/lxd_handler.py
+++ b/src/cleantest/provider/_handler/lxd_handler.py
@@ -25,6 +25,16 @@
 
 
 class InstanceMetadata:
+    """Metaclass to track key information about LXD test environments.
+
+    Args:
+        name (str): Name of the test environment instance.
+        image (str): Name of image being used for the test environment instance.
+        exists (bool): Bool representing whether test environment exists.
+            True - test environment instance exists.
+            False - test environment instance does not exist (Default: False).
+    """
+
     def __init__(self, name: str, image: str, exists: bool = False) -> None:
         self.name = name
         self.image = image
@@ -32,8 +42,15 @@ def __init__(self, name: str, image: str, exists: bool = False) -> None:
 
 
 class LXDHandler(BaseHandler):
+    """Handler mixin for running tests that use LXD as the test environment provider."""
+
     @property
     def _client(self) -> Client:
+        """Get the connection to the LXD API socket.
+
+        Returns:
+            (Client): Connection to LXD API socket.
+        """
         if self._client_config is None:
             return Client(project="default")
         else:
@@ -48,9 +65,19 @@ def _client(self) -> Client:
 
     @property
     def _instance_metadata(self) -> List[InstanceMetadata]:
+        """Create metaclasses to track key information about LXD test environments.
+
+        Returns:
+            (List[InstanceMetadata]): List of metaclasses.
+        """
         return [InstanceMetadata(name=f"{self._name}-{i}", image=i) for i in self._image]
 
     def _build(self, instance: InstanceMetadata) -> None:
+        """Build LXD test environment instance.
+
+        Args:
+            instance (InstanceMetadata): Instance to build.
+        """
         if instance.exists is False:
             config = self._data.get_config(instance.image)
             config.name = instance.name
@@ -63,6 +90,11 @@ def _build(self, instance: InstanceMetadata) -> None:
                 self._client.instances.get(instance.name).start(wait=True)
 
     def _init(self, instance: Any) -> None:
+        """Initialize LXD test environment instance after it has been built.
+
+        Args:
+            instance (Any): Instance to initialize.
+        """
         meta = CleantestInfo()
         instance.execute(["mkdir", "-p", "/root/init"])
         for module, src in {**meta.src, **meta.dependencies}.items():
@@ -74,26 +106,61 @@ def _init(self, instance: Any) -> None:
             instance.execute(["python3", f"/root/init/install_{module}"])
 
     def _execute(self, test: str, instance: InstanceMetadata) -> Any:
+        """Execute a testlet inside an LXD test environment instance.
+
+        Args:
+            test (str): Testlet to execute inside test environment instance.
+            instance (InstanceMetadata): Test environment instance to execute testlet inside.
+
+        Returns:
+            (Any): Result of the testlet.
+        """
         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: InstanceMetadata) -> None:
+        """Teardown an LXD test environment instance after the testing has completed.
+
+        Args:
+            instance (InstanceMetadata): Test environment instance to teardown.
+        """
         instance = self._client.instances.get(instance.name)
         instance.stop(wait=True)
         instance.delete(wait=True)
 
     def _process(self, result: Any) -> Result:
+        """Process returned result by testlet.
+
+        Args:
+            result (Any): Raw result to process.
+
+        Returns:
+            (Result): Processed result.
+        """
         return Result(exit_code=result.exit_code, stdout=result.stdout, stderr=result.stderr)
 
     def _exists(self, instance: InstanceMetadata) -> InstanceMetadata:
+        """Check whether an instance exists.
+
+        Args:
+            instance (InstanceMetadata): Instance to check the existence of.
+
+        Returns:
+            (InstanceMetadata): Update instance metadata.
+        """
         if self._client.instances.exists(instance.name):
             instance.exists = True
 
         return instance
 
     def _handle_start_env_hooks(self, instance: InstanceMetadata) -> None:
+        """Handle start env hooks.
+
+        Args:
+            instance (InstanceMetadata): Instance to run start env hooks in.
+        """
         start_env_hooks = self._clean_config.get_start_env_hooks()
         while start_env_hooks:
             hook = start_env_hooks.pop()
@@ -106,6 +173,11 @@ def _handle_start_env_hooks(self, instance: InstanceMetadata) -> None:
                     self._handle_artifact_upload(instance, artifact)
 
     def _handle_stop_env_hooks(self, instance: InstanceMetadata) -> None:
+        """Handle stop env hooks.
+
+        Args:
+            instance (InstanceMetadata): Instance to run stop env hooks in.
+        """
         stop_env_hooks = self._clean_config.get_stop_env_hooks()
         while stop_env_hooks:
             hook = stop_env_hooks.pop()
@@ -115,6 +187,12 @@ def _handle_stop_env_hooks(self, instance: InstanceMetadata) -> None:
                     self._handle_artifact_download(instance, artifact)
 
     def _handle_package_install(self, instance: Any, pkg: BasePackage) -> None:
+        """Install a package inside an LXD test environment instance.
+
+        Args:
+            instance (Any): Instance to install package in.
+            pkg (BasePackage): Package to install in instance.
+        """
         dispatch = {"charmlib": lambda x: self._env.add(json.loads(x))}
 
         dump_data = pkg._dump()
@@ -131,6 +209,12 @@ def _handle_package_install(self, instance: Any, pkg: BasePackage) -> None:
             dispatch[pkg.__class__.__name__.lower()](result.stdout)
 
     def _handle_artifact_upload(self, instance: Any, artifact: Injectable) -> None:
+        """Upload an artifact to an LXD test environment instance.
+
+        Args:
+            instance (Any): Instance to upload artifact to.
+            artifact (Injectable): Artifact to upload.
+        """
         artifact.load()
         dump_data = artifact._dump()
         data_path = pathlib.Path(dump_data.path)
@@ -145,6 +229,12 @@ def _handle_artifact_upload(self, instance: Any, artifact: Injectable) -> None:
         instance.execute(["python3", "/root/init/data/dump"])
 
     def _handle_artifact_download(self, instance: Any, artifact: Injectable) -> None:
+        """Download an artifact from an LXD test environment instance.
+
+        Args:
+            instance (Any): Instance to download artifact from.
+            artifact (Injectable): Artifact to download.
+        """
         dump_data = artifact._dump()
         data_path = pathlib.Path(dump_data.path)
         instance.execute(["mkdir", "-p", "/root/post/data"])
@@ -165,12 +255,24 @@ def _handle_artifact_download(self, instance: Any, artifact: Injectable) -> None
 
 
 class Serial(BaseEntrypoint, LXDHandler):
+    """Entrypoint for running tests in serial using LXD.
+
+    Args:
+        attr (Dict[str, Any]): Attributes from lxd decorator to mount.
+        func (Callable): Testlet to inject inside LXD test environment instances.
+    """
+
     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)
         self._func_name = func.__name__
 
     def run(self) -> Dict[str, Result]:
+        """Run LXD tests in serial.
+
+        Returns:
+            (Dict[str, Result]): Aggregated results of all LXD test environment instances.
+        """
         results = {}
         for i in self._instance_metadata:
             self._build(self._exists(i))
@@ -188,12 +290,24 @@ def run(self) -> Dict[str, Result]:
 
 
 class Parallel(BaseEntrypoint, LXDHandler):
+    """Entrypoint for running tests in parallel using LXD.
+
+    Args:
+        attr (Dict[str, Any]): Attributes from lxd decorator to mount.
+        func (Callable): Testlet to inject inside LXD test environment instances.
+    """
+
     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)
         self._func_name = func.__name__
 
     def run(self) -> Dict[str, Result]:
+        """Run LXD tests in parallel.
+
+        Returns:
+            (Dict[str, Any]): Aggregated results of all LXD test environment instances.
+        """
         results = {}
         with ProcessPoolExecutor(
             max_workers=self._num_threads,
@@ -205,6 +319,14 @@ def run(self) -> Dict[str, Result]:
         return results
 
     def _target(self, i: InstanceMetadata) -> Dict[str, Result]:
+        """Target function run inside the parallel process pool.
+
+        Args:
+            i (InstanceMetadata): Instance to operate on.
+
+        Returns:
+            (Dict[str, Result]): Result of test run inside LXD test environment instance.
+        """
         self._build(self._exists(i))
         self._handle_start_env_hooks(i)
         result = self._execute(
@@ -219,10 +341,30 @@ def _target(self, i: InstanceMetadata) -> Dict[str, Result]:
 
 
 class LXDProvider:
+    """Return LXD test environment provider based on passed parameters from lxd decorator."""
+
     @staticmethod
     def serial(lxd, func: Callable) -> "Serial":
+        """Return entrypoint for running tests in serial using LXD.
+
+        Args:
+            lxd (lxd): LXD decorator.
+            func (Callable): Testlet.
+
+        Returns:
+            (Serial): Serial entrypoint.
+        """
         return Serial(lxd.__dict__, func)
 
     @staticmethod
     def parallel(lxd, func: Callable) -> "Parallel":
+        """Return entrypint for running tests in parallel using LXD.
+
+        Args:
+            lxd (lxd): LXD decorator.
+            func (Callable): Testlet.
+
+        Returns:
+            (Parallel): Parallel entrypoint.
+        """
         return Parallel(lxd.__dict__, func)
diff --git a/src/cleantest/provider/data/_mixins/dict_ops.py b/src/cleantest/provider/data/_mixins/dict_ops.py
index da5b547..6f95b12 100644
--- a/src/cleantest/provider/data/_mixins/dict_ops.py
+++ b/src/cleantest/provider/data/_mixins/dict_ops.py
@@ -8,10 +8,25 @@
 
 
 class DictOps:
+    """Mixin that provides dictionary methods needed by data providers."""
+
     def dict(self) -> Dict:
+        """Return class as a unidirectional dictionary.
+
+        Returns:
+            (Dict): Class as a dictionary.
+        """
         return self._filterdict(self.__dict__)
 
     def _filterdict(self, input_dict: Dict) -> Dict:
+        """Recursively filter dictionary containing classes with __dict__ attribute.
+
+        Args:
+            input_dict (Dict): Dictionary containing classes with __dict__ attribute.
+
+        Returns:
+            (Dict): Dictionary with containing parsed __dict__ attributes.
+        """
         result = {}
         for key, value in input_dict.items():
             if hasattr(value, "__dict__"):
diff --git a/src/cleantest/provider/data/env_data.py b/src/cleantest/provider/data/env_data.py
index 4fe4851..73401a9 100644
--- a/src/cleantest/provider/data/env_data.py
+++ b/src/cleantest/provider/data/env_data.py
@@ -9,20 +9,22 @@
 
 
 class EnvDataStore:
+    """Manage environment data for test environments."""
 
-    __env = {}
+    _env = {}
 
     def __new__(cls) -> object:
         if not hasattr(cls, "instance"):
             cls.instance = super(EnvDataStore, cls).__new__(cls)
         return cls.instance
 
-    @property
-    def _raw_env(self) -> Dict[str, Any]:
-        return self.__env
-
     def add(self, env_mapping: Dict[str, Any]) -> None:
-        self.__env.update(env_mapping)
+        """Add new values to environment.
+
+        Args:
+            (Dict[str, Any]): Key, value mapping to add to the environment store.
+        """
+        self._env.update(env_mapping)
 
     def get(self, env_var: str) -> Union[Any, None]:
         """Retrieve environment variable from store.
@@ -31,17 +33,23 @@ def get(self, env_var: str) -> Union[Any, None]:
             env_var (str): Environment variable to retrieve.
 
         Returns:
-            Any | None: Environment variable value. Returns None if variable is not in store.
+            (Union[Any, None]): Environment variable value.
+                Returns None if variable is not in store.
         """
         try:
-            if type(self.__env[env_var]) == list:
-                return os.pathsep.join(self.__env[env_var])
+            if type(self._env[env_var]) == list:
+                return os.pathsep.join(self._env[env_var])
         except KeyError:
             return None
 
     def dump(self) -> Dict[str, Any]:
+        """Dump environment store as a dictionary.
+
+        Returns:
+            (Dict[str, Any]): Environment store as a dictionary.
+        """
         result = {}
-        for k, v in self.__env.items():
+        for k, v in self._env.items():
             if type(v) == list:
                 result.update({k: os.pathsep.join(v)})
             else:
diff --git a/src/cleantest/provider/data/lxd_data.py b/src/cleantest/provider/data/lxd_data.py
index 31698f9..6ed83f5 100644
--- a/src/cleantest/provider/data/lxd_data.py
+++ b/src/cleantest/provider/data/lxd_data.py
@@ -2,7 +2,7 @@
 # Copyright 2022 Jason C. Nucciarone, Canonical Ltd.
 # See LICENSE file for licensing details.
 
-"""Information needed for LXD test provider."""
+"""Information needed by LXD test environment provider."""
 
 from typing import Any, Dict, List, Optional, Union
 
@@ -22,6 +22,16 @@ class LXDConfigNotFoundError(Exception):
 
 
 class LXDSource:
+    """Metaclass to define an LXD image to use for the test environment.
+
+    Args:
+        type (str): Type of artifact to pull.
+        mode (str): Mode in which to access the artifact.
+        server (str): Server to get the artifact from.
+        protocol (str): Protocol to use when pulling/pushing the artifact.
+        alias (str): Alias of the artifact.
+    """
+
     def __init__(self, type: str, mode: str, server: str, protocol: str, alias: str) -> None:
         self.type = type
         self.mode = mode
@@ -31,6 +41,14 @@ def __init__(self, type: str, mode: str, server: str, protocol: str, alias: str)
 
 
 class LXDConfig(DictOps):
+    """Metaclass to define an LXD container or virtual machine to bring up.
+
+    Args:
+        name (str): Name to use for container or virtual machine.
+        source (LXDSource): Where to get the LXD image from.
+        project (str): Project to make LXD container or virtual machine member of.
+    """
+
     def __init__(self, name: str, source: LXDSource, project: Optional[str] = None) -> None:
         self.name = name
         self.source = source
@@ -38,6 +56,14 @@ def __init__(self, name: str, source: LXDSource, project: Optional[str] = None)
 
 
 class Defaults:
+    """Define default images that can be used with test environment provider.
+
+    Notes:
+        jammy_amd64: Ubuntu 22.04 LTS amd64
+        focal_amd64: Ubuntu 20.04 LTS amd64
+        bionic_amd64: Ubuntu 18.04 LTS amd64
+    """
+
     jammy_amd64: Dict[str, Any] = {
         "name": "jammy-amd64",
         "source": {
@@ -75,31 +101,39 @@ class Defaults:
 
 class LXDDataStore:
     def __init__(self) -> None:
-        self.__defaults = Defaults()
-        self.__config_registry = []
-        self.add_config(self.__defaults.jammy_amd64)
-        self.add_config(self.__defaults.focal_amd64)
-        self.add_config(self.__defaults.bionic_amd64)
+        self._defaults = Defaults()
+        self._config_registry = []
+        self.add_config(self._defaults.jammy_amd64)
+        self.add_config(self._defaults.focal_amd64)
+        self.add_config(self._defaults.bionic_amd64)
+
+    def get_config(self, config_name: str) -> LXDConfig:
+        """Get the configuration of an LXD image.
 
-    @property
-    def _raw_config(self) -> List[LXDConfig]:
-        return self.__config_registry
+        Args:
+            config_name (str): Name of configuration to get.
 
-    @property
-    def _raw_defaults(self) -> Defaults:
-        return self.__defaults
+        Raises:
+            LXDConfigNotFoundError: Raised if configuration does not exist in registry.
 
-    def get_config(self, config_name: str) -> LXDConfig:
-        for c in self.__config_registry:
+        Returns:
+            (LXDConfig): Retrieved LXD image configuration.
+        """
+        for c in self._config_registry:
             if c.name == config_name:
                 return c
 
         raise LXDConfigNotFoundError(config_name)
 
     def add_config(self, new_config: Dict[str, Any]) -> None:
+        """Add a new LXD image configuration to the registry.
+
+        Args:
+            new_config (Dict[str, Any]): New configuration to add to the registry.
+        """
         self._lint_config(new_config)
         source = new_config.get("source")
-        self.__config_registry.append(
+        self._config_registry.append(
             LXDConfig(
                 name=new_config.get("name"),
                 source=LXDSource(
@@ -114,17 +148,33 @@ def add_config(self, new_config: Dict[str, Any]) -> None:
         )
 
     def _lint_config(self, new_config: Dict[str, Any]) -> None:
+        """Lint a new LXD image configuration to ensure that it is valid.
+
+        Args:
+            new_config (Dict[str, Any]): New configuration to lint.
+
+        Raises:
+            BadLXDConfigError: Raised if the passed LXD image configuration is invalid.
+        """
         checks = ["name", "server", "alias", "protocol", "type", "mode"]
-        config = self.__deconstruct(new_config)
+        config = self._deconstruct(new_config)
         for i in checks:
             if i not in config:
                 raise BadLXDConfigError(new_config)
 
-    def __deconstruct(self, d: Dict[str, Any]) -> List[str]:
+    def _deconstruct(self, d: Dict[str, Any]) -> List[str]:
+        """Recursively deconstruct a dictionary to get all of its keys.
+
+        Args:
+            d (Dict[str, Any]): Dictionary to deconstruct.
+
+        Returns:
+            (List[str]): Keys of deconstructed dictionary.
+        """
         config = []
         for k in d.keys():
             if isinstance(d[k], dict):
-                config.extend(self.__deconstruct(d[k]))
+                config.extend(self._deconstruct(d[k]))
             else:
                 config.append(k)
 
diff --git a/src/cleantest/provider/local.py b/src/cleantest/provider/local.py
deleted file mode 100644
index 80522f6..0000000
--- a/src/cleantest/provider/local.py
+++ /dev/null
@@ -1,15 +0,0 @@
-#!/usr/bin/env python3
-# Copyright 2022 Canonical Ltd.
-# See LICENSE file for licensing details.
-
-"""Local test environment provider functions and utilities."""
-
-from typing import Callable
-
-
-class local:
-    def __init__(self, func: Callable) -> None:
-        self.func = func
-
-    def __call__(self) -> None:
-        pass
diff --git a/src/cleantest/provider/lxd.py b/src/cleantest/provider/lxd.py
index 954f4b1..8b1d5a2 100644
--- a/src/cleantest/provider/lxd.py
+++ b/src/cleantest/provider/lxd.py
@@ -14,12 +14,28 @@
 
 from ._handler import LXDProvider
 
+# Configure LXD client configuration if hypervisor is not on localhost.
 LXDClientConfig = namedtuple(
     "LXDClientConfig", ["endpoint", "version", "cert", "verify", "timeout", "project"]
 )
 
 
 class lxd:
+    """LXD test environment provider.
+
+    Args:
+        name (str): Name for test environment (Default: "test").
+        image (List[str]): LXD image to use for test environment (Default: ["jammy-amd64"]).
+        preserve (bool): Preserve test environment after test has completed (Default: True).
+        env (EnvDataStore): Environment to use in test environment (Default: EnvDataStore).
+        data (LXDDataStore): Data necessary for LXD (Default: LXDDataStore).
+        image_config (List[Dict[str, Any]]): Configuration to use for LXD image (Default: None).
+        client_config (LXDClientConfig): Configuration to use for LXD client (Default: None).
+        parallel (bool): Run test environments in parallel (Default: False).
+        num_threads (int): Number of threads to use when running
+            test environments in parallel (Default: None).
+    """
+
     def __init__(
         self,
         name: str = "test",
@@ -60,6 +76,8 @@ def __init__(
             self._num_threads = num_threads
 
     def __call__(self, func: Callable) -> Callable:
+        """Callable for lxd decorator."""
+
         def wrapper(*args, **kwargs) -> Dict[str, Result]:
             handler = (
                 LXDProvider.parallel(self, func)
diff --git a/src/cleantest/utils/detect_os.py b/src/cleantest/utils/detect_os.py
index 16f01bb..cd5d441 100644
--- a/src/cleantest/utils/detect_os.py
+++ b/src/cleantest/utils/detect_os.py
@@ -8,10 +8,20 @@
 
 
 class UnknownOSError(Exception):
+    """Raised when an unknown operating system is detected."""
+
     ...
 
 
 def detect_os_variant() -> str:
+    """Detect the operating system of a test environment.
+
+    Raises:
+        UnknownOSError: Raised if an unknown operating system has been detected.
+
+    Returns:
+        (str): ID of the test environment's operating system.
+    """
     try:
         dispatch = {
             "linux": _determine_linux,
@@ -25,6 +35,11 @@ def detect_os_variant() -> str:
 
 
 def _determine_linux() -> str:
+    """Determine Linux distribution by reading /etc/os-release.
+
+    Returns:
+        (str): ID of the Linux distribution read from /etc/os-release.
+    """
     os_release_data = [line.strip() for line in open("/etc/os-release", "rt")]
     for line in os_release_data:
         if line.startswith("ID="):
diff --git a/tests/local/test_local.py b/tests/local/test_local.py
deleted file mode 100644
index bf98b47..0000000
--- a/tests/local/test_local.py
+++ /dev/null
@@ -1,3 +0,0 @@
-#!/usr/bin/env python3
-# Copyright 2022 Canonical Ltd.
-# See LICENSE file for licensing details.