Repository: omnivector-solutions/jobbergate (language: python)

Issue:
JOB-93 Refactored Jobbergate API to use postgres for unit tests
`Task`: https://app.clickup.com/t/18022949/JOB-93

Changed the database backend to postgres. This requires a database
instance to be running externally. This may be done by standing up the
testing database via `docker-compose` or by using the new `jobbergate db
start-test` command. The test performance is slower, but the validation
is improved because the tests better represent the environmen in which
the code will ultimately be running.

Notable changes:
* Added some cli helpers based on `py-docker-gadgets` to stand up
  database instances with docker
* Moved command-line helpers in to a `cli` submodule and rebuilt them as
  a single typer app with sub-commands
* Removed `DATABASE_URL` as a configruation option. Instead, the url
  MUST always be built from parts
* Added `TEST_DATABASE_` configuration settings to make sure that the
  tests cannot accidently target a non-test database
* Added `TEST` item to DeployEnvEnum. This is explicitly set in the
  pytest env section of pyproject.toml to ensure tests target test DBs.
* Updated the docker-compose to improve usability in a dev environment
* Added pyproject-flake8 to allow flake8 config to live in
  pyproject.toml
* Used `returning` clauses for `create` and `update` endpoints to avoid
  a second query. This is only possible in postgres (SQLite doesn't
  support `returning`)
* Removed double-serialization of data responses. FastAPI does
  serialization automatically; no need to do it explicitly in the methods
* Removed explicit `with database.transaction` clauses. Databases
  already creates transactions for async tasks automatically.
* Added an error handler for handling foreign key errors so that the
  `handle_fk_error` context manager can instead just be used globally.
* Removed `compat` module: it's not necesary any more.
* Removed `create_all()` from main. Non-test databases should use
  alembic to apply schema changes.
* Reworked pagination/packaging for list responses. Added an
  `ok_response` helper method to make response typing on list endpoints
  re-usable.
* Added `fill_.*_data` fixtures to make test setup easier (combine
  default data with explicitly set fields for the test cases)
* Removed reliance in unit tests on primary key values and removed
  assignment of primary keys. This does not work in postgres due to
  persistent pk-indexes even after rollback.
* Adjusted test fixtures that dealt with the database to be more
  correct.
* Lots of fixes and miscellaneous adjustments to unit tests.
* Bumped IPython dependency due to security issue. This was not severe
  since IPyhton is only a dev dependency, but it will shut dependabot up


---

#### Peer Review
Please follow the upstream omnivector documentation concerning
[peer-review guidelines](https://github.com/omnivector-solutions/Documentation/blob/main/Contributing/pr_review_standards.md#peer-review).



Discussion / hints:
> Looks really nice.
> 
> Only two things ....
> 
> 1. `jobbergate-api/jobbergate_api/cli/__init__.py`, `jobbergate-api/jobbergate_api/cli/db.py`, `jobbergate-api/jobbergate_api/cli/dev_server.py` are development tools. Is there a reason for maintaining these tools as part of `jobbergate-api/jobbergate_api/cli` proper?
> 2. Endpoint descriptions end up in the swagger docs. Please close the endpoint descriptions with periods so they read like a sentence when inspecting the docs.

1. No, these tools could be moved into a separate sub-package that isn't installed when the package is installed with pip
2. Will do.
> Looks really nice.
> 
> Only two things ....
> 
> 1. `jobbergate-api/jobbergate_api/cli/__init__.py`, `jobbergate-api/jobbergate_api/cli/db.py`, `jobbergate-api/jobbergate_api/cli/dev_server.py` are development tools. Is there a reason for maintaining these tools as part of `jobbergate-api/jobbergate_api/cli` proper?
> 2. Endpoint descriptions end up in the swagger docs. Please close the endpoint descriptions with periods so they read like a sentence when inspecting the docs.

Both points have been addressed in the latest commit.

Fix patch:
diff --git a/fake-sbatch/Makefile b/fake-sbatch/Makefile
new file mode 100644
index 00000000..91df1778
--- /dev/null
+++ b/fake-sbatch/Makefile
@@ -0,0 +1,50 @@
+.ONESHELL:
+.DEFAULT_GOAL:=help
+SHELL:=/bin/bash
+PACKAGE_NAME:=fake_sbatch
+
+.PHONY: install
+install:
+	poetry install
+
+.PHONY: test
+test: install
+	poetry run pytest
+
+.PHONY: lint
+lint: install
+	poetry run black --check ${PACKAGE_NAME} tests
+	poetry run isort --check ${PACKAGE_NAME} tests
+	poetry run pflake8 ${PACKAGE_NAME} tests
+
+.PHONY: mypy
+mypy: install
+	poetry run mypy ${PACKAGE_NAME} --pretty
+
+.PHONY: qa
+qa: test lint mypy
+	echo "All quality checks pass!"
+
+.PHONY: format
+format: install
+	poetry run black ${PACKAGE_NAME} tests
+	poetry run isort ${PACKAGE_NAME} tests
+
+.PHONY: clean
+clean:
+	@find . -iname '*.pyc' -delete
+	@find . -iname '*.pyo' -delete
+	@find . -iname '*~' -delete
+	@find . -iname '*.swp' -delete
+	@find . -iname '__pycache__' -delete
+	@rm -rf .mypy_cache
+	@rm -rf .pytest_cache
+	@find . -name '*.egg' -print0|xargs -0 rm -rf --
+	@rm -rf .eggs/
+	@rm -fr build/
+	@rm -fr dist/
+	@rm -fr *.egg-info
+
+.PHONY: help
+help: # Display target comments in 'make help'
+	grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
diff --git a/fake-sbatch/README.rst b/fake-sbatch/README.rst
new file mode 100644
index 00000000..c7009f51
--- /dev/null
+++ b/fake-sbatch/README.rst
@@ -0,0 +1,40 @@
+=============
+ Fake sbatch
+=============
+
+A testing utility that returns ``sbatch`` output assuming a certain failure percentage.
+
+By default, ``fake-sbatch`` will fail 10% of the time (for testing error cases).
+
+
+
+Usage
+-----
+
+.. code-block:: console
+
+    $ fake-sbatch
+    fake-sbatch: Submitted batch job 653898
+
+
+Settings
+--------
+
+If you wish to override the fail percentage (for example, if you wanted it to always or never fail),
+you may set the it with: ``FAKE_SBATCH_FAIL_PCT=1.0``. 1.0 means it will always fail. 0.0 means it will
+never fail.
+
+You can also control the range of randomly produced slurm job id values by adjusting the ``FAKE_SBATCH_MAX_JOB_ID`` and
+``FAKE_SBATCH_MIN_JOB_ID`` settings.
+
+
+Setting up to use with Jobbergate CLI
+-------------------------------------
+
+A simple way to make the ``fake-sbatch`` executable available for jobbergate is to install it via ``pipx``.
+
+Simply, navigate to the root of the ``fake-sbatch`` repository and execute::
+
+   $ pipx install .
+
+Then, in your Jobbergate environment, apply the setting ``SBATCH_PATH=fake-sbatch``
diff --git a/fake-sbatch/fake_sbatch/config.py b/fake-sbatch/fake_sbatch/config.py
new file mode 100644
index 00000000..2f7e485d
--- /dev/null
+++ b/fake-sbatch/fake_sbatch/config.py
@@ -0,0 +1,15 @@
+from pathlib import Path
+
+from pydantic import BaseSettings, Field
+
+
+class Settings(BaseSettings):
+    FAKE_SBATCH_FAIL_PCT: float = Field(0.1, ge=0.0, le=1.0)
+    FAKE_SBATCH_MAX_JOB_ID: int = 1_000_000
+    FAKE_SBATCH_MIN_JOB_ID: int = 1
+
+    class Config:
+        env_file = Path(".env")
+
+
+settings = Settings()
diff --git a/fake-sbatch/fake_sbatch/main.py b/fake-sbatch/fake_sbatch/main.py
new file mode 100644
index 00000000..4461ccbd
--- /dev/null
+++ b/fake-sbatch/fake_sbatch/main.py
@@ -0,0 +1,23 @@
+from random import random, randint
+
+import typer
+
+from fake_sbatch.config import settings
+
+
+app = typer.Typer()
+
+
+@app.command(context_settings=dict(allow_extra_args=True, ignore_unknown_options=True))
+def submit():
+    roll = random()
+    if roll < settings.FAKE_SBATCH_FAIL_PCT:
+        typer.echo(f"Fake sbatch failed ({roll=} <= {settings.FAKE_SBATCH_FAIL_PCT})")
+        raise typer.Exit(code=1)
+
+    fake_slurm_job_id = randint(settings.FAKE_SBATCH_MIN_JOB_ID, settings.FAKE_SBATCH_MAX_JOB_ID)
+    typer.echo(f"fake-sbatch: Submitted batch job {fake_slurm_job_id}")
+
+
+if __name__ == '__main__':
+    app()
diff --git a/fake-sbatch/poetry.lock b/fake-sbatch/poetry.lock
new file mode 100644
index 00000000..c84cf1e0
--- /dev/null
+++ b/fake-sbatch/poetry.lock
@@ -0,0 +1,475 @@
+[[package]]
+name = "atomicwrites"
+version = "1.4.0"
+description = "Atomic file writes."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "attrs"
+version = "21.4.0"
+description = "Classes Without Boilerplate"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.extras]
+dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
+docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
+tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
+tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"]
+
+[[package]]
+name = "black"
+version = "22.1.0"
+description = "The uncompromising code formatter."
+category = "dev"
+optional = false
+python-versions = ">=3.6.2"
+
+[package.dependencies]
+click = ">=8.0.0"
+mypy-extensions = ">=0.4.3"
+pathspec = ">=0.9.0"
+platformdirs = ">=2"
+tomli = ">=1.1.0"
+typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}
+
+[package.extras]
+colorama = ["colorama (>=0.4.3)"]
+d = ["aiohttp (>=3.7.4)"]
+jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
+uvloop = ["uvloop (>=0.15.2)"]
+
+[[package]]
+name = "click"
+version = "8.0.4"
+description = "Composable command line interface toolkit"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+
+[[package]]
+name = "colorama"
+version = "0.4.4"
+description = "Cross-platform colored terminal text."
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "flake8"
+version = "4.0.1"
+description = "the modular source code checker: pep8 pyflakes and co"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+mccabe = ">=0.6.0,<0.7.0"
+pycodestyle = ">=2.8.0,<2.9.0"
+pyflakes = ">=2.4.0,<2.5.0"
+
+[[package]]
+name = "iniconfig"
+version = "1.1.1"
+description = "iniconfig: brain-dead simple config-ini parsing"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "isort"
+version = "5.10.1"
+description = "A Python utility / library to sort Python imports."
+category = "dev"
+optional = false
+python-versions = ">=3.6.1,<4.0"
+
+[package.extras]
+pipfile_deprecated_finder = ["pipreqs", "requirementslib"]
+requirements_deprecated_finder = ["pipreqs", "pip-api"]
+colors = ["colorama (>=0.4.3,<0.5.0)"]
+plugins = ["setuptools"]
+
+[[package]]
+name = "mccabe"
+version = "0.6.1"
+description = "McCabe checker, plugin for flake8"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "mypy-extensions"
+version = "0.4.3"
+description = "Experimental type system extensions for programs checked with the mypy typechecker."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "packaging"
+version = "21.3"
+description = "Core utilities for Python packages"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
+
+[[package]]
+name = "pathspec"
+version = "0.9.0"
+description = "Utility library for gitignore style pattern matching of file paths."
+category = "dev"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+
+[[package]]
+name = "platformdirs"
+version = "2.5.1"
+description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.extras]
+docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"]
+test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"]
+
+[[package]]
+name = "pluggy"
+version = "1.0.0"
+description = "plugin and hook calling mechanisms for python"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
+[[package]]
+name = "py"
+version = "1.11.0"
+description = "library with cross-python path, ini-parsing, io, code, log facilities"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "pycodestyle"
+version = "2.8.0"
+description = "Python style guide checker"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "pydantic"
+version = "1.9.0"
+description = "Data validation and settings management using python 3.6 type hinting"
+category = "main"
+optional = false
+python-versions = ">=3.6.1"
+
+[package.dependencies]
+python-dotenv = {version = ">=0.10.4", optional = true, markers = "extra == \"dotenv\""}
+typing-extensions = ">=3.7.4.3"
+
+[package.extras]
+dotenv = ["python-dotenv (>=0.10.4)"]
+email = ["email-validator (>=1.0.3)"]
+
+[[package]]
+name = "pyflakes"
+version = "2.4.0"
+description = "passive checker of Python programs"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "pyparsing"
+version = "3.0.7"
+description = "Python parsing module"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+diagrams = ["jinja2", "railroad-diagrams"]
+
+[[package]]
+name = "pyproject-flake8"
+version = "0.0.1a2"
+description = "pyproject-flake8 (`pflake8`), a monkey patching wrapper to connect flake8 with pyproject.toml configuration"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+flake8 = "*"
+toml = "*"
+
+[[package]]
+name = "pytest"
+version = "7.0.1"
+description = "pytest: simple powerful testing with Python"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""}
+attrs = ">=19.2.0"
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=0.12,<2.0"
+py = ">=1.8.2"
+tomli = ">=1.0.0"
+
+[package.extras]
+testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
+
+[[package]]
+name = "pytest-mock"
+version = "3.7.0"
+description = "Thin-wrapper around the mock package for easier use with pytest"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+pytest = ">=5.0"
+
+[package.extras]
+dev = ["pre-commit", "tox", "pytest-asyncio"]
+
+[[package]]
+name = "python-dotenv"
+version = "0.19.2"
+description = "Read key-value pairs from a .env file and set them as environment variables"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[package.extras]
+cli = ["click (>=5.0)"]
+
+[[package]]
+name = "toml"
+version = "0.10.2"
+description = "Python Library for Tom's Obvious, Minimal Language"
+category = "dev"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
+name = "tomli"
+version = "2.0.1"
+description = "A lil' TOML parser"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[[package]]
+name = "typer"
+version = "0.4.0"
+description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+click = ">=7.1.1,<9.0.0"
+
+[package.extras]
+all = ["colorama (>=0.4.3,<0.5.0)", "shellingham (>=1.3.0,<2.0.0)"]
+dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)"]
+doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=5.4.0,<6.0.0)", "markdown-include (>=0.5.1,<0.6.0)"]
+test = ["shellingham (>=1.3.0,<2.0.0)", "pytest (>=4.4.0,<5.4.0)", "pytest-cov (>=2.10.0,<3.0.0)", "coverage (>=5.2,<6.0)", "pytest-xdist (>=1.32.0,<2.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "mypy (==0.910)", "black (>=19.10b0,<20.0b0)", "isort (>=5.0.6,<6.0.0)"]
+
+[[package]]
+name = "typing-extensions"
+version = "4.1.1"
+description = "Backported and Experimental Type Hints for Python 3.6+"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[metadata]
+lock-version = "1.1"
+python-versions = "^3.8"
+content-hash = "c61abf3c279a7b6c1674868f025b275a16e298c9c62901c0b12791c4dba5b8e9"
+
+[metadata.files]
+atomicwrites = [
+    {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
+    {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
+]
+attrs = [
+    {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"},
+    {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"},
+]
+black = [
+    {file = "black-22.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1297c63b9e1b96a3d0da2d85d11cd9bf8664251fd69ddac068b98dc4f34f73b6"},
+    {file = "black-22.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2ff96450d3ad9ea499fc4c60e425a1439c2120cbbc1ab959ff20f7c76ec7e866"},
+    {file = "black-22.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e21e1f1efa65a50e3960edd068b6ae6d64ad6235bd8bfea116a03b21836af71"},
+    {file = "black-22.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2f69158a7d120fd641d1fa9a921d898e20d52e44a74a6fbbcc570a62a6bc8ab"},
+    {file = "black-22.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:228b5ae2c8e3d6227e4bde5920d2fc66cc3400fde7bcc74f480cb07ef0b570d5"},
+    {file = "black-22.1.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b1a5ed73ab4c482208d20434f700d514f66ffe2840f63a6252ecc43a9bc77e8a"},
+    {file = "black-22.1.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35944b7100af4a985abfcaa860b06af15590deb1f392f06c8683b4381e8eeaf0"},
+    {file = "black-22.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7835fee5238fc0a0baf6c9268fb816b5f5cd9b8793423a75e8cd663c48d073ba"},
+    {file = "black-22.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dae63f2dbf82882fa3b2a3c49c32bffe144970a573cd68d247af6560fc493ae1"},
+    {file = "black-22.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fa1db02410b1924b6749c245ab38d30621564e658297484952f3d8a39fce7e8"},
+    {file = "black-22.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:c8226f50b8c34a14608b848dc23a46e5d08397d009446353dad45e04af0c8e28"},
+    {file = "black-22.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2d6f331c02f0f40aa51a22e479c8209d37fcd520c77721c034517d44eecf5912"},
+    {file = "black-22.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:742ce9af3086e5bd07e58c8feb09dbb2b047b7f566eb5f5bc63fd455814979f3"},
+    {file = "black-22.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fdb8754b453fb15fad3f72cd9cad3e16776f0964d67cf30ebcbf10327a3777a3"},
+    {file = "black-22.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5660feab44c2e3cb24b2419b998846cbb01c23c7fe645fee45087efa3da2d61"},
+    {file = "black-22.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:6f2f01381f91c1efb1451998bd65a129b3ed6f64f79663a55fe0e9b74a5f81fd"},
+    {file = "black-22.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:efbadd9b52c060a8fc3b9658744091cb33c31f830b3f074422ed27bad2b18e8f"},
+    {file = "black-22.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8871fcb4b447206904932b54b567923e5be802b9b19b744fdff092bd2f3118d0"},
+    {file = "black-22.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ccad888050f5393f0d6029deea2a33e5ae371fd182a697313bdbd835d3edaf9c"},
+    {file = "black-22.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07e5c049442d7ca1a2fc273c79d1aecbbf1bc858f62e8184abe1ad175c4f7cc2"},
+    {file = "black-22.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:373922fc66676133ddc3e754e4509196a8c392fec3f5ca4486673e685a421321"},
+    {file = "black-22.1.0-py3-none-any.whl", hash = "sha256:3524739d76b6b3ed1132422bf9d82123cd1705086723bc3e235ca39fd21c667d"},
+    {file = "black-22.1.0.tar.gz", hash = "sha256:a7c0192d35635f6fc1174be575cb7915e92e5dd629ee79fdaf0dcfa41a80afb5"},
+]
+click = [
+    {file = "click-8.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"},
+    {file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"},
+]
+colorama = [
+    {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
+    {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
+]
+flake8 = [
+    {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"},
+    {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"},
+]
+iniconfig = [
+    {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
+    {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
+]
+isort = [
+    {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"},
+    {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"},
+]
+mccabe = [
+    {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
+    {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
+]
+mypy-extensions = [
+    {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
+    {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
+]
+packaging = [
+    {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
+    {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
+]
+pathspec = [
+    {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
+    {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
+]
+platformdirs = [
+    {file = "platformdirs-2.5.1-py3-none-any.whl", hash = "sha256:bcae7cab893c2d310a711b70b24efb93334febe65f8de776ee320b517471e227"},
+    {file = "platformdirs-2.5.1.tar.gz", hash = "sha256:7535e70dfa32e84d4b34996ea99c5e432fa29a708d0f4e394bbcb2a8faa4f16d"},
+]
+pluggy = [
+    {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
+    {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
+]
+py = [
+    {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
+    {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
+]
+pycodestyle = [
+    {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"},
+    {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"},
+]
+pydantic = [
+    {file = "pydantic-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb23bcc093697cdea2708baae4f9ba0e972960a835af22560f6ae4e7e47d33f5"},
+    {file = "pydantic-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1d5278bd9f0eee04a44c712982343103bba63507480bfd2fc2790fa70cd64cf4"},
+    {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab624700dc145aa809e6f3ec93fb8e7d0f99d9023b713f6a953637429b437d37"},
+    {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8d7da6f1c1049eefb718d43d99ad73100c958a5367d30b9321b092771e96c25"},
+    {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3c3b035103bd4e2e4a28da9da7ef2fa47b00ee4a9cf4f1a735214c1bcd05e0f6"},
+    {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3011b975c973819883842c5ab925a4e4298dffccf7782c55ec3580ed17dc464c"},
+    {file = "pydantic-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:086254884d10d3ba16da0588604ffdc5aab3f7f09557b998373e885c690dd398"},
+    {file = "pydantic-1.9.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0fe476769acaa7fcddd17cadd172b156b53546ec3614a4d880e5d29ea5fbce65"},
+    {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8e9dcf1ac499679aceedac7e7ca6d8641f0193c591a2d090282aaf8e9445a46"},
+    {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1e4c28f30e767fd07f2ddc6f74f41f034d1dd6bc526cd59e63a82fe8bb9ef4c"},
+    {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:c86229333cabaaa8c51cf971496f10318c4734cf7b641f08af0a6fbf17ca3054"},
+    {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:c0727bda6e38144d464daec31dff936a82917f431d9c39c39c60a26567eae3ed"},
+    {file = "pydantic-1.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:dee5ef83a76ac31ab0c78c10bd7d5437bfdb6358c95b91f1ba7ff7b76f9996a1"},
+    {file = "pydantic-1.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d9c9bdb3af48e242838f9f6e6127de9be7063aad17b32215ccc36a09c5cf1070"},
+    {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ee7e3209db1e468341ef41fe263eb655f67f5c5a76c924044314e139a1103a2"},
+    {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b6037175234850ffd094ca77bf60fb54b08b5b22bc85865331dd3bda7a02fa1"},
+    {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b2571db88c636d862b35090ccf92bf24004393f85c8870a37f42d9f23d13e032"},
+    {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8b5ac0f1c83d31b324e57a273da59197c83d1bb18171e512908fe5dc7278a1d6"},
+    {file = "pydantic-1.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:bbbc94d0c94dd80b3340fc4f04fd4d701f4b038ebad72c39693c794fd3bc2d9d"},
+    {file = "pydantic-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e0896200b6a40197405af18828da49f067c2fa1f821491bc8f5bde241ef3f7d7"},
+    {file = "pydantic-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bdfdadb5994b44bd5579cfa7c9b0e1b0e540c952d56f627eb227851cda9db77"},
+    {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:574936363cd4b9eed8acdd6b80d0143162f2eb654d96cb3a8ee91d3e64bf4cf9"},
+    {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c556695b699f648c58373b542534308922c46a1cda06ea47bc9ca45ef5b39ae6"},
+    {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f947352c3434e8b937e3aa8f96f47bdfe6d92779e44bb3f41e4c213ba6a32145"},
+    {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5e48ef4a8b8c066c4a31409d91d7ca372a774d0212da2787c0d32f8045b1e034"},
+    {file = "pydantic-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:96f240bce182ca7fe045c76bcebfa0b0534a1bf402ed05914a6f1dadff91877f"},
+    {file = "pydantic-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:815ddebb2792efd4bba5488bc8fde09c29e8ca3227d27cf1c6990fc830fd292b"},
+    {file = "pydantic-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6c5b77947b9e85a54848343928b597b4f74fc364b70926b3c4441ff52620640c"},
+    {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c68c3bc88dbda2a6805e9a142ce84782d3930f8fdd9655430d8576315ad97ce"},
+    {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a79330f8571faf71bf93667d3ee054609816f10a259a109a0738dac983b23c3"},
+    {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f5a64b64ddf4c99fe201ac2724daada8595ada0d102ab96d019c1555c2d6441d"},
+    {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a733965f1a2b4090a5238d40d983dcd78f3ecea221c7af1497b845a9709c1721"},
+    {file = "pydantic-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cc6a4cb8a118ffec2ca5fcb47afbacb4f16d0ab8b7350ddea5e8ef7bcc53a16"},
+    {file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"},
+    {file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"},
+]
+pyflakes = [
+    {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"},
+    {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"},
+]
+pyparsing = [
+    {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"},
+    {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"},
+]
+pyproject-flake8 = [
+    {file = "pyproject-flake8-0.0.1a2.tar.gz", hash = "sha256:bdeca37f78ecd34bd64a49d3657d53d099f5445831071a31c46e1fe20cd61461"},
+    {file = "pyproject_flake8-0.0.1a2-py2.py3-none-any.whl", hash = "sha256:e61ed1dc088e9f9f8a7170967ac4ec135acfef3a59ab9738c7b58cc11f294a7e"},
+]
+pytest = [
+    {file = "pytest-7.0.1-py3-none-any.whl", hash = "sha256:9ce3ff477af913ecf6321fe337b93a2c0dcf2a0a1439c43f5452112c1e4280db"},
+    {file = "pytest-7.0.1.tar.gz", hash = "sha256:e30905a0c131d3d94b89624a1cc5afec3e0ba2fbdb151867d8e0ebd49850f171"},
+]
+pytest-mock = [
+    {file = "pytest-mock-3.7.0.tar.gz", hash = "sha256:5112bd92cc9f186ee96e1a92efc84969ea494939c3aead39c50f421c4cc69534"},
+    {file = "pytest_mock-3.7.0-py3-none-any.whl", hash = "sha256:6cff27cec936bf81dc5ee87f07132b807bcda51106b5ec4b90a04331cba76231"},
+]
+python-dotenv = [
+    {file = "python-dotenv-0.19.2.tar.gz", hash = "sha256:a5de49a31e953b45ff2d2fd434bbc2670e8db5273606c1e737cc6b93eff3655f"},
+    {file = "python_dotenv-0.19.2-py2.py3-none-any.whl", hash = "sha256:32b2bdc1873fd3a3c346da1c6db83d0053c3c62f28f1f38516070c4c8971b1d3"},
+]
+toml = [
+    {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
+    {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
+]
+tomli = [
+    {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
+    {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
+]
+typer = [
+    {file = "typer-0.4.0-py3-none-any.whl", hash = "sha256:d81169725140423d072df464cad1ff25ee154ef381aaf5b8225352ea187ca338"},
+    {file = "typer-0.4.0.tar.gz", hash = "sha256:63c3aeab0549750ffe40da79a1b524f60e08a2cbc3126c520ebf2eeaf507f5dd"},
+]
+typing-extensions = [
+    {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"},
+    {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"},
+]
diff --git a/fake-sbatch/pyproject.toml b/fake-sbatch/pyproject.toml
new file mode 100644
index 00000000..52c4e7d4
--- /dev/null
+++ b/fake-sbatch/pyproject.toml
@@ -0,0 +1,39 @@
+[tool.poetry]
+name = "fake-sbatch"
+version = "0.1.0"
+description = "An executable that pretends to be sbatch for testing purposes."
+authors = ["Omnivector Solutions <info@omnivector.solutions>"]
+license = "MIT"
+
+[tool.poetry.dependencies]
+python = "^3.8"
+pydantic = {extras = ["dotenv"], version = "^1.9.0"}
+typer = "^0.4.0"
+
+[tool.poetry.dev-dependencies]
+black = "^22.1.0"
+isort = "^5.10.1"
+pyproject-flake8 = "^0.0.1-alpha.2"
+pytest-mock = "^3.7.0"
+pytest = "^7.0.1"
+
+[tool.poetry.scripts]
+fake-sbatch = "fake_sbatch.main:app"
+
+[tool.black]
+line-length = 120
+
+[tool.isort]
+line_length = 120
+lines_after_imports = "2"
+profile = "black"
+multi_line_output = 3
+include_trailing_comma = true
+
+[tool.flake8]
+max-line-length = 120
+max-complexity = 40
+
+[build-system]
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"
diff --git a/jobbergate-api/CHANGELOG.rst b/jobbergate-api/CHANGELOG.rst
index 1495e06b..6d980e4a 100644
--- a/jobbergate-api/CHANGELOG.rst
+++ b/jobbergate-api/CHANGELOG.rst
@@ -6,6 +6,9 @@ This file keeps track of all notable changes to jobbergate-api
 
 Unreleased
 ----------
+- Removed owner_email from create schemas (this comes from token now)
+- Changed status codes for update routes to 200
+- Added ``migrate`` and ``upgrade`` commands to dev-tools
 
 2.2.9 -- 2022-02-16
 -------------------
diff --git a/jobbergate-api/dev_tools/db.py b/jobbergate-api/dev_tools/db.py
index 4999a49f..bff287e7 100644
--- a/jobbergate-api/dev_tools/db.py
+++ b/jobbergate-api/dev_tools/db.py
@@ -16,7 +16,7 @@
 
 
 @app.command()
-def login(test: bool = typer.Option(False, help="Log into the test database instead")):
+def login(test: bool = typer.Option(False, help="Log into the test database.")):
     """
     Log into a local database.
     """
@@ -26,7 +26,7 @@ def login(test: bool = typer.Option(False, help="Log into the test database inst
 
 
 @app.command()
-def start(test: bool = typer.Option(False, help="Start a test database instead")):
+def start(test: bool = typer.Option(False, help="Start a test database.")):
     """
     Start a local postgres database for local development.
     """
@@ -64,3 +64,38 @@ def start_all():
     """
     start()
     start(test=True)
+
+
+@app.command()
+def migrate(
+    message: str = typer.Option("Unlabeled migration", help="The message to attach to the migration."),
+    blank: bool = typer.Option(False, help="Produce a blank migration"),
+):
+    """
+    Create alembic migrations for a local database.
+    """
+    commands = [
+        "alembic",
+        "--config=alembic/alembic.ini",
+        "revision",
+        f"--message={message}",
+    ]
+    if not blank:
+        commands.append("--autogenerate")
+
+    subprocess.run(commands)
+
+
+@app.command()
+def upgrade(target: str = typer.Option("head", help="The migration to which the db should be upgraded."),):
+    """
+    Apply alembic migrations to a local database.
+    """
+    commands = [
+        "alembic",
+        "--config=alembic/alembic.ini",
+        "upgrade",
+        target,
+    ]
+
+    subprocess.run(commands)
diff --git a/jobbergate-api/jobbergate_api/apps/applications/routers.py b/jobbergate-api/jobbergate_api/apps/applications/routers.py
index 57503fac..4c4e2bc7 100644
--- a/jobbergate-api/jobbergate_api/apps/applications/routers.py
+++ b/jobbergate-api/jobbergate_api/apps/applications/routers.py
@@ -40,12 +40,13 @@ async def applications_create(
     Create new applications using an authenticated user token.
     """
     identity_claims = IdentityClaims.from_token_payload(token_payload)
-    application.application_owner_email = identity_claims.user_email
+    create_dict = dict(
+        **application.dict(exclude_unset=True), application_owner_email=identity_claims.user_email,
+    )
 
     try:
         insert_query = applications_table.insert().returning(applications_table)
-        values = application.dict()
-        application_data = await database.fetch_one(query=insert_query, values=values)
+        application_data = await database.fetch_one(query=insert_query, values=create_dict)
 
     except INTEGRITY_CHECK_EXCEPTIONS as e:
         raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
@@ -233,7 +234,7 @@ async def applications_get_by_id(application_id: int = Query(...)):
 
 @router.put(
     "/applications/{application_id}",
-    status_code=status.HTTP_201_CREATED,
+    status_code=status.HTTP_200_OK,
     description="Endpoint to update an application given the id",
     response_model=ApplicationResponse,
     dependencies=[Depends(guard.lockdown(Permissions.APPLICATIONS_EDIT))],
diff --git a/jobbergate-api/jobbergate_api/apps/applications/schemas.py b/jobbergate-api/jobbergate_api/apps/applications/schemas.py
index 3c7c54e0..04424eda 100644
--- a/jobbergate-api/jobbergate_api/apps/applications/schemas.py
+++ b/jobbergate-api/jobbergate_api/apps/applications/schemas.py
@@ -91,7 +91,6 @@ class ApplicationCreateRequest(BaseModel):
     application_name: str
     application_identifier: Optional[str]
     application_description: Optional[str] = None
-    application_owner_email: Optional[str]
     application_file: str
     application_config: str
 
diff --git a/jobbergate-api/jobbergate_api/apps/job_scripts/routers.py b/jobbergate-api/jobbergate_api/apps/job_scripts/routers.py
index 58dec689..cf1fdbe6 100644
--- a/jobbergate-api/jobbergate_api/apps/job_scripts/routers.py
+++ b/jobbergate-api/jobbergate_api/apps/job_scripts/routers.py
@@ -85,14 +85,13 @@ def build_job_script_data_as_string(s3_application_tar, param_dict):
     """
     Return the job_script_data_as string from the S3 application and the templates.
     """
-    try:
-        support_files_output = param_dict["jobbergate_config"]["supporting_files_output_name"]
-    except KeyError:
-        support_files_output = {}
-    try:
-        supporting_files = param_dict["jobbergate_config"]["supporting_files"]
-    except KeyError:
-        supporting_files = []
+    support_files_output = param_dict["jobbergate_config"].get("supporting_files_output_name")
+    if support_files_output is None:
+        support_files_output = dict()
+
+    supporting_files = param_dict["jobbergate_config"].get("supporting_files")
+    if supporting_files is None:
+        supporting_files = list()
 
     default_template = [
         default_template := param_dict["jobbergate_config"].get("default_template"),
@@ -112,9 +111,12 @@ def build_job_script_data_as_string(s3_application_tar, param_dict):
 
     # Use tempfile to generate .tar in memory - NOT write to disk
     param_dict_flat = {}
-    for key in param_dict.keys():
-        for nest_key, nest_value in param_dict[key].items():
-            param_dict_flat[nest_key] = nest_value
+    for (key, value) in param_dict.items():
+        if isinstance(value, dict):
+            for nest_key, nest_value in value.items():
+                param_dict_flat[nest_key] = nest_value
+        else:
+            param_dict_flat[key] = value
     with tempfile.NamedTemporaryFile("wb", suffix=".tar.gz", delete=False) as f:
         with tarfile.open(fileobj=f, mode="w:gz") as rendered_tar:
             for member in s3_application_tar.getmembers():
@@ -161,13 +163,14 @@ async def job_script_create(
     s3_application_tar = get_s3_object_as_tarfile(application.id)
 
     identity_claims = IdentityClaims.from_token_payload(token_payload)
-    job_script.job_script_owner_email = identity_claims.user_email
 
-    create_dict = job_script.dict(exclude_unset=True)
+    create_dict = dict(
+        **{k: v for (k, v) in job_script.dict(exclude_unset=True).items() if k != "param_dict"},
+        job_script_owner_email=identity_claims.user_email,
+    )
 
-    param_dict = json.loads(create_dict.pop("param_dict", "{}"))
     logger.debug("Rendering job_script data as string")
-    job_script_data_as_string = build_job_script_data_as_string(s3_application_tar, param_dict)
+    job_script_data_as_string = build_job_script_data_as_string(s3_application_tar, job_script.param_dict)
 
     sbatch_params = create_dict.pop("sbatch_params", [])
     create_dict["job_script_data_as_string"] = inject_sbatch_params(job_script_data_as_string, sbatch_params)
@@ -260,7 +263,7 @@ async def job_script_delete(job_script_id: int = Query(..., description="id of t
 
 @router.put(
     "/job-scripts/{job_script_id}",
-    status_code=status.HTTP_201_CREATED,
+    status_code=status.HTTP_200_OK,
     description="Endpoint to update a job_script given the id",
     response_model=JobScriptResponse,
     dependencies=[Depends(guard.lockdown(Permissions.JOB_SCRIPTS_EDIT))],
diff --git a/jobbergate-api/jobbergate_api/apps/job_scripts/schemas.py b/jobbergate-api/jobbergate_api/apps/job_scripts/schemas.py
index bb214acb..bc47687f 100644
--- a/jobbergate-api/jobbergate_api/apps/job_scripts/schemas.py
+++ b/jobbergate-api/jobbergate_api/apps/job_scripts/schemas.py
@@ -2,7 +2,7 @@
 JobScript resource schema.
 """
 from datetime import datetime
-from typing import List, Optional
+from typing import Any, Dict, List, Optional
 
 from pydantic import BaseModel
 
@@ -43,7 +43,7 @@
     ),
     param_dict=MetaField(
         description="Parameters to use when rendering the job_script jinja2 template",
-        example='{"param1": 7, "param2": 13}',
+        example={"param1": 7, "param2": 13},
     ),
 )
 
@@ -55,10 +55,9 @@ class JobScriptCreateRequest(BaseModel):
 
     job_script_name: str
     job_script_description: Optional[str]
-    job_script_owner_email: str
     application_id: int
     sbatch_params: Optional[List[str]]
-    param_dict: Optional[str]
+    param_dict: Optional[Dict[str, Any]]
 
     class Config:
         schema_extra = job_script_meta_mapper
diff --git a/jobbergate-api/jobbergate_api/apps/job_submissions/routers.py b/jobbergate-api/jobbergate_api/apps/job_submissions/routers.py
index 474f602a..279f73e0 100644
--- a/jobbergate-api/jobbergate_api/apps/job_submissions/routers.py
+++ b/jobbergate-api/jobbergate_api/apps/job_submissions/routers.py
@@ -41,7 +41,9 @@ async def job_submission_create(
     Make a post request to this endpoint with the required values to create a new job submission.
     """
     identity_claims = IdentityClaims.from_token_payload(token_payload)
-    job_submission.job_submission_owner_email = identity_claims.user_email
+    create_dict = dict(
+        **job_submission.dict(exclude_unset=True), job_submission_owner_email=identity_claims.user_email,
+    )
 
     select_query = job_scripts_table.select().where(job_scripts_table.c.id == job_submission.job_script_id)
     raw_job_script = await database.fetch_one(select_query)
@@ -55,7 +57,7 @@ async def job_submission_create(
     async with database.transaction():
         try:
             insert_query = job_submissions_table.insert().returning(job_submissions_table)
-            job_submission_data = await database.fetch_one(query=insert_query, values=job_submission.dict())
+            job_submission_data = await database.fetch_one(query=insert_query, values=create_dict)
 
         except INTEGRITY_CHECK_EXCEPTIONS as e:
             raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e))
@@ -153,7 +155,7 @@ async def job_submission_delete(
 
 @router.put(
     "/job-submissions/{job_submission_id}",
-    status_code=status.HTTP_201_CREATED,
+    status_code=status.HTTP_200_OK,
     description="Endpoint to update a job_submission given the id",
     response_model=JobSubmissionResponse,
     dependencies=[Depends(guard.lockdown(Permissions.JOB_SUBMISSIONS_EDIT))],
diff --git a/jobbergate-api/jobbergate_api/apps/job_submissions/schemas.py b/jobbergate-api/jobbergate_api/apps/job_submissions/schemas.py
index 743726b2..88e04fe4 100644
--- a/jobbergate-api/jobbergate_api/apps/job_submissions/schemas.py
+++ b/jobbergate-api/jobbergate_api/apps/job_submissions/schemas.py
@@ -42,7 +42,6 @@ class JobSubmissionCreateRequest(BaseModel):
 
     job_submission_name: str
     job_submission_description: Optional[str]
-    job_submission_owner_email: Optional[str]
     job_script_id: int
     slurm_job_id: Optional[int]
 
diff --git a/jobbergate-api/poetry.lock b/jobbergate-api/poetry.lock
index c27b2783..a84c904d 100644
--- a/jobbergate-api/poetry.lock
+++ b/jobbergate-api/poetry.lock
@@ -136,11 +136,11 @@ tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>
 
 [[package]]
 name = "auto-name-enum"
-version = "1.0.2"
+version = "1.1.0"
 description = "String-based Enum class that automatically assigns values"
 category = "main"
 optional = false
-python-versions = ">=3.6,<4.0"
+python-versions = ">=3.7,<4.0"
 
 [[package]]
 name = "backcall"
@@ -172,14 +172,14 @@ d = ["aiohttp (>=3.3.2)", "aiohttp-cors"]
 
 [[package]]
 name = "boto3"
-version = "1.21.7"
+version = "1.21.18"
 description = "The AWS SDK for Python"
 category = "main"
 optional = false
 python-versions = ">= 3.6"
 
 [package.dependencies]
-botocore = ">=1.24.7,<1.25.0"
+botocore = ">=1.24.18,<1.25.0"
 jmespath = ">=0.7.1,<1.0.0"
 s3transfer = ">=0.5.0,<0.6.0"
 
@@ -188,7 +188,7 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
 
 [[package]]
 name = "botocore"
-version = "1.24.7"
+version = "1.24.18"
 description = "Low-level, data-driven core of boto 3."
 category = "main"
 optional = false
@@ -353,7 +353,7 @@ python-versions = "*"
 
 [[package]]
 name = "dnspython"
-version = "2.2.0"
+version = "2.2.1"
 description = "DNS toolkit"
 category = "main"
 optional = false
@@ -468,11 +468,11 @@ pydocstyle = ">=2.1"
 
 [[package]]
 name = "freezegun"
-version = "1.1.0"
+version = "1.2.0"
 description = "Let your Python tests travel through time"
 category = "dev"
 optional = false
-python-versions = ">=3.5"
+python-versions = ">=3.6"
 
 [package.dependencies]
 python-dateutil = ">=2.7"
@@ -551,7 +551,7 @@ python-versions = ">=3.5"
 
 [[package]]
 name = "importlib-metadata"
-version = "4.11.1"
+version = "4.11.3"
 description = "Read metadata from Python packages"
 category = "main"
 optional = false
@@ -561,7 +561,7 @@ python-versions = ">=3.7"
 zipp = ">=0.5"
 
 [package.extras]
-docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
+docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"]
 perf = ["ipython"]
 testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"]
 
@@ -688,11 +688,11 @@ dev = ["colorama (>=0.3.4)", "docutils (==0.16)", "flake8 (>=3.7.7)", "tox (>=3.
 
 [[package]]
 name = "mako"
-version = "1.1.6"
-description = "A super-fast templating language that borrows the  best ideas from the existing templating languages."
+version = "1.2.0"
+description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
 category = "main"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=3.7"
 
 [package.dependencies]
 MarkupSafe = ">=0.9.2"
@@ -700,6 +700,7 @@ MarkupSafe = ">=0.9.2"
 [package.extras]
 babel = ["babel"]
 lingua = ["lingua"]
+testing = ["pytest"]
 
 [[package]]
 name = "markupsafe"
@@ -871,7 +872,7 @@ sshtunnel = ["sshtunnel (>=0.4.0)"]
 
 [[package]]
 name = "pgspecial"
-version = "1.13.0"
+version = "1.13.1"
 description = "Meta-commands handler for Postgres Database."
 category = "dev"
 optional = false
@@ -1257,11 +1258,11 @@ python-versions = ">=3.6"
 
 [[package]]
 name = "regex"
-version = "2022.1.18"
+version = "2022.3.2"
 description = "Alternative regular expression module, to replace re."
 category = "dev"
 optional = false
-python-versions = "*"
+python-versions = ">=3.6"
 
 [[package]]
 name = "requests"
@@ -1333,7 +1334,7 @@ crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"]
 
 [[package]]
 name = "sentry-sdk"
-version = "1.5.6"
+version = "1.5.7"
 description = "Python client for Sentry (https://sentry.io)"
 category = "main"
 optional = false
@@ -1382,7 +1383,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
 
 [[package]]
 name = "snick"
-version = "1.1.0"
+version = "1.2.0"
 description = ""
 category = "main"
 optional = false
@@ -1409,7 +1410,7 @@ python-versions = "*"
 
 [[package]]
 name = "sqlalchemy"
-version = "1.4.31"
+version = "1.4.32"
 description = "Database Abstraction Library"
 category = "main"
 optional = false
@@ -1567,7 +1568,7 @@ standard = ["websockets (>=9.1)", "httptools (>=0.2.0,<0.3.0)", "watchgod (>=0.6
 
 [[package]]
 name = "virtualenv"
-version = "20.13.2"
+version = "20.13.3"
 description = "Virtual Python Environment builder"
 category = "dev"
 optional = false
@@ -1593,7 +1594,7 @@ python-versions = "*"
 
 [[package]]
 name = "websocket-client"
-version = "1.2.3"
+version = "1.3.1"
 description = "WebSocket client for Python with low level API options"
 category = "main"
 optional = false
@@ -1703,8 +1704,8 @@ attrs = [
     {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"},
 ]
 auto-name-enum = [
-    {file = "auto-name-enum-1.0.2.tar.gz", hash = "sha256:020e7bde8f36a0f0f7ea60a5192e3e3234244620c7d67aa751702f3c4520ecec"},
-    {file = "auto_name_enum-1.0.2-py3-none-any.whl", hash = "sha256:559057cae30801490fe782d822c42f95326a5672bab0b77fb3ab8dc95c18bd68"},
+    {file = "auto-name-enum-1.1.0.tar.gz", hash = "sha256:36d846621833b6710d97cf99e0d3b6df7417315d4603b2105dbe06885513ac84"},
+    {file = "auto_name_enum-1.1.0-py3-none-any.whl", hash = "sha256:cacb0a1f7da6ae68af72c7d7cc9c2b5e8f8f44dbb388da5bedff1599c1b6ee83"},
 ]
 backcall = [
     {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"},
@@ -1715,12 +1716,12 @@ black = [
     {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"},
 ]
 boto3 = [
-    {file = "boto3-1.21.7-py3-none-any.whl", hash = "sha256:af92931f65e33e7450c3389c6cc6ab6b3e2f619697ea5566aacc0f16f3b21f61"},
-    {file = "boto3-1.21.7.tar.gz", hash = "sha256:8f59383fe578ac9107466a464d7198933e5332d85a4790f2e01cf24a4a7f635b"},
+    {file = "boto3-1.21.18-py3-none-any.whl", hash = "sha256:d857feb6af9932e1ee3a748060a2cd9fd6043dbbccf66976eda54586597efdc0"},
+    {file = "boto3-1.21.18.tar.gz", hash = "sha256:8d6f3c548f0ee03d742f404c96515e7579fc6968135aaa50dd855a046698ff79"},
 ]
 botocore = [
-    {file = "botocore-1.24.7-py3-none-any.whl", hash = "sha256:a34118bfadc02903ab404148822fe5a6de7a3bb58943f1a6a19cc8b0446d2a50"},
-    {file = "botocore-1.24.7.tar.gz", hash = "sha256:5d1a2a2ac72461bbaa79317b3e4cb72c7ebb315aef184d90f72ec1f6dba0ca6c"},
+    {file = "botocore-1.24.18-py3-none-any.whl", hash = "sha256:7ea8ef1ff7c4882ab59b337662f90ddf5ea860e95e7e209dca593a34ea585b1b"},
+    {file = "botocore-1.24.18.tar.gz", hash = "sha256:d2da7ccbc5ddd61fe3cd45fcbd3de380d9e3a15bfa8fbfd2d9259a93dcc60c56"},
 ]
 certifi = [
     {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"},
@@ -1890,8 +1891,8 @@ distlib = [
     {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"},
 ]
 dnspython = [
-    {file = "dnspython-2.2.0-py3-none-any.whl", hash = "sha256:081649da27ced5e75709a1ee542136eaba9842a0fe4c03da4fb0a3d3ed1f3c44"},
-    {file = "dnspython-2.2.0.tar.gz", hash = "sha256:e79351e032d0b606b98d38a4b0e6e2275b31a5b85c873e587cc11b73aca026d6"},
+    {file = "dnspython-2.2.1-py3-none-any.whl", hash = "sha256:a851e51367fb93e9e1361732c1d60dab63eff98712e503ea7d92e6eccb109b4f"},
+    {file = "dnspython-2.2.1.tar.gz", hash = "sha256:0f7569a4a6ff151958b64304071d370daa3243d15941a7beedf0c9fe5105603e"},
 ]
 docker = [
     {file = "docker-5.0.3-py2.py3-none-any.whl", hash = "sha256:7a79bb439e3df59d0a72621775d600bc8bc8b422d285824cb37103eab91d1ce0"},
@@ -1922,8 +1923,8 @@ flake8-docstrings = [
     {file = "flake8_docstrings-1.6.0-py2.py3-none-any.whl", hash = "sha256:99cac583d6c7e32dd28bbfbef120a7c0d1b6dde4adb5a9fd441c4227a6534bde"},
 ]
 freezegun = [
-    {file = "freezegun-1.1.0-py2.py3-none-any.whl", hash = "sha256:2ae695f7eb96c62529f03a038461afe3c692db3465e215355e1bb4b0ab408712"},
-    {file = "freezegun-1.1.0.tar.gz", hash = "sha256:177f9dd59861d871e27a484c3332f35a6e3f5d14626f2bf91be37891f18927f3"},
+    {file = "freezegun-1.2.0-py3-none-any.whl", hash = "sha256:93e90676da387a241a7c2ae7ae68af7b95bfe80e952d0bbf3e088342de90a38d"},
+    {file = "freezegun-1.2.0.tar.gz", hash = "sha256:e19563d0b05dcab6dc8ad21c05269004a96998ce2b6b1b8131f1297ae20dd038"},
 ]
 greenlet = [
     {file = "greenlet-1.1.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6"},
@@ -1998,8 +1999,8 @@ idna = [
     {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
 ]
 importlib-metadata = [
-    {file = "importlib_metadata-4.11.1-py3-none-any.whl", hash = "sha256:e0bc84ff355328a4adfc5240c4f211e0ab386f80aa640d1b11f0618a1d282094"},
-    {file = "importlib_metadata-4.11.1.tar.gz", hash = "sha256:175f4ee440a0317f6e8d81b7f8d4869f93316170a65ad2b007d2929186c8052c"},
+    {file = "importlib_metadata-4.11.3-py3-none-any.whl", hash = "sha256:1208431ca90a8cca1a6b8af391bb53c1a2db74e5d1cef6ddced95d4b2062edc6"},
+    {file = "importlib_metadata-4.11.3.tar.gz", hash = "sha256:ea4c597ebf37142f827b8f39299579e31685c31d3a438b59f469406afd0f2539"},
 ]
 importlib-resources = [
     {file = "importlib_resources-5.4.0-py3-none-any.whl", hash = "sha256:33a95faed5fc19b4bc16b29a6eeae248a3fe69dd55d4d229d2b480e23eeaad45"},
@@ -2034,8 +2035,8 @@ loguru = [
     {file = "loguru-0.6.0.tar.gz", hash = "sha256:066bd06758d0a513e9836fd9c6b5a75bfb3fd36841f4b996bc60b547a309d41c"},
 ]
 mako = [
-    {file = "Mako-1.1.6-py2.py3-none-any.whl", hash = "sha256:afaf8e515d075b22fad7d7b8b30e4a1c90624ff2f3733a06ec125f5a5f043a57"},
-    {file = "Mako-1.1.6.tar.gz", hash = "sha256:4e9e345a41924a954251b95b4b28e14a301145b544901332e658907a7464b6b2"},
+    {file = "Mako-1.2.0-py3-none-any.whl", hash = "sha256:23aab11fdbbb0f1051b93793a58323ff937e98e34aece1c4219675122e57e4ba"},
+    {file = "Mako-1.2.0.tar.gz", hash = "sha256:9a7c7e922b87db3686210cf49d5d767033a41d4010b284e747682c92bddd8b39"},
 ]
 markupsafe = [
     {file = "MarkupSafe-2.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3028252424c72b2602a323f70fbf50aa80a5d3aa616ea6add4ba21ae9cc9da4c"},
@@ -2233,8 +2234,8 @@ pgcli = [
     {file = "pgcli-3.4.0.tar.gz", hash = "sha256:d566c6eda99aad1a27680bdfd74657e25bc0586d04eaf0b1626bb58bde75cfb6"},
 ]
 pgspecial = [
-    {file = "pgspecial-1.13.0-py3-none-any.whl", hash = "sha256:646d1409fe5dcd25d92707a5965d8c1ad7a20289a323316267fb9a5e4ef7d126"},
-    {file = "pgspecial-1.13.0.tar.gz", hash = "sha256:3847e205b19469f16ded05bda24b4758056d67ade4075a5ded4ce6628a9bad01"},
+    {file = "pgspecial-1.13.1-py3-none-any.whl", hash = "sha256:5716c470f358cf74914b8155bf48d6a8deb8f70aa6eccf8bddaacc44dfee46e4"},
+    {file = "pgspecial-1.13.1.tar.gz", hash = "sha256:d5dab96690908275916dc2c622efae217f142e08165fa366949d41cbc5658701"},
 ]
 pickleshare = [
     {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"},
@@ -2463,80 +2464,80 @@ pyyaml = [
     {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
 ]
 regex = [
-    {file = "regex-2022.1.18-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:34316bf693b1d2d29c087ee7e4bb10cdfa39da5f9c50fa15b07489b4ab93a1b5"},
-    {file = "regex-2022.1.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a0b9f6a1a15d494b35f25ed07abda03209fa76c33564c09c9e81d34f4b919d7"},
-    {file = "regex-2022.1.18-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f99112aed4fb7cee00c7f77e8b964a9b10f69488cdff626ffd797d02e2e4484f"},
-    {file = "regex-2022.1.18-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a2bf98ac92f58777c0fafc772bf0493e67fcf677302e0c0a630ee517a43b949"},
-    {file = "regex-2022.1.18-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8618d9213a863c468a865e9d2ec50221015f7abf52221bc927152ef26c484b4c"},
-    {file = "regex-2022.1.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b52cc45e71657bc4743a5606d9023459de929b2a198d545868e11898ba1c3f59"},
-    {file = "regex-2022.1.18-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e12949e5071c20ec49ef00c75121ed2b076972132fc1913ddf5f76cae8d10b4"},
-    {file = "regex-2022.1.18-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b02e3e72665cd02afafb933453b0c9f6c59ff6e3708bd28d0d8580450e7e88af"},
-    {file = "regex-2022.1.18-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:abfcb0ef78df0ee9df4ea81f03beea41849340ce33a4c4bd4dbb99e23ec781b6"},
-    {file = "regex-2022.1.18-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6213713ac743b190ecbf3f316d6e41d099e774812d470422b3a0f137ea635832"},
-    {file = "regex-2022.1.18-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:61ebbcd208d78658b09e19c78920f1ad38936a0aa0f9c459c46c197d11c580a0"},
-    {file = "regex-2022.1.18-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:b013f759cd69cb0a62de954d6d2096d648bc210034b79b1881406b07ed0a83f9"},
-    {file = "regex-2022.1.18-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9187500d83fd0cef4669385cbb0961e227a41c0c9bc39219044e35810793edf7"},
-    {file = "regex-2022.1.18-cp310-cp310-win32.whl", hash = "sha256:94c623c331a48a5ccc7d25271399aff29729fa202c737ae3b4b28b89d2b0976d"},
-    {file = "regex-2022.1.18-cp310-cp310-win_amd64.whl", hash = "sha256:1a171eaac36a08964d023eeff740b18a415f79aeb212169080c170ec42dd5184"},
-    {file = "regex-2022.1.18-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:49810f907dfe6de8da5da7d2b238d343e6add62f01a15d03e2195afc180059ed"},
-    {file = "regex-2022.1.18-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d2f5c3f7057530afd7b739ed42eb04f1011203bc5e4663e1e1d01bb50f813e3"},
-    {file = "regex-2022.1.18-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:85ffd6b1cb0dfb037ede50ff3bef80d9bf7fa60515d192403af6745524524f3b"},
-    {file = "regex-2022.1.18-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba37f11e1d020969e8a779c06b4af866ffb6b854d7229db63c5fdddfceaa917f"},
-    {file = "regex-2022.1.18-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:637e27ea1ebe4a561db75a880ac659ff439dec7f55588212e71700bb1ddd5af9"},
-    {file = "regex-2022.1.18-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:37978254d9d00cda01acc1997513f786b6b971e57b778fbe7c20e30ae81a97f3"},
-    {file = "regex-2022.1.18-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e54a1eb9fd38f2779e973d2f8958fd575b532fe26013405d1afb9ee2374e7ab8"},
-    {file = "regex-2022.1.18-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:768632fd8172ae03852e3245f11c8a425d95f65ff444ce46b3e673ae5b057b74"},
-    {file = "regex-2022.1.18-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:de2923886b5d3214be951bc2ce3f6b8ac0d6dfd4a0d0e2a4d2e5523d8046fdfb"},
-    {file = "regex-2022.1.18-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:1333b3ce73269f986b1fa4d5d395643810074dc2de5b9d262eb258daf37dc98f"},
-    {file = "regex-2022.1.18-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:d19a34f8a3429bd536996ad53597b805c10352a8561d8382e05830df389d2b43"},
-    {file = "regex-2022.1.18-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d2f355a951f60f0843f2368b39970e4667517e54e86b1508e76f92b44811a8a"},
-    {file = "regex-2022.1.18-cp36-cp36m-win32.whl", hash = "sha256:2245441445099411b528379dee83e56eadf449db924648e5feb9b747473f42e3"},
-    {file = "regex-2022.1.18-cp36-cp36m-win_amd64.whl", hash = "sha256:25716aa70a0d153cd844fe861d4f3315a6ccafce22b39d8aadbf7fcadff2b633"},
-    {file = "regex-2022.1.18-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7e070d3aef50ac3856f2ef5ec7214798453da878bb5e5a16c16a61edf1817cc3"},
-    {file = "regex-2022.1.18-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22709d701e7037e64dae2a04855021b62efd64a66c3ceed99dfd684bfef09e38"},
-    {file = "regex-2022.1.18-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9099bf89078675c372339011ccfc9ec310310bf6c292b413c013eb90ffdcafc"},
-    {file = "regex-2022.1.18-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04611cc0f627fc4a50bc4a9a2e6178a974c6a6a4aa9c1cca921635d2c47b9c87"},
-    {file = "regex-2022.1.18-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:552a39987ac6655dad4bf6f17dd2b55c7b0c6e949d933b8846d2e312ee80005a"},
-    {file = "regex-2022.1.18-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e031899cb2bc92c0cf4d45389eff5b078d1936860a1be3aa8c94fa25fb46ed8"},
-    {file = "regex-2022.1.18-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2dacb3dae6b8cc579637a7b72f008bff50a94cde5e36e432352f4ca57b9e54c4"},
-    {file = "regex-2022.1.18-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:e5c31d70a478b0ca22a9d2d76d520ae996214019d39ed7dd93af872c7f301e52"},
-    {file = "regex-2022.1.18-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bb804c7d0bfbd7e3f33924ff49757de9106c44e27979e2492819c16972ec0da2"},
-    {file = "regex-2022.1.18-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:36b2d700a27e168fa96272b42d28c7ac3ff72030c67b32f37c05616ebd22a202"},
-    {file = "regex-2022.1.18-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:16f81025bb3556eccb0681d7946e2b35ff254f9f888cff7d2120e8826330315c"},
-    {file = "regex-2022.1.18-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:da80047524eac2acf7c04c18ac7a7da05a9136241f642dd2ed94269ef0d0a45a"},
-    {file = "regex-2022.1.18-cp37-cp37m-win32.whl", hash = "sha256:6ca45359d7a21644793de0e29de497ef7f1ae7268e346c4faf87b421fea364e6"},
-    {file = "regex-2022.1.18-cp37-cp37m-win_amd64.whl", hash = "sha256:38289f1690a7e27aacd049e420769b996826f3728756859420eeee21cc857118"},
-    {file = "regex-2022.1.18-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6014038f52b4b2ac1fa41a58d439a8a00f015b5c0735a0cd4b09afe344c94899"},
-    {file = "regex-2022.1.18-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0b5d6f9aed3153487252d00a18e53f19b7f52a1651bc1d0c4b5844bc286dfa52"},
-    {file = "regex-2022.1.18-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9d24b03daf7415f78abc2d25a208f234e2c585e5e6f92f0204d2ab7b9ab48e3"},
-    {file = "regex-2022.1.18-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bf594cc7cc9d528338d66674c10a5b25e3cde7dd75c3e96784df8f371d77a298"},
-    {file = "regex-2022.1.18-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd914db437ec25bfa410f8aa0aa2f3ba87cdfc04d9919d608d02330947afaeab"},
-    {file = "regex-2022.1.18-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90b6840b6448203228a9d8464a7a0d99aa8fa9f027ef95fe230579abaf8a6ee1"},
-    {file = "regex-2022.1.18-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11772be1eb1748e0e197a40ffb82fb8fd0d6914cd147d841d9703e2bef24d288"},
-    {file = "regex-2022.1.18-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a602bdc8607c99eb5b391592d58c92618dcd1537fdd87df1813f03fed49957a6"},
-    {file = "regex-2022.1.18-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7e26eac9e52e8ce86f915fd33380f1b6896a2b51994e40bb094841e5003429b4"},
-    {file = "regex-2022.1.18-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:519c0b3a6fbb68afaa0febf0d28f6c4b0a1074aefc484802ecb9709faf181607"},
-    {file = "regex-2022.1.18-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:3c7ea86b9ca83e30fa4d4cd0eaf01db3ebcc7b2726a25990966627e39577d729"},
-    {file = "regex-2022.1.18-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:51f02ca184518702975b56affde6c573ebad4e411599005ce4468b1014b4786c"},
-    {file = "regex-2022.1.18-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:385ccf6d011b97768a640e9d4de25412204fbe8d6b9ae39ff115d4ff03f6fe5d"},
-    {file = "regex-2022.1.18-cp38-cp38-win32.whl", hash = "sha256:1f8c0ae0a0de4e19fddaaff036f508db175f6f03db318c80bbc239a1def62d02"},
-    {file = "regex-2022.1.18-cp38-cp38-win_amd64.whl", hash = "sha256:760c54ad1b8a9b81951030a7e8e7c3ec0964c1cb9fee585a03ff53d9e531bb8e"},
-    {file = "regex-2022.1.18-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:93c20777a72cae8620203ac11c4010365706062aa13aaedd1a21bb07adbb9d5d"},
-    {file = "regex-2022.1.18-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6aa427c55a0abec450bca10b64446331b5ca8f79b648531138f357569705bc4a"},
-    {file = "regex-2022.1.18-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c38baee6bdb7fe1b110b6b3aaa555e6e872d322206b7245aa39572d3fc991ee4"},
-    {file = "regex-2022.1.18-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:752e7ddfb743344d447367baa85bccd3629c2c3940f70506eb5f01abce98ee68"},
-    {file = "regex-2022.1.18-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8acef4d8a4353f6678fd1035422a937c2170de58a2b29f7da045d5249e934101"},
-    {file = "regex-2022.1.18-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c73d2166e4b210b73d1429c4f1ca97cea9cc090e5302df2a7a0a96ce55373f1c"},
-    {file = "regex-2022.1.18-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24c89346734a4e4d60ecf9b27cac4c1fee3431a413f7aa00be7c4d7bbacc2c4d"},
-    {file = "regex-2022.1.18-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:596f5ae2eeddb79b595583c2e0285312b2783b0ec759930c272dbf02f851ff75"},
-    {file = "regex-2022.1.18-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ecfe51abf7f045e0b9cdde71ca9e153d11238679ef7b5da6c82093874adf3338"},
-    {file = "regex-2022.1.18-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:1d6301f5288e9bdca65fab3de6b7de17362c5016d6bf8ee4ba4cbe833b2eda0f"},
-    {file = "regex-2022.1.18-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:93cce7d422a0093cfb3606beae38a8e47a25232eea0f292c878af580a9dc7605"},
-    {file = "regex-2022.1.18-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:cf0db26a1f76aa6b3aa314a74b8facd586b7a5457d05b64f8082a62c9c49582a"},
-    {file = "regex-2022.1.18-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:defa0652696ff0ba48c8aff5a1fac1eef1ca6ac9c660b047fc8e7623c4eb5093"},
-    {file = "regex-2022.1.18-cp39-cp39-win32.whl", hash = "sha256:6db1b52c6f2c04fafc8da17ea506608e6be7086715dab498570c3e55e4f8fbd1"},
-    {file = "regex-2022.1.18-cp39-cp39-win_amd64.whl", hash = "sha256:ebaeb93f90c0903233b11ce913a7cb8f6ee069158406e056f884854c737d2442"},
-    {file = "regex-2022.1.18.tar.gz", hash = "sha256:97f32dc03a8054a4c4a5ab5d761ed4861e828b2c200febd4e46857069a483916"},
+    {file = "regex-2022.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ab69b4fe09e296261377d209068d52402fb85ef89dc78a9ac4a29a895f4e24a7"},
+    {file = "regex-2022.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5bc5f921be39ccb65fdda741e04b2555917a4bced24b4df14eddc7569be3b493"},
+    {file = "regex-2022.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43eba5c46208deedec833663201752e865feddc840433285fbadee07b84b464d"},
+    {file = "regex-2022.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c68d2c04f7701a418ec2e5631b7f3552efc32f6bcc1739369c6eeb1af55f62e0"},
+    {file = "regex-2022.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:caa2734ada16a44ae57b229d45091f06e30a9a52ace76d7574546ab23008c635"},
+    {file = "regex-2022.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef806f684f17dbd6263d72a54ad4073af42b42effa3eb42b877e750c24c76f86"},
+    {file = "regex-2022.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be319f4eb400ee567b722e9ea63d5b2bb31464e3cf1b016502e3ee2de4f86f5c"},
+    {file = "regex-2022.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:42bb37e2b2d25d958c25903f6125a41aaaa1ed49ca62c103331f24b8a459142f"},
+    {file = "regex-2022.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fbc88d3ba402b5d041d204ec2449c4078898f89c4a6e6f0ed1c1a510ef1e221d"},
+    {file = "regex-2022.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:91e0f7e7be77250b808a5f46d90bf0032527d3c032b2131b63dee54753a4d729"},
+    {file = "regex-2022.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:cb3652bbe6720786b9137862205986f3ae54a09dec8499a995ed58292bdf77c2"},
+    {file = "regex-2022.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:878c626cbca3b649e14e972c14539a01191d79e58934e3f3ef4a9e17f90277f8"},
+    {file = "regex-2022.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6df070a986fc064d865c381aecf0aaff914178fdf6874da2f2387e82d93cc5bd"},
+    {file = "regex-2022.3.2-cp310-cp310-win32.whl", hash = "sha256:b549d851f91a4efb3e65498bd4249b1447ab6035a9972f7fc215eb1f59328834"},
+    {file = "regex-2022.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:8babb2b5751105dc0aef2a2e539f4ba391e738c62038d8cb331c710f6b0f3da7"},
+    {file = "regex-2022.3.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:1977bb64264815d3ef016625adc9df90e6d0e27e76260280c63eca993e3f455f"},
+    {file = "regex-2022.3.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e73652057473ad3e6934944af090852a02590c349357b79182c1b681da2c772"},
+    {file = "regex-2022.3.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b22ff939a8856a44f4822da38ef4868bd3a9ade22bb6d9062b36957c850e404f"},
+    {file = "regex-2022.3.2-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:878f5d649ba1db9f52cc4ef491f7dba2d061cdc48dd444c54260eebc0b1729b9"},
+    {file = "regex-2022.3.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0008650041531d0eadecc96a73d37c2dc4821cf51b0766e374cb4f1ddc4e1c14"},
+    {file = "regex-2022.3.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:06b1df01cf2aef3a9790858af524ae2588762c8a90e784ba00d003f045306204"},
+    {file = "regex-2022.3.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57484d39447f94967e83e56db1b1108c68918c44ab519b8ecfc34b790ca52bf7"},
+    {file = "regex-2022.3.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:74d86e8924835f863c34e646392ef39039405f6ce52956d8af16497af4064a30"},
+    {file = "regex-2022.3.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:ae17fc8103f3b63345709d3e9654a274eee1c6072592aec32b026efd401931d0"},
+    {file = "regex-2022.3.2-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:5f92a7cdc6a0ae2abd184e8dfd6ef2279989d24c85d2c85d0423206284103ede"},
+    {file = "regex-2022.3.2-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:5dcc4168536c8f68654f014a3db49b6b4a26b226f735708be2054314ed4964f4"},
+    {file = "regex-2022.3.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:1e30762ddddb22f7f14c4f59c34d3addabc789216d813b0f3e2788d7bcf0cf29"},
+    {file = "regex-2022.3.2-cp36-cp36m-win32.whl", hash = "sha256:286ff9ec2709d56ae7517040be0d6c502642517ce9937ab6d89b1e7d0904f863"},
+    {file = "regex-2022.3.2-cp36-cp36m-win_amd64.whl", hash = "sha256:d326ff80ed531bf2507cba93011c30fff2dd51454c85f55df0f59f2030b1687b"},
+    {file = "regex-2022.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9d828c5987d543d052b53c579a01a52d96b86f937b1777bbfe11ef2728929357"},
+    {file = "regex-2022.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c87ac58b9baaf50b6c1b81a18d20eda7e2883aa9a4fb4f1ca70f2e443bfcdc57"},
+    {file = "regex-2022.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6c2441538e4fadd4291c8420853431a229fcbefc1bf521810fbc2629d8ae8c2"},
+    {file = "regex-2022.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f3356afbb301ec34a500b8ba8b47cba0b44ed4641c306e1dd981a08b416170b5"},
+    {file = "regex-2022.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d96eec8550fd2fd26f8e675f6d8b61b159482ad8ffa26991b894ed5ee19038b"},
+    {file = "regex-2022.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf668f26604e9f7aee9f8eaae4ca07a948168af90b96be97a4b7fa902a6d2ac1"},
+    {file = "regex-2022.3.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0eb0e2845e81bdea92b8281a3969632686502565abf4a0b9e4ab1471c863d8f3"},
+    {file = "regex-2022.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:87bc01226cd288f0bd9a4f9f07bf6827134dc97a96c22e2d28628e824c8de231"},
+    {file = "regex-2022.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:09b4b6ccc61d4119342b26246ddd5a04accdeebe36bdfe865ad87a0784efd77f"},
+    {file = "regex-2022.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:9557545c10d52c845f270b665b52a6a972884725aa5cf12777374e18f2ea8960"},
+    {file = "regex-2022.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:0be0c34a39e5d04a62fd5342f0886d0e57592a4f4993b3f9d257c1f688b19737"},
+    {file = "regex-2022.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7b103dffb9f6a47ed7ffdf352b78cfe058b1777617371226c1894e1be443afec"},
+    {file = "regex-2022.3.2-cp37-cp37m-win32.whl", hash = "sha256:f8169ec628880bdbca67082a9196e2106060a4a5cbd486ac51881a4df805a36f"},
+    {file = "regex-2022.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:4b9c16a807b17b17c4fa3a1d8c242467237be67ba92ad24ff51425329e7ae3d0"},
+    {file = "regex-2022.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:67250b36edfa714ba62dc62d3f238e86db1065fccb538278804790f578253640"},
+    {file = "regex-2022.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5510932596a0f33399b7fff1bd61c59c977f2b8ee987b36539ba97eb3513584a"},
+    {file = "regex-2022.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6f7ee2289176cb1d2c59a24f50900f8b9580259fa9f1a739432242e7d254f93"},
+    {file = "regex-2022.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d7a68fa53688e1f612c3246044157117403c7ce19ebab7d02daf45bd63913e"},
+    {file = "regex-2022.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aaf5317c961d93c1a200b9370fb1c6b6836cc7144fef3e5a951326912bf1f5a3"},
+    {file = "regex-2022.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad397bc7d51d69cb07ef89e44243f971a04ce1dca9bf24c992c362406c0c6573"},
+    {file = "regex-2022.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:297c42ede2c81f0cb6f34ea60b5cf6dc965d97fa6936c11fc3286019231f0d66"},
+    {file = "regex-2022.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:af4d8cc28e4c7a2f6a9fed544228c567340f8258b6d7ea815b62a72817bbd178"},
+    {file = "regex-2022.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:452519bc4c973e961b1620c815ea6dd8944a12d68e71002be5a7aff0a8361571"},
+    {file = "regex-2022.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:cb34c2d66355fb70ae47b5595aafd7218e59bb9c00ad8cc3abd1406ca5874f07"},
+    {file = "regex-2022.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:3d146e5591cb67c5e836229a04723a30af795ef9b70a0bbd913572e14b7b940f"},
+    {file = "regex-2022.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:03299b0bcaa7824eb7c0ebd7ef1e3663302d1b533653bfe9dc7e595d453e2ae9"},
+    {file = "regex-2022.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9ccb0a4ab926016867260c24c192d9df9586e834f5db83dfa2c8fffb3a6e5056"},
+    {file = "regex-2022.3.2-cp38-cp38-win32.whl", hash = "sha256:f7e8f1ee28e0a05831c92dc1c0c1c94af5289963b7cf09eca5b5e3ce4f8c91b0"},
+    {file = "regex-2022.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:35ed2f3c918a00b109157428abfc4e8d1ffabc37c8f9abc5939ebd1e95dabc47"},
+    {file = "regex-2022.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:55820bc631684172b9b56a991d217ec7c2e580d956591dc2144985113980f5a3"},
+    {file = "regex-2022.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:83f03f0bd88c12e63ca2d024adeee75234d69808b341e88343b0232329e1f1a1"},
+    {file = "regex-2022.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42d6007722d46bd2c95cce700181570b56edc0dcbadbfe7855ec26c3f2d7e008"},
+    {file = "regex-2022.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:320c2f4106962ecea0f33d8d31b985d3c185757c49c1fb735501515f963715ed"},
+    {file = "regex-2022.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbd3fe37353c62fd0eb19fb76f78aa693716262bcd5f9c14bb9e5aca4b3f0dc4"},
+    {file = "regex-2022.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e51ad1e6131c496b58d317bc9abec71f44eb1957d32629d06013a21bc99cac"},
+    {file = "regex-2022.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72bc3a5effa5974be6d965ed8301ac1e869bc18425c8a8fac179fbe7876e3aee"},
+    {file = "regex-2022.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e5602a9b5074dcacc113bba4d2f011d2748f50e3201c8139ac5b68cf2a76bd8b"},
+    {file = "regex-2022.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:729aa8ca624c42f309397c5fc9e21db90bf7e2fdd872461aabdbada33de9063c"},
+    {file = "regex-2022.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d6ecfd1970b3380a569d7b3ecc5dd70dba295897418ed9e31ec3c16a5ab099a5"},
+    {file = "regex-2022.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:13bbf0c9453c6d16e5867bda7f6c0c7cff1decf96c5498318bb87f8136d2abd4"},
+    {file = "regex-2022.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:58ba41e462653eaf68fc4a84ec4d350b26a98d030be1ab24aba1adcc78ffe447"},
+    {file = "regex-2022.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c0446b2871335d5a5e9fcf1462f954586b09a845832263db95059dcd01442015"},
+    {file = "regex-2022.3.2-cp39-cp39-win32.whl", hash = "sha256:20e6a27959f162f979165e496add0d7d56d7038237092d1aba20b46de79158f1"},
+    {file = "regex-2022.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:9efa41d1527b366c88f265a227b20bcec65bda879962e3fc8a2aee11e81266d7"},
+    {file = "regex-2022.3.2.tar.gz", hash = "sha256:79e5af1ff258bc0fe0bdd6f69bc4ae33935a898e3cbefbbccf22e88a27fa053b"},
 ]
 requests = [
     {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
@@ -2559,8 +2560,8 @@ s3transfer = [
     {file = "s3transfer-0.5.2.tar.gz", hash = "sha256:95c58c194ce657a5f4fb0b9e60a84968c808888aed628cd98ab8771fe1db98ed"},
 ]
 sentry-sdk = [
-    {file = "sentry-sdk-1.5.6.tar.gz", hash = "sha256:ac2a50128409d57655279817aedcb7800cace1f76b266f3dd62055d5afd6e098"},
-    {file = "sentry_sdk-1.5.6-py2.py3-none-any.whl", hash = "sha256:1ab34e3851a34aeb3d1af1a0f77cec73978c4e9698e5210d050e4932953cb241"},
+    {file = "sentry-sdk-1.5.7.tar.gz", hash = "sha256:aa52da941c56b5a76fd838f8e9e92a850bf893a9eb1e33ffce6c21431d07ee30"},
+    {file = "sentry_sdk-1.5.7-py2.py3-none-any.whl", hash = "sha256:411a8495bd18cf13038e5749e4710beb4efa53da6351f67b4c2f307c2d9b6d49"},
 ]
 setproctitle = [
     {file = "setproctitle-1.2.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:9106bcbacae534b6f82955b176723f1b2ca6514518aab44dffec05a583f8dca8"},
@@ -2590,8 +2591,8 @@ six = [
     {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
 ]
 snick = [
-    {file = "snick-1.1.0-py3-none-any.whl", hash = "sha256:1c3c15d777469d1e55bf308fef1407a030c787d3fb583452d8b2baa884f28d21"},
-    {file = "snick-1.1.0.tar.gz", hash = "sha256:0e1d3f174f639d2c5d2cf5e274ec03091d37655cacf32bfb54fc46b654c9a5ce"},
+    {file = "snick-1.2.0-py3-none-any.whl", hash = "sha256:bf5243b13bb84c5c150d90db2f20432282a7e6154aff9d2f91c5e1aa565346d7"},
+    {file = "snick-1.2.0.tar.gz", hash = "sha256:0534ed139142e76b110f84c60787a3628bb362e087265f3dd932b963945f3b11"},
 ]
 sniffio = [
     {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"},
@@ -2602,42 +2603,41 @@ snowballstemmer = [
     {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"},
 ]
 sqlalchemy = [
-    {file = "SQLAlchemy-1.4.31-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:c3abc34fed19fdeaead0ced8cf56dd121f08198008c033596aa6aae7cc58f59f"},
-    {file = "SQLAlchemy-1.4.31-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:8d0949b11681380b4a50ac3cd075e4816afe9fa4a8c8ae006c1ca26f0fa40ad8"},
-    {file = "SQLAlchemy-1.4.31-cp27-cp27m-win32.whl", hash = "sha256:f3b7ec97e68b68cb1f9ddb82eda17b418f19a034fa8380a0ac04e8fe01532875"},
-    {file = "SQLAlchemy-1.4.31-cp27-cp27m-win_amd64.whl", hash = "sha256:81f2dd355b57770fdf292b54f3e0a9823ec27a543f947fa2eb4ec0df44f35f0d"},
-    {file = "SQLAlchemy-1.4.31-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4ad31cec8b49fd718470328ad9711f4dc703507d434fd45461096da0a7135ee0"},
-    {file = "SQLAlchemy-1.4.31-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:05fa14f279d43df68964ad066f653193187909950aa0163320b728edfc400167"},
-    {file = "SQLAlchemy-1.4.31-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dccff41478050e823271642837b904d5f9bda3f5cf7d371ce163f00a694118d6"},
-    {file = "SQLAlchemy-1.4.31-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57205844f246bab9b666a32f59b046add8995c665d9ecb2b7b837b087df90639"},
-    {file = "SQLAlchemy-1.4.31-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea8210090a816d48a4291a47462bac750e3bc5c2442e6d64f7b8137a7c3f9ac5"},
-    {file = "SQLAlchemy-1.4.31-cp310-cp310-win32.whl", hash = "sha256:2e216c13ecc7fcdcbb86bb3225425b3ed338e43a8810c7089ddb472676124b9b"},
-    {file = "SQLAlchemy-1.4.31-cp310-cp310-win_amd64.whl", hash = "sha256:e3a86b59b6227ef72ffc10d4b23f0fe994bef64d4667eab4fb8cd43de4223bec"},
-    {file = "SQLAlchemy-1.4.31-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:2fd4d3ca64c41dae31228b80556ab55b6489275fb204827f6560b65f95692cf3"},
-    {file = "SQLAlchemy-1.4.31-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f22c040d196f841168b1456e77c30a18a3dc16b336ddbc5a24ce01ab4e95ae0"},
-    {file = "SQLAlchemy-1.4.31-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c0c7171aa5a57e522a04a31b84798b6c926234cb559c0939840c3235cf068813"},
-    {file = "SQLAlchemy-1.4.31-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d046a9aeba9bc53e88a41e58beb72b6205abb9a20f6c136161adf9128e589db5"},
-    {file = "SQLAlchemy-1.4.31-cp36-cp36m-win32.whl", hash = "sha256:d86132922531f0dc5a4f424c7580a472a924dd737602638e704841c9cb24aea2"},
-    {file = "SQLAlchemy-1.4.31-cp36-cp36m-win_amd64.whl", hash = "sha256:ca68c52e3cae491ace2bf39b35fef4ce26c192fd70b4cd90f040d419f70893b5"},
-    {file = "SQLAlchemy-1.4.31-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:cf2cd387409b12d0a8b801610d6336ee7d24043b6dd965950eaec09b73e7262f"},
-    {file = "SQLAlchemy-1.4.31-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb4b15fb1f0aafa65cbdc62d3c2078bea1ceecbfccc9a1f23a2113c9ac1191fa"},
-    {file = "SQLAlchemy-1.4.31-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c317ddd7c586af350a6aef22b891e84b16bff1a27886ed5b30f15c1ed59caeaa"},
-    {file = "SQLAlchemy-1.4.31-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c7ed6c69debaf6198fadb1c16ae1253a29a7670bbf0646f92582eb465a0b999"},
-    {file = "SQLAlchemy-1.4.31-cp37-cp37m-win32.whl", hash = "sha256:6a01ec49ca54ce03bc14e10de55dfc64187a2194b3b0e5ac0fdbe9b24767e79e"},
-    {file = "SQLAlchemy-1.4.31-cp37-cp37m-win_amd64.whl", hash = "sha256:330eb45395874cc7787214fdd4489e2afb931bc49e0a7a8f9cd56d6e9c5b1639"},
-    {file = "SQLAlchemy-1.4.31-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:5e9c7b3567edbc2183607f7d9f3e7e89355b8f8984eec4d2cd1e1513c8f7b43f"},
-    {file = "SQLAlchemy-1.4.31-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de85c26a5a1c72e695ab0454e92f60213b4459b8d7c502e0be7a6369690eeb1a"},
-    {file = "SQLAlchemy-1.4.31-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:975f5c0793892c634c4920057da0de3a48bbbbd0a5c86f5fcf2f2fedf41b76da"},
-    {file = "SQLAlchemy-1.4.31-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5c20c8415173b119762b6110af64448adccd4d11f273fb9f718a9865b88a99c"},
-    {file = "SQLAlchemy-1.4.31-cp38-cp38-win32.whl", hash = "sha256:b35dca159c1c9fa8a5f9005e42133eed82705bf8e243da371a5e5826440e65ca"},
-    {file = "SQLAlchemy-1.4.31-cp38-cp38-win_amd64.whl", hash = "sha256:b7b20c88873675903d6438d8b33fba027997193e274b9367421e610d9da76c08"},
-    {file = "SQLAlchemy-1.4.31-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:85e4c244e1de056d48dae466e9baf9437980c19fcde493e0db1a0a986e6d75b4"},
-    {file = "SQLAlchemy-1.4.31-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e79e73d5ee24196d3057340e356e6254af4d10e1fc22d3207ea8342fc5ffb977"},
-    {file = "SQLAlchemy-1.4.31-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:15a03261aa1e68f208e71ae3cd845b00063d242cbf8c87348a0c2c0fc6e1f2ac"},
-    {file = "SQLAlchemy-1.4.31-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ddc5e5ccc0160e7ad190e5c61eb57560f38559e22586955f205e537cda26034"},
-    {file = "SQLAlchemy-1.4.31-cp39-cp39-win32.whl", hash = "sha256:289465162b1fa1e7a982f8abe59d26a8331211cad4942e8031d2b7db1f75e649"},
-    {file = "SQLAlchemy-1.4.31-cp39-cp39-win_amd64.whl", hash = "sha256:9e4fb2895b83993831ba2401b6404de953fdbfa9d7d4fa6a4756294a83bbc94f"},
-    {file = "SQLAlchemy-1.4.31.tar.gz", hash = "sha256:582b59d1e5780a447aada22b461e50b404a9dc05768da1d87368ad8190468418"},
+    {file = "SQLAlchemy-1.4.32-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:4b2bcab3a914715d332ca783e9bda13bc570d8b9ef087563210ba63082c18c16"},
+    {file = "SQLAlchemy-1.4.32-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:159c2f69dd6efd28e894f261ffca1100690f28210f34cfcd70b895e0ea7a64f3"},
+    {file = "SQLAlchemy-1.4.32-cp27-cp27m-win_amd64.whl", hash = "sha256:d7e483f4791fbda60e23926b098702340504f7684ce7e1fd2c1bf02029288423"},
+    {file = "SQLAlchemy-1.4.32-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4aa96e957141006181ca58e792e900ee511085b8dae06c2d08c00f108280fb8a"},
+    {file = "SQLAlchemy-1.4.32-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:576684771456d02e24078047c2567025f2011977aa342063468577d94e194b00"},
+    {file = "SQLAlchemy-1.4.32-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff677fa4522dafb5a5e2c0cf909790d5d367326321aeabc0dffc9047cb235bd"},
+    {file = "SQLAlchemy-1.4.32-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8679f9aba5ac22e7bce54ccd8a77641d3aea3e2d96e73e4356c887ebf8ff1082"},
+    {file = "SQLAlchemy-1.4.32-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7046f7aa2db445daccc8424f50b47a66c4039c9f058246b43796aa818f8b751"},
+    {file = "SQLAlchemy-1.4.32-cp310-cp310-win32.whl", hash = "sha256:bedd89c34ab62565d44745212814e4b57ef1c24ad4af9b29c504ce40f0dc6558"},
+    {file = "SQLAlchemy-1.4.32-cp310-cp310-win_amd64.whl", hash = "sha256:199dc6d0068753b6a8c0bd3aceb86a3e782df118260ebc1fa981ea31ee054674"},
+    {file = "SQLAlchemy-1.4.32-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:8e1e5d96b744a4f91163290b01045430f3f32579e46d87282449e5b14d27d4ac"},
+    {file = "SQLAlchemy-1.4.32-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edfcf93fd92e2f9eef640b3a7a40db20fe3c1d7c2c74faa41424c63dead61b76"},
+    {file = "SQLAlchemy-1.4.32-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:04164e0063feb7aedd9d073db0fd496edb244be40d46ea1f0d8990815e4b8c34"},
+    {file = "SQLAlchemy-1.4.32-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ba59761c19b800bc2e1c9324da04d35ef51e4ee9621ff37534bc2290d258f71"},
+    {file = "SQLAlchemy-1.4.32-cp36-cp36m-win32.whl", hash = "sha256:708973b5d9e1e441188124aaf13c121e5b03b6054c2df59b32219175a25aa13e"},
+    {file = "SQLAlchemy-1.4.32-cp36-cp36m-win_amd64.whl", hash = "sha256:316270e5867566376e69a0ac738b863d41396e2b63274616817e1d34156dff0e"},
+    {file = "SQLAlchemy-1.4.32-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:9a0195af6b9050c9322a97cf07514f66fe511968e623ca87b2df5e3cf6349615"},
+    {file = "SQLAlchemy-1.4.32-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e4a3c0c3c596296b37f8427c467c8e4336dc8d50f8ed38042e8ba79507b2c9"},
+    {file = "SQLAlchemy-1.4.32-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bca714d831e5b8860c3ab134c93aec63d1a4f493bed20084f54e3ce9f0a3bf99"},
+    {file = "SQLAlchemy-1.4.32-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9a680d9665f88346ed339888781f5236347933906c5a56348abb8261282ec48"},
+    {file = "SQLAlchemy-1.4.32-cp37-cp37m-win32.whl", hash = "sha256:9cb5698c896fa72f88e7ef04ef62572faf56809093180771d9be8d9f2e264a13"},
+    {file = "SQLAlchemy-1.4.32-cp37-cp37m-win_amd64.whl", hash = "sha256:8b9a395122770a6f08ebfd0321546d7379f43505882c7419d7886856a07caa13"},
+    {file = "SQLAlchemy-1.4.32-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:3f88a4ee192142eeed3fe173f673ea6ab1f5a863810a9d85dbf6c67a9bd08f97"},
+    {file = "SQLAlchemy-1.4.32-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd93162615870c976dba43963a24bb418b28448fef584f30755990c134a06a55"},
+    {file = "SQLAlchemy-1.4.32-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5a2e73508f939175363d8a4be9dcdc84cf16a92578d7fa86e6e4ca0e6b3667b2"},
+    {file = "SQLAlchemy-1.4.32-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfec934aac7f9fa95fc82147a4ba5db0a8bdc4ebf1e33b585ab8860beb10232f"},
+    {file = "SQLAlchemy-1.4.32-cp38-cp38-win32.whl", hash = "sha256:bb42f9b259c33662c6a9b866012f6908a91731a419e69304e1261ba3ab87b8d1"},
+    {file = "SQLAlchemy-1.4.32-cp38-cp38-win_amd64.whl", hash = "sha256:7ff72b3cc9242d1a1c9b84bd945907bf174d74fc2519efe6184d6390a8df478b"},
+    {file = "SQLAlchemy-1.4.32-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5dc9801ae9884e822ba942ca493642fb50f049c06b6dbe3178691fce48ceb089"},
+    {file = "SQLAlchemy-1.4.32-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4607d2d16330757818c9d6fba322c2e80b4b112ff24295d1343a80b876eb0ed"},
+    {file = "SQLAlchemy-1.4.32-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:20e9eba7fd86ef52e0df25bea83b8b518dfdf0bce09b336cfe51671f52aaaa3f"},
+    {file = "SQLAlchemy-1.4.32-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:290cbdf19129ae520d4bdce392648c6fcdbee763bc8f750b53a5ab51880cb9c9"},
+    {file = "SQLAlchemy-1.4.32-cp39-cp39-win32.whl", hash = "sha256:1bbac3e8293b34c4403d297e21e8f10d2a57756b75cff101dc62186adec725f5"},
+    {file = "SQLAlchemy-1.4.32-cp39-cp39-win_amd64.whl", hash = "sha256:b3f1d9b3aa09ab9adc7f8c4b40fc3e081eb903054c9a6f9ae1633fe15ae503b4"},
+    {file = "SQLAlchemy-1.4.32.tar.gz", hash = "sha256:6fdd2dc5931daab778c2b65b03df6ae68376e028a3098eb624d0909d999885bc"},
 ]
 sqlalchemy-stubs = [
     {file = "sqlalchemy-stubs-0.4.tar.gz", hash = "sha256:c665d6dd4482ef642f01027fa06c3d5e91befabb219dc71fc2a09e7d7695f7ae"},
@@ -2706,16 +2706,16 @@ uvicorn = [
     {file = "uvicorn-0.15.0.tar.gz", hash = "sha256:d9a3c0dd1ca86728d3e235182683b4cf94cd53a867c288eaeca80ee781b2caff"},
 ]
 virtualenv = [
-    {file = "virtualenv-20.13.2-py2.py3-none-any.whl", hash = "sha256:e7b34c9474e6476ee208c43a4d9ac1510b041c68347eabfe9a9ea0c86aa0a46b"},
-    {file = "virtualenv-20.13.2.tar.gz", hash = "sha256:01f5f80744d24a3743ce61858123488e91cb2dd1d3bdf92adaf1bba39ffdedf0"},
+    {file = "virtualenv-20.13.3-py2.py3-none-any.whl", hash = "sha256:dd448d1ded9f14d1a4bfa6bfc0c5b96ae3be3f2d6c6c159b23ddcfd701baa021"},
+    {file = "virtualenv-20.13.3.tar.gz", hash = "sha256:e9dd1a1359d70137559034c0f5433b34caf504af2dc756367be86a5a32967134"},
 ]
 wcwidth = [
     {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"},
     {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"},
 ]
 websocket-client = [
-    {file = "websocket-client-1.2.3.tar.gz", hash = "sha256:1315816c0acc508997eb3ae03b9d3ff619c9d12d544c9a9b553704b1cc4f6af5"},
-    {file = "websocket_client-1.2.3-py3-none-any.whl", hash = "sha256:2eed4cc58e4d65613ed6114af2f380f7910ff416fc8c46947f6e76b6815f56c0"},
+    {file = "websocket-client-1.3.1.tar.gz", hash = "sha256:6278a75065395418283f887de7c3beafb3aa68dada5cacbe4b214e8d26da499b"},
+    {file = "websocket_client-1.3.1-py3-none-any.whl", hash = "sha256:074e2ed575e7c822fc0940d31c3ac9bb2b1142c303eafcf3e304e6ce035522e8"},
 ]
 win32-setctime = [
     {file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"},
diff --git a/jobbergate-cli/CHANGELOG.rst b/jobbergate-cli/CHANGELOG.rst
index 16b3a650..724f2ac4 100644
--- a/jobbergate-cli/CHANGELOG.rst
+++ b/jobbergate-cli/CHANGELOG.rst
@@ -6,6 +6,13 @@ This file keeps track of all notable changes to jobbergate-cli
 
 Unreleased
 ----------
+- Complete re-write of the Jobbergate CLI
+- Used typer to implement the application
+- Styled user output with Rich formatting
+- Broke the code up into modules and functions for easier maintenance and reading
+- Refactored the question asking system in applications
+- Added over 100 unit tests
+
 
 2.2.9 -- 2022-02-16
 -------------------
diff --git a/jobbergate-cli/Makefile b/jobbergate-cli/Makefile
index 228376b1..4a04a408 100644
--- a/jobbergate-cli/Makefile
+++ b/jobbergate-cli/Makefile
@@ -13,18 +13,22 @@ test: install
 
 .PHONY: lint
 lint: install
-	poetry run black --check ${PACKAGE_NAME}
-	poetry run isort --check ${PACKAGE_NAME}
-	poetry run flake8 --max-line-length=110 --max-complexity=40 ${PACKAGE_NAME}
+	poetry run black --check ${PACKAGE_NAME} tests
+	poetry run isort --check ${PACKAGE_NAME} tests
+	poetry run pflake8 ${PACKAGE_NAME} tests
+
+.PHONY: mypy
+mypy: install
+	poetry run mypy ${PACKAGE_NAME} --pretty
 
 .PHONY: qa
-qa: test lint
+qa: test lint mypy
 	echo "All quality checks pass!"
 
 .PHONY: format
 format: install
-	poetry run black ${PACKAGE_NAME}
-	poetry run isort ${PACKAGE_NAME}
+	poetry run black ${PACKAGE_NAME} tests
+	poetry run isort ${PACKAGE_NAME} tests
 
 .PHONY: release-%
 release-%:
@@ -41,8 +45,8 @@ clean:
 	@find . -iname '*~' -delete
 	@find . -iname '*.swp' -delete
 	@find . -iname '__pycache__' -delete
-	@rm -r .mypy_cache
-	@rm -r .pytest_cache
+	@rm -rf .mypy_cache
+	@rm -rf .pytest_cache
 	@find . -name '*.egg' -print0|xargs -0 rm -rf --
 	@rm -rf .eggs/
 	@rm -fr build/
diff --git a/jobbergate-cli/jobbergate_cli/__init__.py b/jobbergate-cli/jobbergate_cli/__init__.py
index ab19cbe2..e24f8e84 100644
--- a/jobbergate-cli/jobbergate_cli/__init__.py
+++ b/jobbergate-cli/jobbergate_cli/__init__.py
@@ -1,3 +1,23 @@
 """
 Jobbergate command-line interface and app library
 """
+
+import importlib
+import warnings
+
+from jobbergate_cli.config import settings
+
+
+def __getattr__(name: str):
+    """
+    Overload module attribute lookup to warn if 'appform' is being imported because it is deprecated.
+    """
+    if name == "appform" and settings.JOBBERGATE_COMPATIBILITY_MODE:
+        warnings.warn(
+            "appform is deprecated.  Import from 'jobbergate_cli.subapps.applications.questions' instead",
+            DeprecationWarning,
+        )
+        return importlib.import_module("jobbergate_cli.subapps.applications.questions", __name__)
+
+    else:
+        raise AttributeError(f"module {__name__} has no attribute {name}")
diff --git a/jobbergate-cli/jobbergate_cli/appform.py b/jobbergate-cli/jobbergate_cli/appform.py
deleted file mode 100644
index a645a82e..00000000
--- a/jobbergate-cli/jobbergate_cli/appform.py
+++ /dev/null
@@ -1,179 +0,0 @@
-"""
-Appform
-=======
-Abstraction layer for questions. Each class represents different question
-types, and QuestionBase"""
-
-from collections import deque
-from functools import partial, wraps
-
-
-questions = deque()
-workflows = {}
-
-
-class QuestionBase:
-    """Baseclass for questions.
-    All questions have variablename, message and an optional default.
-    :param variablename: The variable name to set
-    :param message: Message to show
-    :param default: Default value
-    """
-
-    def __init__(self, variablename, message, default):
-        self.variablename = variablename
-        self.message = message
-        self.default = default
-
-
-class Text(QuestionBase):
-    """Asks for a text value.
-    :param variablename: The variable name to set
-    :param message: Message to show
-    :param default: Default value
-    """
-
-    def __init__(self, variablename, message, default=None):
-        super().__init__(variablename, message, default)
-
-
-class Integer(QuestionBase):
-    """Asks for an integer value. Could have min and/or max constrains.
-    :param variablename: The variable name to set
-    :param message: Message to show
-    :param minval: Minumum value
-    :param maxval: Maximum value
-    :param default: Default value
-    """
-
-    def __init__(self, variablename, message, minval=None, maxval=None, default=None):
-        super().__init__(variablename, message, default)
-        self.maxval = maxval
-        self.minval = minval
-
-    def validate(self, _, value):
-        if self.minval is not None and self.maxval is not None:
-            return self.minval <= int(value) <= self.maxval
-        if self.minval is not None:
-            return self.minval <= int(value)
-        if self.maxval is not None:
-            return int(value) <= self.maxval
-        return True
-
-
-class List(QuestionBase):
-    """Gives the user a list to choose one from.
-    :param variablename: The variable name to set
-    :param message: Message to show
-    :param choices: List with choices
-    :param default: Default value"""
-
-    def __init__(self, variablename, message, choices, default=None):
-        super().__init__(variablename, message, default)
-        self.choices = choices
-
-
-class Directory(QuestionBase):
-    """Asks for a directory name. If `exists` is `True` it checks if path exists and is a directory.
-    :param variablename: The variable name to set
-    :param message: Message to show
-    :param default: Default value
-    :param exists: Checks if given directory exists"""
-
-    def __init__(self, variablename, message, default=None, exists=None):
-        super().__init__(variablename, message, default)
-        self.exists = exists
-
-
-class File(QuestionBase):
-    """Asks for a file name. If `exists` is `True` it checks if path exists and is a directory.
-    :param variablename: The variable name to set
-    :param message: Message to show
-    :param default: Default value
-    :param exists: Checks if given file exists"""
-
-    def __init__(self, variablename, message, default=None, exists=None):
-        super().__init__(variablename, message, default)
-        self.exists = exists
-
-
-class Checkbox(QuestionBase):
-    """Gives the user a list to choose multiple entries from.
-    :param variablename: The variable name to set
-    :param message: Message to show
-    :param choices: List with choices
-    :param default: Default value(s)"""
-
-    def __init__(self, variablename, message, choices, default=None):
-        super().__init__(variablename, message, default)
-        self.choices = choices
-
-
-class Confirm(QuestionBase):
-    """Asks a question with an boolean answer (true/false).
-    :param variablename: The variable name to set
-    :param message: Message to show
-    :param default: Default value
-    """
-
-    def __init__(self, variablename, message, default=None):
-        super().__init__(variablename, message, default)
-
-
-class BooleanList(QuestionBase):
-    """Gives the use a boolean question, and depending on answer it shows `whentrue` or `whenfalse` questions.
-    `whentrue` and `whenfalse` are lists with questions. Could contain multiple levels of BooleanLists.
-    :param variablename: The variable name to set
-    :param message: Message to show
-    :param default: Default value
-    :param whentrue: List of questions to show if user answers yes/true on this question
-    :param whentrue: List of questions to show if user answers no/false on this question
-    """
-
-    def __init__(self, variablename, message, default=None, whentrue=None, whenfalse=None):
-        super().__init__(variablename, message, default)
-        if whentrue is None and whenfalse is None:
-            raise ValueError("Empty questions lists")
-        self.whentrue = whentrue
-        self.whenfalse = whenfalse
-        self.ignore = lambda a: a[self.variablename]
-        self.noignore = lambda a: not a[self.variablename]
-
-
-class Const(QuestionBase):
-    """Sets the variable to the `default` value. Doesn't show anything.
-    :param variablename: The variable name to set
-    :param message: Message to show
-    :param default: Value that variable is set to
-    """
-
-    def __init__(self, variablename, default):
-        super().__init__(variablename, None, default)
-
-
-def workflow(func=None, *, name=None):
-    """A decorator for workflows. Adds an workflow question and all questions
-    added in the decorated question is asked after selecting workflow.
-    :param name: (optional) Descriptional name that is shown when choosing workflow
-    Add a workflow named debug:
-    .. code-block:: python
-        @workflow
-        def debug(data):
-            return [appform.File("debugfile", "Name of debug file")]
-    Add a workflow with longer name:
-    .. code-block:: python
-        @workflow(name="Secondary Eigen step")
-        def 2ndstep(data):
-            return [appform.Text("eigendata", "Definition of eigendata")]
-    """
-
-    if func is None:
-        return partial(workflow, name=name)
-
-    @wraps(func)
-    def wrapper(*args, **kvargs):
-        return func(*args, **kvargs)
-
-    workflows[name or func.__name__] = func
-
-    return
diff --git a/jobbergate-cli/jobbergate_cli/application_base.py b/jobbergate-cli/jobbergate_cli/application_base.py
index b386a0a8..4cf186cb 100644
--- a/jobbergate-cli/jobbergate_cli/application_base.py
+++ b/jobbergate-cli/jobbergate_cli/application_base.py
@@ -1,22 +1,37 @@
-#!/usr/bin/env python3
-"""ApplicationBase."""
-import os
+"""
+Provide a stub module to maintain compatibility with previous versions.
 
+Issue a deprecation warning when this module is imported from if JOBBERGATE_COMPATIBILITY_MODE is enabled.
 
-class JobbergateApplicationBase:
-    """JobbergateApplicationBase."""
+If JOBBERGATE_COMPATIBILITY_MODE is not enabled, raise an import error when this module is imported.
+"""
 
-    def __init__(self, jobbergate_yaml):
-        """Initialize class attributes."""
-        self.jobbergate_config = jobbergate_yaml["jobbergate_config"]
-        self.application_config = jobbergate_yaml["application_config"]
+import warnings
 
-    def mainflow(self, data):
-        """Implements the main question asking workflow."""
-        raise NotImplementedError("Inheriting class must override this method.")
+from jobbergate_cli.config import settings
+from jobbergate_cli.text_tools import dedent, unwrap
 
-    def get_template_files(self):
-        templates = [template for root, directory, template in os.walk("./templates")]
-        print(f"templates are: {templates}")
-        print(os.getcwd())
-        return templates[0]
+
+if settings.JOBBERGATE_COMPATIBILITY_MODE:
+
+    from jobbergate_cli.subapps.applications.application_base import JobbergateApplicationBase  # noqa
+
+    warnings.warn(
+        dedent(
+            """
+            Importing application_base from jobbergate_cli is deprecated.
+            The module has been moved.
+            Import 'application_base' from 'jobbergate_cli.subapps.applications' instead",
+            """
+        ),
+        DeprecationWarning,
+    )
+else:
+    raise ImportError(
+        unwrap(
+            """
+            JobbergateApplicationBase has been moved to
+            'jobbergate_cli.subapps.applications.application_base.JobbergateApplicationBase'
+            """
+        )
+    )
diff --git a/jobbergate-cli/jobbergate_cli/auth.py b/jobbergate-cli/jobbergate_cli/auth.py
new file mode 100644
index 00000000..1c22049d
--- /dev/null
+++ b/jobbergate-cli/jobbergate_cli/auth.py
@@ -0,0 +1,312 @@
+"""
+Utilities for handling auth in jobbergate-cli.
+"""
+
+from time import sleep
+from typing import Dict, Optional, cast
+
+import pydantic
+from jose import jwt
+from jose.exceptions import ExpiredSignatureError
+from loguru import logger
+
+from jobbergate_cli.config import settings
+from jobbergate_cli.exceptions import Abort, JobbergateCliError
+from jobbergate_cli.render import terminal_message
+from jobbergate_cli.requests import make_request
+from jobbergate_cli.schemas import DeviceCodeData, IdentityData, JobbergateContext, Persona, TokenSet
+from jobbergate_cli.text_tools import unwrap
+from jobbergate_cli.time_loop import TimeLoop
+
+
+def validate_token_and_extract_identity(token_set: TokenSet) -> IdentityData:
+    """
+    Validate the access_token from a TokenSet and extract the identity data.
+
+    Validations:
+        * Checks timestamp on the access token.
+        * Checks for identity data
+        * Checks that all identity elements are present
+
+    Reports an error in the logs and to the user if there is an issue with the access_token
+    """
+    logger.debug("Validating access token")
+    try:
+        token_data = jwt.decode(
+            token_set.access_token,
+            None,
+            options=dict(
+                verify_signature=False,
+                verify_aud=False,
+                verify_exp=True,
+            ),
+        )
+    except ExpiredSignatureError:
+        raise  # Will be handled in calling context
+    except Exception as err:
+        raise Abort(
+            """
+            There was an unknown error while validating the access token.
+
+            Please try logging in again.
+            """,
+            subject="Invalid access token",
+            support=True,
+            log_message=f"Unknown error while validating access access token: {err}",
+            sentry_context=dict(access_token=dict(access_token=token_set.access_token)),
+            original_error=err,
+        )
+
+    logger.debug("Extracting identity data from the access token")
+    identity_claims = token_data.get(settings.IDENTITY_CLAIMS_KEY)
+    Abort.require_condition(
+        identity_claims,
+        "No identity data found in access token data",
+        raise_kwargs=dict(
+            subject="No identity found",
+            support=True,
+        ),
+    )
+    try:
+        return IdentityData.parse_obj(identity_claims)
+    except pydantic.ValidationError as err:
+        raise Abort(
+            """
+            The identity data in the access token is malformed or incomplete.
+
+            Please try logging in again.
+            """,
+            subject="Invalid identity data",
+            support=True,
+            log_message=f"Identity data is incomplete: {err}",
+            sentry_context=dict(access_token=dict(access_token=token_set.access_token)),
+        )
+
+
+def load_tokens_from_cache() -> TokenSet:
+    """
+    Loads an access token (and a refresh token if one exists) from the cache.
+    """
+
+    # Make static type checkers happy
+    assert settings.JOBBERGATE_API_ACCESS_TOKEN_PATH is not None
+    assert settings.JOBBERGATE_API_REFRESH_TOKEN_PATH is not None
+
+    Abort.require_condition(
+        settings.JOBBERGATE_API_ACCESS_TOKEN_PATH.exists(),
+        "Please login with your auth token first using the `jobbergate login` command",
+        raise_kwargs=dict(subject="You need to login"),
+    )
+
+    logger.debug("Retrieving access token from cache")
+    token_set = TokenSet(access_token=settings.JOBBERGATE_API_ACCESS_TOKEN_PATH.read_text())
+
+    if settings.JOBBERGATE_API_REFRESH_TOKEN_PATH.exists():
+        logger.debug("Retrieving refresh token from cache")
+        token_set.refresh_token = settings.JOBBERGATE_API_REFRESH_TOKEN_PATH.read_text()
+
+    return token_set
+
+
+def save_tokens_to_cache(token_set: TokenSet):
+    """
+    Saves tokens from a token_set to the cache.
+    """
+
+    # Make static type checkers happy
+    assert settings.JOBBERGATE_API_ACCESS_TOKEN_PATH is not None
+    assert settings.JOBBERGATE_API_REFRESH_TOKEN_PATH is not None
+
+    logger.debug(f"Caching access token at {settings.JOBBERGATE_API_ACCESS_TOKEN_PATH}")
+    settings.JOBBERGATE_API_ACCESS_TOKEN_PATH.write_text(token_set.access_token)
+
+    if token_set.refresh_token is not None:
+        logger.debug(f"Caching refresh token at {settings.JOBBERGATE_API_REFRESH_TOKEN_PATH}")
+        settings.JOBBERGATE_API_REFRESH_TOKEN_PATH.write_text(token_set.refresh_token)
+
+
+def clear_token_cache():
+    """
+    Clears the token cache.
+    """
+    logger.debug("Clearing cached tokens")
+
+    logger.debug(f"Removing access token at {settings.JOBBERGATE_API_ACCESS_TOKEN_PATH}")
+    if settings.JOBBERGATE_API_ACCESS_TOKEN_PATH.exists():
+        settings.JOBBERGATE_API_ACCESS_TOKEN_PATH.unlink()
+
+    logger.debug(f"Removing refresh token at {settings.JOBBERGATE_API_REFRESH_TOKEN_PATH}")
+    if settings.JOBBERGATE_API_REFRESH_TOKEN_PATH.exists():
+        settings.JOBBERGATE_API_REFRESH_TOKEN_PATH.unlink()
+
+
+def init_persona(ctx: JobbergateContext, token_set: Optional[TokenSet] = None):
+    """
+    Initializes the "persona" which contains the tokens and identity data for a user.
+
+    Retrieves the access token for the user from the cache.
+
+    Token is retrieved from the cache, validated, and identity data is extracted.
+
+    If the access token is expired, a new one will be acquired via the cached refresh token (if there is one).
+
+    Saves token_set to cache.
+
+    Returns the persona.
+    """
+    if token_set is None:
+        token_set = load_tokens_from_cache()
+
+    try:
+        identity_data = validate_token_and_extract_identity(token_set)
+    except ExpiredSignatureError:
+        Abort.require_condition(
+            token_set.refresh_token is not None,
+            "The auth token is expired. Please retrieve a new and log in again.",
+            raise_kwargs=dict(
+                subject="Expired access token",
+                support=True,
+            ),
+        )
+
+        logger.debug("The access token is expired. Attempting to refresh token")
+        refresh_access_token(ctx, token_set)
+        identity_data = validate_token_and_extract_identity(token_set)
+
+    logger.debug(f"Persona created with identity data: {identity_data}")
+
+    save_tokens_to_cache(token_set)
+
+    return Persona(
+        token_set=token_set,
+        identity_data=identity_data,
+    )
+
+
+def refresh_access_token(ctx: JobbergateContext, token_set: TokenSet):
+    """
+    Attempt to fetch a new access token given a refresh token in a token_set.
+
+    Sets the access token in-place.
+
+    If refresh fails, notify the user that they need to log in again.
+    """
+    url = f"https://{settings.AUTH0_DOMAIN}/oauth/token"
+    logger.debug(f"Requesting refreshed access token from {url}")
+
+    JobbergateCliError.require_condition(
+        ctx.client is not None,
+        "Attempted to refresh with a null client. This should not happen",
+    )
+
+    # Make static type-checkers happy
+    assert ctx.client is not None
+
+    refreshed_token_set = cast(
+        TokenSet,
+        make_request(
+            ctx.client,
+            "/oauth/token",
+            "POST",
+            abort_message="The auth token could not be refreshed. Please try logging in again.",
+            abort_subject="EXPIRED ACCESS TOKEN",
+            support=True,
+            response_model=TokenSet,
+            data=dict(
+                client_id=settings.AUTH0_CLIENT_ID,
+                audience=settings.AUTH0_AUDIENCE,
+                grant_type="refresh_token",
+                refresh_token=token_set.refresh_token,
+            ),
+        ),
+    )
+
+    token_set.access_token = refreshed_token_set.access_token
+
+
+def fetch_auth_tokens(ctx: JobbergateContext) -> TokenSet:
+    """
+    Fetch an access token (and possibly a refresh token) from Auth0.
+
+    Prints out a URL for the user to use to authenticate and polls the token endpoint to fetch it when
+    the browser-based process finishes
+    """
+    # Make static type-checkers happy
+    assert ctx.client is not None
+
+    device_code_data = cast(
+        DeviceCodeData,
+        make_request(
+            ctx.client,
+            "/oauth/device/code",
+            "POST",
+            expected_status=200,
+            abort_message="There was a problem retrieving a device verification code from the auth provider",
+            abort_subject="COULD NOT RETRIEVE TOKEN",
+            support=True,
+            response_model=DeviceCodeData,
+            data=dict(
+                client_id=settings.AUTH0_CLIENT_ID,
+                audience=settings.AUTH0_AUDIENCE,
+                scope="offline_access",  # To get refresh token
+            ),
+        ),
+    )
+
+    terminal_message(
+        f"""
+        To complete login, please open the following link in a browser:
+
+          {device_code_data.verification_uri_complete}
+
+        Waiting up to {settings.AUTH0_MAX_POLL_TIME / 60} minutes for you to complete the process...
+        """,
+        subject="Waiting for login",
+    )
+
+    for tick in TimeLoop(
+        settings.AUTH0_MAX_POLL_TIME,
+        message="Waiting for web login",
+    ):
+
+        response_data = cast(
+            Dict,
+            make_request(
+                ctx.client,
+                "/oauth/token",
+                "POST",
+                abort_message="There was a problem retrieving a device verification code from the auth provider",
+                abort_subject="COULD NOT FETCH ACCESS TOKEN",
+                support=True,
+                data=dict(
+                    grant_type="urn:ietf:params:oauth:grant-type:device_code",
+                    device_code=device_code_data.device_code,
+                    client_id=settings.AUTH0_CLIENT_ID,
+                ),
+            ),
+        )
+        if "error" in response_data:
+            if response_data["error"] == "authorization_pending":
+                logger.debug(f"Token fetch attempt #{tick.counter} failed")
+                sleep(device_code_data.interval)
+            else:
+                # TODO: Test this failure condition
+                raise Abort(
+                    unwrap(
+                        """
+                        There was a problem retrieving a device verification code from the auth provider:
+                        Unexpected failure retrieving access token.
+                        """
+                    ),
+                    subject="Unexpected error",
+                    support=True,
+                    log_message=f"Unexpected error response: {response_data}",
+                )
+        else:
+            return TokenSet(**response_data)
+
+    raise Abort(
+        "Login process was not completed in time. Please try again.",
+        subject="Timed out",
+        log_message="Timed out while waiting for user to complete login",
+    )
diff --git a/jobbergate-cli/jobbergate_cli/client.py b/jobbergate-cli/jobbergate_cli/client.py
deleted file mode 100644
index 12904d52..00000000
--- a/jobbergate-cli/jobbergate_cli/client.py
+++ /dev/null
@@ -1,53 +0,0 @@
-"""
-For troubleshooting and QA, turn on tracing of HTTP traffic through requests
-"""
-from http.client import HTTPConnection
-import logging
-
-# import locally so we can patch them when tracing
-from requests import Session, delete, get, post, put
-
-from jobbergate_cli import constants
-
-
-(
-    get,
-    post,
-    put,
-    delete,
-)  # importable from here, we may patch them out if debugging is turned on
-
-
-urllib3_logger = logging.getLogger("requests.packages.urllib3")
-
-
-def debug_body_printer(max_bytes):
-    """
-    -> function which can print the response body, for debugging, as a hook
-    """
-
-    def debug_body_print(response, *a, **kw):
-        data = response.content[:max_bytes]
-        urllib3_logger.debug(data)
-
-    return debug_body_print
-
-
-def debug_requests_on(max_bytes=constants.DEFAULT_MAX_BYTES_DEBUG):
-    """Switches on logging of the requests module.
-
-    Response body will be printed as well, up to max_bytes
-    """
-    global get, post, put, delete
-
-    HTTPConnection.debuglevel = 1
-    sesh = Session()
-    sesh.hooks["response"] = [debug_body_printer(max_bytes)]
-    get = sesh.get
-    post = sesh.post
-    put = sesh.put
-    delete = sesh.delete
-
-    logging.basicConfig(level=logging.DEBUG)
-    urllib3_logger.setLevel(logging.DEBUG)
-    urllib3_logger.propagate = True
diff --git a/jobbergate-cli/jobbergate_cli/compat.py b/jobbergate-cli/jobbergate_cli/compat.py
new file mode 100644
index 00000000..ed664e6e
--- /dev/null
+++ b/jobbergate-cli/jobbergate_cli/compat.py
@@ -0,0 +1,90 @@
+"""
+Provide compatibility to the previous version of Jobbergate CLI for users who have automation or are
+familiar with the old commands
+"""
+
+import typer
+
+from jobbergate_cli.subapps.applications.app import create as create_application
+from jobbergate_cli.subapps.applications.app import delete as delete_application
+from jobbergate_cli.subapps.applications.app import get_one as get_application
+from jobbergate_cli.subapps.applications.app import list_all as list_applications
+from jobbergate_cli.subapps.applications.app import update as update_application
+from jobbergate_cli.subapps.job_scripts.app import create as create_job_script
+from jobbergate_cli.subapps.job_scripts.app import delete as delete_job_script
+from jobbergate_cli.subapps.job_scripts.app import get_one as get_job_script
+from jobbergate_cli.subapps.job_scripts.app import list_all as list_job_scripts
+from jobbergate_cli.subapps.job_scripts.app import update as update_job_script
+from jobbergate_cli.subapps.job_submissions.app import create as create_job_submission
+from jobbergate_cli.subapps.job_submissions.app import delete as delete_job_submission
+from jobbergate_cli.subapps.job_submissions.app import get_one as get_job_submission
+from jobbergate_cli.subapps.job_submissions.app import list_all as list_job_submissions
+
+
+def add_legacy_compatible_commands(app: typer.Typer):
+    """
+    Add commands from the restructured CLI under the previous names for the commands
+    to the root ``typer`` app.
+    """
+
+    # Applications
+    app.command(
+        name="list-applications",
+        help="LIST the available applications",
+    )(list_applications)
+    app.command(
+        name="get-application",
+        help="GET an Application.",
+    )(get_application)
+    app.command(
+        name="create-application",
+        help="CREATE an Application.",
+    )(create_application)
+    app.command(
+        name="delete-application",
+        help="DELETE an Application.",
+    )(delete_application)
+    app.command(
+        name="update-application",
+        help="UPDATE an Application.",
+    )(update_application)
+
+    # Job Scripts
+    app.command(
+        name="list-job-scripts",
+        help="LIST job scripts",
+    )(list_job_scripts)
+    app.command(
+        name="get-job-script",
+        help="GET a job script",
+    )(get_job_script)
+    app.command(
+        name="create-job-script",
+        help="CREATE a job script",
+    )(create_job_script)
+    app.command(
+        name="update-job-script",
+        help="UPDATE a job script",
+    )(update_job_script)
+    app.command(
+        name="delete-job-script",
+        help="DELETE a job script",
+    )(delete_job_script)
+
+    # Job Submissions
+    app.command(
+        name="list-job-submissions",
+        help="LIST job submissions",
+    )(list_job_submissions)
+    app.command(
+        name="get-job-submission",
+        help="GET a job submission",
+    )(get_job_submission)
+    app.command(
+        name="create-job-submission",
+        help="CREATE a job submission",
+    )(create_job_submission)
+    app.command(
+        name="delete-job-submission",
+        help="DELETE a job submission",
+    )(delete_job_submission)
diff --git a/jobbergate-cli/jobbergate_cli/config.py b/jobbergate-cli/jobbergate_cli/config.py
index ee7574a2..f68ab425 100644
--- a/jobbergate-cli/jobbergate_cli/config.py
+++ b/jobbergate-cli/jobbergate_cli/config.py
@@ -6,15 +6,14 @@
 from typing import Optional
 
 from pydantic import AnyHttpUrl, BaseSettings, Field, root_validator
-import urllib3
 
 from jobbergate_cli import constants
 
 
-urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
-
-
 class Settings(BaseSettings):
+    """
+    Provide a ``pydantic`` settings model to hold configuration values loaded from the environment.
+    """
 
     JOBBERGATE_CACHE_DIR: Path = Field(Path.home() / ".local/share/jobbergate")
     JOBBERGATE_API_ENDPOINT: AnyHttpUrl = Field("https://jobbergateapi2-staging.omnivector.solutions")
@@ -24,6 +23,7 @@ class Settings(BaseSettings):
 
     SBATCH_PATH: Path = Field("/usr/bin/sbatch")
     SENTRY_DSN: Optional[str]
+    SENTRY_TRACE_RATE: float = Field(1.0, gt=0.0, le=1.0)
 
     # Settings for log uploads
     JOBBERGATE_AWS_ACCESS_KEY_ID: Optional[str]
@@ -38,6 +38,9 @@ class Settings(BaseSettings):
     JOBBERGATE_API_ACCESS_TOKEN_PATH: Optional[Path]
     JOBBERGATE_API_REFRESH_TOKEN_PATH: Optional[Path]
 
+    # Compatibility mode: If True, add commands as they appear in the legacy app
+    JOBBERGATE_COMPATIBILITY_MODE: Optional[bool] = False
+
     # Auth0 config for machine-to-machine security
     AUTH0_DOMAIN: str
     AUTH0_LOGIN_DOMAIN: Optional[str]
@@ -50,15 +53,14 @@ class Settings(BaseSettings):
 
     @root_validator
     def compute_extra_settings(cls, values):
+        """
+        Compute settings values that are based on other settings values.
+        """
         cache_dir = values["JOBBERGATE_CACHE_DIR"]
         cache_dir.mkdir(exist_ok=True, parents=True)
 
-        values["JOBBERGATE_APPLICATION_MODULE_PATH"] = (
-            cache_dir / constants.JOBBERGATE_APPLICATION_MODULE_FILE_NAME
-        )
-        values["JOBBERGATE_APPLICATION_CONFIG_PATH"] = (
-            cache_dir / constants.JOBBERGATE_APPLICATION_CONFIG_FILE_NAME
-        )
+        values["JOBBERGATE_APPLICATION_MODULE_PATH"] = cache_dir / constants.JOBBERGATE_APPLICATION_MODULE_FILE_NAME
+        values["JOBBERGATE_APPLICATION_CONFIG_PATH"] = cache_dir / constants.JOBBERGATE_APPLICATION_CONFIG_FILE_NAME
 
         log_dir = cache_dir / "logs"
         log_dir.mkdir(exist_ok=True, parents=True)
@@ -75,10 +77,15 @@ def compute_extra_settings(cls, values):
         return values
 
     class Config:
+        """
+        Customize behavior of the Settings class. Especially, enable the use of dotenv to load settings from a ``.env``
+        file instead of the environment.
+        """
+
         if constants.JOBBERGATE_DEFAULT_DOTENV_PATH.is_file():
             env_file = constants.JOBBERGATE_DEFAULT_DOTENV_PATH
         else:
-            env_file = ".env"
+            env_file = Path(".env")
 
 
 settings = Settings()
diff --git a/jobbergate-cli/jobbergate_cli/constants.py b/jobbergate-cli/jobbergate_cli/constants.py
index 26621bc7..7e7f6261 100644
--- a/jobbergate-cli/jobbergate_cli/constants.py
+++ b/jobbergate-cli/jobbergate_cli/constants.py
@@ -1,6 +1,8 @@
 """
-Constants used throughout the tool
+Provide constants that may be used throughout the CLI modules.
 """
+
+from enum import Enum
 from pathlib import Path
 
 
@@ -30,6 +32,14 @@
 JOBBERGATE_APPLICATION_CONFIG_FILE_NAME = "jobbergate.yaml"
 TAR_NAME = "jobbergate.tar.gz"
 
-DEFAULT_MAX_BYTES_DEBUG = 1000
-
 OV_CONTACT = "Omnivector Solutions <info@omnivector.solutions>"
+
+
+class SortOrder(str, Enum):
+    """
+    Enum descring the type of sort orders that are available for list commands.
+    """
+
+    ASCENDING = "ASCENDING"
+    DESCENDING = "DESCENDING"
+    UNSORTED = "UNSORTED"
diff --git a/jobbergate-cli/jobbergate_cli/exceptions.py b/jobbergate-cli/jobbergate_cli/exceptions.py
new file mode 100644
index 00000000..01b7ff85
--- /dev/null
+++ b/jobbergate-cli/jobbergate_cli/exceptions.py
@@ -0,0 +1,107 @@
+"""
+Provide exceptions and custom handlers for the CLI.
+"""
+
+from functools import wraps
+
+import buzz
+import sentry_sdk
+import typer
+from loguru import logger
+from rich import traceback
+from rich.console import Console
+from rich.panel import Panel
+
+from jobbergate_cli.config import settings
+from jobbergate_cli.constants import OV_CONTACT
+from jobbergate_cli.text_tools import dedent, unwrap
+
+
+# Enables prettified traceback printing via rich
+traceback.install()
+
+
+class JobbergateCliError(buzz.Buzz):
+    """
+    A generic exception base class to use in Jobbergate CLI
+    """
+
+
+class Abort(buzz.Buzz):
+    """
+    A special exception used to abort the Jobbergate CLI.
+
+    Collects information provided for use in the ``handle_abort`` context manager.
+    """
+
+    def __init__(
+        self,
+        message,
+        *args,
+        subject=None,
+        support=False,
+        log_message=None,
+        sentry_context=None,
+        original_error=None,
+        warn_only=False,
+        **kwargs,
+    ):
+        """
+        Initialize the Abort errror.
+        """
+        self.subject = subject
+        self.support = support
+        self.log_message = log_message
+        self.sentry_context = sentry_context
+        self.original_error = original_error
+        self.warn_only = warn_only
+        super().__init__(message, *args, **kwargs)
+
+
+def handle_abort(func):
+    """
+    Apply a decorator to gracefully handle any Abort errors that happen within the context.
+
+    Will log the error, dispatch it to Sentry, show a helpful message to the user about the error, and exit.
+    """
+
+    @wraps(func)
+    def wrapper(*args, **kwargs):
+        try:
+            func(*args, **kwargs)
+        except Abort as err:
+            if not err.warn_only:
+                if err.log_message is not None:
+                    logger.error(err.log_message)
+
+                if err.original_error is not None:
+                    logger.error(f"Original exception: {err.original_error}")
+
+                if settings.SENTRY_DSN:
+                    with sentry_sdk.push_scope() as scope:
+                        if err.sentry_context is not None:
+                            scope.set_context(**err.sentry_context)
+                        sentry_sdk.capture_exception(err.original_error if err.original_error is not None else err)
+                        sentry_sdk.flush()
+
+            panel_kwargs = dict()
+            if err.subject is not None:
+                panel_kwargs["title"] = f"[red]{err.subject}"
+            message = dedent(err.message)
+            if err.support:
+                support_message = unwrap(
+                    f"""
+                    [yellow]If the problem persists,
+                    please contact [bold]{OV_CONTACT}[/bold]
+                    for support and trouble-shooting
+                    """
+                )
+                message = f"{message}\n\n{support_message}"
+
+            console = Console()
+            console.print()
+            console.print(Panel(message, **panel_kwargs))
+            console.print()
+            raise typer.Exit(code=1)
+
+    return wrapper
diff --git a/jobbergate-cli/jobbergate_cli/jobberappslib.py b/jobbergate-cli/jobbergate_cli/jobberappslib.py
deleted file mode 100644
index c7340a8e..00000000
--- a/jobbergate-cli/jobbergate_cli/jobberappslib.py
+++ /dev/null
@@ -1,61 +0,0 @@
-import os
-from pathlib import Path
-import subprocess
-import sys
-
-
-def get_output_file():
-    """Returns the output file name, extracted from the command line."""
-    # TODO cleanup?
-    scriptout = "?"
-    ind = 2
-    while ind < len(sys.argv):
-        if sys.argv[ind] in ["-f", "--fast"]:
-            ind = ind + 1
-        elif sys.argv[ind] in [
-            "-a",
-            "--answerfile",
-            "-p",
-            "--prefill",
-            "-s",
-            "--saveanswers",
-            "-t",
-            "--template",
-        ]:
-            ind = ind + 2
-        else:
-            scriptout = sys.argv[ind]
-            break
-    return scriptout
-
-
-def get_running_jobs(user_only=True):
-    """Returns a list of the user's currently running jobs, as given by SLURM."""
-    cmd_args = [
-        "squeue",
-        '--format="%.8A %j"',
-        "--noheader",
-    ]  # format:[job ID, 8 chars] [job name]
-    if user_only:
-        cmd_args.append("--user=" + os.getenv("USER"))
-    try:
-        cmd_results = subprocess.run(cmd_args, stdout=subprocess.PIPE)
-        # Skip last line (empty), strip quotation marks
-        ID_alternatives = cmd_results.stdout.decode().split("\n")[:-1]
-        ID_alternatives = [j.strip('"') for j in ID_alternatives]
-    except:  # noqa: E722
-        print("Could not retrieve queue information from SLURM.")
-        return []
-    return ID_alternatives
-
-
-def get_file_list(path=None, search_term="*.*"):
-    """Returns a list of inputfiles in a directory ( default: pwd)"""
-    if not path:
-        path = Path.cwd()
-
-    files = sorted(path.glob(search_term), key=os.path.getmtime, reverse=True)
-
-    files = [x.name for x in files if x.is_file()]
-
-    return files
diff --git a/jobbergate-cli/jobbergate_cli/jobbergate_api_wrapper.py b/jobbergate-cli/jobbergate_cli/jobbergate_api_wrapper.py
deleted file mode 100644
index 10bb3104..00000000
--- a/jobbergate-cli/jobbergate_cli/jobbergate_api_wrapper.py
+++ /dev/null
@@ -1,1360 +0,0 @@
-#!/usr/bin/env python3
-import importlib
-import json
-import os
-from os import listdir
-from os.path import exists, isfile
-import pathlib
-from subprocess import PIPE, Popen
-import tarfile
-
-import inquirer
-from loguru import logger
-import requests
-import yaml
-import yarl
-
-from jobbergate_cli import appform, client, constants
-from jobbergate_cli.config import settings
-
-
-ASSISTANCE_MESSAGE = f"Please contact {constants.OV_CONTACT} for assistance if the problem persists"
-ACCESS_MESSAGE = f"Please ask your Jobbergate admin for permission or contact {constants.OV_CONTACT}"
-
-
-class JobbergateApi:
-    def __init__(
-        self,
-        token=None,
-        job_script_config=None,
-        job_submission_config=None,
-        application_config=None,
-        api_endpoint=None,
-        user_email=None,
-        full_output=False,
-    ):
-        """Initialize JobbergateAPI."""
-
-        self.token = token
-        self.job_script_config = job_script_config
-        self.job_submission_config = job_submission_config
-        self.application_config = application_config
-        self.api_endpoint = yarl.URL(api_endpoint)
-        self.user_email = user_email
-        # Suppress from list- and create- application:
-        self.application_suppress = (
-            [
-                "application_config",
-                "application_file",
-                "created_at",
-                "updated_at",
-                "application_dir_listing",
-                "application_location",
-                "application_dir_listing_acquired",
-            ]
-            if not full_output
-            else []
-        )
-        self.job_script_suppress = (
-            [
-                "created_at",
-                "updated_at",
-                "job_script_data_as_string",
-            ]
-            if not full_output
-            else []
-        )
-        self.job_submission_suppress = (
-            [
-                "created_at",
-                "updated_at",
-            ]
-            if not full_output
-            else []
-        )
-
-    def tardir(self, path, tar_name, tar_list):
-        """
-        Compress application files to a tar file.
-
-        Keyword Arguments:
-            path      -- Path provided by user to dir
-            tar_name  -- name of tar file
-            tar_list  -- list of values for root to be added to tar file
-                         this is to avoid including extraneous files in tar
-
-        """
-        archive = tarfile.open(tar_name, "w|gz")
-        for root, dirs, files in os.walk(path):
-            if root in tar_list:
-                for file in files:
-                    if "templates" in root:
-                        archive.add(os.path.join(root, file), arcname=f"/templates/{file}")
-                    else:
-                        archive.add(os.path.join(root, file), arcname=file)
-        archive.close()
-
-    def jobbergate_request(self, method, endpoint, data=None, files=None, params=None):
-        """
-        Submit HTTP requests.
-
-        Keyword Arguments:
-            method    -- HTTP request method
-            endpoint  -- API End point: application, job-script, job-submission
-            data      -- data to be submitted on POST/PUT requests
-            params    -- Query parameters for GET requests
-        """
-        if method == "GET":
-            logger.debug(f"Issuing get request to {endpoint} with {params}")
-            try:
-                response = client.get(
-                    endpoint,
-                    params=params,
-                    headers={"Authorization": "Bearer " + self.token},
-                    verify=False,
-                )
-                logger.debug(f"Received response: {response.text}")
-                if response.status_code == 200:
-                    response = response.json()
-                elif response.status_code == 403:
-                    response = self.error_handle(
-                        error=f"User is not Authorized to access {endpoint}",
-                        solution=ACCESS_MESSAGE,
-                    )
-                    return response
-                elif response.status_code == 404:
-                    response = self.error_handle(
-                        error=f"Could not find object at {endpoint}",
-                        solution="Please confirm the URL, id or the application identifier and try again",
-                    )
-                    return response
-                else:
-                    response = self.error_handle(
-                        error=f"Failed to access {endpoint}",
-                        solution="Please check credentials or report server error",
-                    )
-                    return response
-
-            except requests.exceptions.ConnectionError:
-                response = self.error_handle(
-                    error="Failed to establish connection with API",
-                    solution="Please try submitting again",
-                )
-                return response
-        if method == "PUT":
-            logger.debug(f"Issuing put request to {endpoint} with {data}")
-            try:
-                response = client.put(
-                    endpoint,
-                    json=data,
-                    headers={"Authorization": "Bearer " + self.token},
-                    verify=False,
-                )
-                logger.debug(f"Received response: {response.text}")
-                if response.status_code == 403:
-                    response = self.error_handle(
-                        error=f"User is not Authorized to access {endpoint}",
-                        solution=ACCESS_MESSAGE,
-                    )
-                    return response
-                else:
-                    response = response.json()
-            except Exception:
-                response = "PUT request failed"
-                return response
-
-        if method == "DELETE":
-            logger.debug(f"Issuing delete request to {endpoint}")
-            response = client.delete(
-                endpoint,
-                headers={"Authorization": "Bearer " + self.token},
-                params=params,
-                verify=False,
-            )
-            logger.debug(f"Received response: {response.text}")
-            if response.status_code == 403:
-                response = self.error_handle(
-                    error=f"User is not Authorized to access {endpoint}",
-                    solution=ACCESS_MESSAGE,
-                )
-                return response
-            elif response.status_code == 404:
-                response = self.error_handle(
-                    error=f"Could not delete object at {endpoint}",
-                    solution="Please confirm the id and try again",
-                )
-                return response
-            elif response.status_code == 409:
-                try:
-                    body = response.json()
-                    error = body["detail"]["message"]
-                    table = body["detail"]["table"]
-                    solution = f"First delete all items from {table} that depend on the item to be deleted"
-                except Exception:
-                    error = "There was a conflict encountered when deleting"
-                    solution = ASSISTANCE_MESSAGE
-                response = self.error_handle(error=error, solution=solution)
-                return response
-            else:
-                response = response.text
-
-        if method == "POST":
-            logger.debug(f"Issuing post request to {endpoint} with {data}")
-            full_response = client.post(
-                endpoint,
-                json=data,
-                headers={"Authorization": "Bearer " + self.token},
-                verify=False,
-            )
-            logger.debug(f"Received response: {full_response.text}")
-            if full_response.status_code == 400:
-                response = self.error_handle(
-                    error=f"Error with data uploaded: {full_response.text}",
-                    solution=ASSISTANCE_MESSAGE,
-                )
-                return response
-            elif full_response.status_code == 500:
-                error = full_response.text
-                start_point = error.find("Exception Type:")
-                # shorter error resp:
-                end_point = error.find("GET:")
-                # Longer error resp:
-                # end_point = error.find("COOKIES")
-                response = self.error_handle(
-                    error=f"Server Error generated: {error[start_point:end_point]}",
-                    solution=ASSISTANCE_MESSAGE,
-                )
-            elif full_response.status_code == 403:
-                response = self.error_handle(
-                    error=f"User is not Authorized to access {endpoint}",
-                    solution=ACCESS_MESSAGE,
-                )
-                return response
-
-            elif full_response.status_code in [200, 201]:
-                response = full_response.json()
-
-            else:
-                response = self.error_handle(
-                    error=f"Unhandled response code from server: {full_response.status_code}",
-                    solution=ASSISTANCE_MESSAGE,
-                )
-
-        return response
-
-    def jobbergate_upload(self, endpoint, files):
-        """
-        Upload a file to an HTTP endpoint.
-
-        Keyword Arguments:
-            endpoint  -- API End point
-            files     -- file(s) to be sent with request where applicable
-        """
-        full_response = client.post(
-            endpoint,
-            files=files,
-            headers={"Authorization": "Bearer " + self.token},
-            verify=False,
-        )
-        if full_response.status_code == 400:
-            response = self.error_handle(
-                error=f"Error with data uploaded: {full_response.text}",
-                solution=ASSISTANCE_MESSAGE,
-            )
-            return response
-        elif full_response.status_code == 500:
-            error = full_response.text
-            start_point = error.find("Exception Type:")
-            # shorter error resp:
-            end_point = error.find("GET:")
-            # Longer error resp:
-            # end_point = error.find("COOKIES")
-            return self.error_handle(
-                error=f"Server Error generated: {error[start_point:end_point]}",
-                solution=ASSISTANCE_MESSAGE,
-            )
-        elif full_response.status_code == 403:
-            return self.error_handle(
-                error=f"User is not Authorized to access {endpoint}",
-                solution=ACCESS_MESSAGE,
-            )
-
-        elif full_response.status_code == 201:
-            return full_response.json()
-
-        else:
-            return self.error_handle(
-                error=f"Unhandled response code from server: {full_response.status_code}",
-                solution=ASSISTANCE_MESSAGE,
-            )
-
-    def jobbergate_run(self, filename, *argv):
-        """Execute Job Submission."""
-        cmd = [settings.SBATCH_PATH, filename]
-        for arg in argv:
-            cmd.append(arg)
-        p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
-        output, err = p.communicate(b"sbatch output")
-
-        rc = p.returncode
-
-        return output.decode("utf-8"), err.decode("utf-8"), rc
-
-    def import_jobbergate_application_module(self):
-        """Import jobbergate.py for generating questions."""
-        spec = importlib.util.spec_from_file_location(
-            "JobbergateApplication", settings.JOBBERGATE_APPLICATION_MODULE_PATH
-        )
-        module = importlib.util.module_from_spec(spec)
-        spec.loader.exec_module(module)
-        return module
-
-    def assemble_questions(self, question, ignore=None):
-        """
-        Assemble questions from jobbergate.py.
-
-        Keyword Arguments:
-            question  -- question object passed in from jobbergate.py.
-                         function returns the appropriate question from
-                         inquirer
-        """
-
-        if isinstance(question, appform.Text):
-            return inquirer.Text(
-                question.variablename,
-                message=question.message,
-                default=question.default,
-                ignore=ignore,
-            )
-
-        if isinstance(question, appform.Integer):
-            return inquirer.Text(
-                question.variablename,
-                message=question.message,
-                default=question.default,
-                validate=question.validate,
-                ignore=ignore,
-            )
-
-        if isinstance(question, appform.List):
-            return inquirer.List(
-                question.variablename,
-                message=question.message,
-                choices=question.choices,
-                default=question.default,
-                ignore=ignore,
-            )
-
-        if isinstance(question, appform.Directory):
-            return inquirer.Path(
-                question.variablename,
-                message=question.message,
-                path_type=inquirer.Path.DIRECTORY,
-                default=question.default,
-                exists=question.exists,
-                ignore=ignore,
-            )
-
-        if isinstance(question, appform.File):
-            return inquirer.Path(
-                question.variablename,
-                message=question.message,
-                path_type=inquirer.Path.FILE,
-                default=question.default,
-                exists=question.exists,
-                ignore=ignore,
-            )
-
-        if isinstance(question, appform.Checkbox):
-            return inquirer.Checkbox(
-                question.variablename,
-                message=question.message,
-                choices=question.choices,
-                default=question.default,
-                ignore=ignore,
-            )
-
-        if isinstance(question, appform.Confirm):
-            return inquirer.Confirm(
-                question.variablename,
-                message=question.message,
-                default=question.default,
-                ignore=ignore,
-            )
-
-        if isinstance(question, appform.BooleanList):
-            retval = [
-                inquirer.Confirm(
-                    question.variablename,
-                    message=question.message,
-                    default=question.default,
-                    ignore=ignore,
-                )
-            ]
-
-            if question.whenfalse:
-                retval.extend(
-                    [self.assemble_questions(wf, ignore=question.ignore) for wf in question.whenfalse]
-                )
-            if question.whentrue:
-                retval.extend(
-                    [self.assemble_questions(wt, ignore=question.noignore) for wt in question.whentrue]
-                )
-
-            return retval
-
-        if isinstance(question, appform.Const):
-            return inquirer.Text(
-                question.variablename,
-                message="",
-                default=question.default,
-                ignore=True,
-            )
-
-    def error_handle(self, error, solution):
-        """
-        Standardized error handling for CLI.
-
-        Keyword Arguments:
-            error     -- error generated
-            solution  -- recommended  solution specific to each error
-        """
-        response = {"error": error, "solution": solution}
-        return response
-
-    def application_error_check(self, application_path):
-        """
-        Check for errors on application Create and Update.
-
-        Confirms these are valid:
-            dir provided by user for application path
-            jobbergate.py in dir
-            jobbergate.yaml in dir
-        """
-        error_check = []
-
-        # check for required files
-        local_jobbergate_application_dir = pathlib.Path(application_path)
-        local_jobbergate_application_module = (
-            local_jobbergate_application_dir / constants.JOBBERGATE_APPLICATION_MODULE_FILE_NAME
-        )
-        local_jobbergate_application_config = (
-            local_jobbergate_application_dir / constants.JOBBERGATE_APPLICATION_CONFIG_FILE_NAME
-        )
-
-        if not local_jobbergate_application_dir.exists():
-            check = self.error_handle(
-                error="invalid application path supplied",
-                solution=(f"{application_path} is invalid, please check the parameter and try again"),
-            )
-            error_check.append(check)
-        if not local_jobbergate_application_module.exists():
-            check = self.error_handle(
-                error=(
-                    f"Could not find {constants.JOBBERGATE_APPLICATION_MODULE_FILE_NAME} "
-                    "in {application_path}"
-                ),
-                solution=(
-                    f"Please ensure {constants.JOBBERGATE_APPLICATION_MODULE_FILE_NAME} "
-                    "is in application path provided"
-                ),
-            )
-            error_check.append(check)
-        if not local_jobbergate_application_config.exists():
-            check = self.error_handle(
-                error=(
-                    f"Could not find {constants.JOBBERGATE_APPLICATION_CONFIG_FILE_NAME} "
-                    "in {application_path}"
-                ),
-                solution=(
-                    f"Please ensure {constants.JOBBERGATE_APPLICATION_CONFIG_FILE_NAME} "
-                    "is in application path provided"
-                ),
-            )
-            error_check.append(check)
-
-        return error_check
-
-    def list_job_scripts(self, all):
-        """
-        LIST Job Scripts.
-
-        Keyword Arguments:
-            all  -- optional parameter that will return all job scripts
-                    if NOT specified then only the user's job scripts
-                    will be returned
-        """
-        params = dict(all=True) if all else None
-        envelope = self.jobbergate_request(
-            method="GET",
-            endpoint=self.api_endpoint / "job-scripts",
-            params=params,
-        )
-        results = envelope["results"]
-
-        try:
-            return [{k: v for k, v in d.items() if k not in self.job_script_suppress} for d in results]
-        except Exception:
-            return results
-
-    def create_job_script(
-        self,
-        job_script_name,
-        application_id,
-        application_identifier,
-        param_file,
-        sbatch_params,
-        fast,
-        no_submit,
-        debug,
-    ):
-        """
-        CREATE a Job Script.
-
-        Keyword Arguments:
-            name                    --  Name for job script
-            application-id          --  id of the application for the job script
-            application-identifier  --  identifier of the application for the job script
-            param-file              --  optional parameter file for populating templates.
-                                        if this is not provided, the question askin in
-                                        jobbergate.py is triggered
-            sbatch-params           --  optional parameter to submit raw sbatch parameters
-            fast                    --  optional parameter to use default answers (when available)
-                                        instead of asking user
-            no-submit               --  optional parameter to not even ask about submitting job
-            debug                   --  optional parameter to view job script data
-                                        in CLI output
-        """
-        parameter_check = []
-        if application_id and application_identifier:
-            response = self.error_handle(
-                error="Both identifier and id supplied",
-                solution="Please try again with only one",
-            )
-            parameter_check.append(response)
-
-        if not application_id and not application_identifier:
-            response = self.error_handle(
-                error="--application-id and --aplication-identifier for the job script not defined",
-                solution="Please try again with one of them specified",
-            )
-            parameter_check.append(response)
-
-        if job_script_name is None:
-            response = self.error_handle(
-                error="--name for the job script not defined",
-                solution="Please try again with --name specified",
-            )
-            parameter_check.append(response)
-
-        if len(parameter_check) > 0:
-            response = parameter_check
-            return response
-
-        self.validation_check = {}
-        data = self.job_script_config
-        data["job_script_name"] = job_script_name
-        app_data = None
-
-        if application_identifier:
-            app_data = self.jobbergate_request(
-                method="GET",
-                endpoint=self.api_endpoint / f"applications?identifier={application_identifier}",
-            )
-            application_id = app_data.get("id")
-
-        data["application_id"] = application_id
-
-        if param_file:
-            is_param_file = os.path.isfile(param_file)
-            if is_param_file is False:
-                response = self.error_handle(
-                    error=f"invalid --parameter-file supplied: {param_file}",
-                    solution="Provide the full path to a valid parameter file",
-                )
-                return response
-
-            with open(param_file, "rb") as fh:
-                supplied_params = json.loads(fh.read())
-        else:
-            supplied_params = {}
-
-        if not app_data:
-            app_data = self.jobbergate_request(
-                method="GET",
-                endpoint=self.api_endpoint / f"applications/{application_id}",
-            )
-        if "error" in app_data.keys():
-            return app_data
-
-        # Get the jobbergate application python module
-        settings.JOBBERGATE_APPLICATION_MODULE_PATH.write_text(app_data["application_file"])
-        # Get the jobbergate application yaml config
-        settings.JOBBERGATE_APPLICATION_CONFIG_PATH.write_text(app_data["application_config"])
-
-        # Load the jobbergate yaml
-        config = settings.JOBBERGATE_APPLICATION_CONFIG_PATH.read_text()
-
-        try:
-            param_dict = yaml.load(config, Loader=yaml.FullLoader)
-        except:  # noqa
-            response = self.error_handle(
-                error="Could not load application's yaml file",
-                solution="Please review yaml file for formatting.",
-            )
-            return response
-
-        # Exec the jobbergate application python module
-        module = self.import_jobbergate_application_module()
-        application = module.JobbergateApplication(param_dict)
-
-        # Add all parameters from parameter file
-        param_dict["jobbergate_config"].update(supplied_params)
-
-        # Begin question assembly, starting in "mainflow" method
-        param_dict["jobbergate_config"]["nextworkflow"] = "mainflow"
-
-        while "nextworkflow" in param_dict["jobbergate_config"]:
-            method_to_call = getattr(
-                application, param_dict["jobbergate_config"].pop("nextworkflow")
-            )  # Use and remove from the dict
-
-            try:
-                workflow_questions = method_to_call(data=param_dict["jobbergate_config"])
-            except NotImplementedError:
-                response = self.error_handle(
-                    error="Abstract method not implemented",
-                    solution=f"Please implement {method_to_call.__name__} in your class.",
-                )
-                return response
-
-            questions = []
-            auto_answers = {}
-
-            while workflow_questions:
-                field = workflow_questions.pop(0)
-                # Use pre-defined answer or ask user
-                if field.variablename in supplied_params.keys():
-                    pass  # No further action needed, case kept here to visualize priority
-                elif fast and field.default is not None:
-                    print(f"Default value used: {field.variablename}={field.default}")
-                    auto_answers[field.variablename] = field.default
-                else:
-                    # Prepare question for user
-                    question = self.assemble_questions(field)
-                    if isinstance(question, list):
-                        questions.extend(question)
-                    else:
-                        questions.append(question)
-
-            workflow_answers = inquirer.prompt(questions, raise_keyboard_interrupt=True)
-            workflow_answers.update(auto_answers)
-            param_dict["jobbergate_config"].update(workflow_answers)
-
-        data["param_dict"] = json.dumps(param_dict)
-
-        # Possibly overwrite script name
-        job_script_name_from_param = param_dict["jobbergate_config"].get("job_script_name")
-        if job_script_name_from_param:
-            data["job_script_name"] = job_script_name_from_param
-
-        if sbatch_params:
-            for i, param in enumerate(sbatch_params):
-                data["sbatch_params_" + str(i)] = param
-            data["sbatch_params_len"] = len(sbatch_params)
-
-        response = self.jobbergate_request(
-            method="POST",
-            endpoint=self.api_endpoint / "job-scripts",
-            data=data,
-        )
-        if "error" in response.keys():
-            return response
-
-        try:
-            rendered_dict = json.loads(response["job_script_data_as_string"])
-        except:  # noqa: E722
-            response = self.error_handle(
-                error="could not load job_script_data_as_string from response",
-                solution=f"Please review response: {response}",
-            )
-            return response
-
-        job_script_data_as_string = ""
-        for key, value in rendered_dict.items():
-            job_script_data_as_string += "\n\nNEW_FILE\n\n"
-            job_script_data_as_string += value
-
-        response["job_script_data_as_string"] = job_script_data_as_string
-
-        if debug is False:
-            del response["job_script_data_as_string"]
-
-        # Check if user wants to submit immediately
-        if no_submit:
-            submit = False
-        elif fast:
-            submit = True
-        else:
-            submit = inquirer.prompt(
-                [
-                    inquirer.Confirm(
-                        "sub",
-                        message="Would you like to submit this immediately?",
-                        default=True,
-                    )
-                ]
-            )["sub"]
-
-        # Write local copy of script and supporting files
-        submission_result = self.create_job_submission(
-            job_script_id=response["id"],
-            render_only=not submit,
-            job_submission_name=response["job_script_name"],
-        )
-        if submit:
-            response["submission_result"] = submission_result
-
-        return response
-
-    def get_job_script(self, job_script_id, as_str):
-        """
-        GET a Job Script.
-
-        Keyword Arguments:
-            job_script_id -- id of job script to be returned
-            as_str        -- return job script as str in CLI output
-        """
-        if job_script_id is None:
-            response = self.error_handle(
-                error="--id not defined",
-                solution="Please try again with --id specified",
-            )
-            return response
-
-        response = self.jobbergate_request(
-            method="GET",
-            endpoint=self.api_endpoint / f"job-scripts/{job_script_id}",
-        )
-        if "error" in response.keys():
-            return response
-
-        rendered_dict = json.loads(response["job_script_data_as_string"])
-        if as_str:
-            return rendered_dict["application.sh"]
-        else:
-            job_script_data_as_string = ""
-            for key, value in rendered_dict.items():
-                job_script_data_as_string += "\nNEW_FILE\n"
-                job_script_data_as_string += value
-
-            response["job_script_data_as_string"] = job_script_data_as_string
-
-            return response
-
-    def update_job_script(self, job_script_id, job_script_data_as_string):
-        """
-        UPDATE a Job Script.
-
-        Keyword Arguments:
-            job_script_id              -- id of job script to update
-            job_script_data_as_string  -- data to update job script with
-        """
-        if job_script_id is None:
-            response = self.error_handle(
-                error="--id not defined",
-                solution="Please try again with --id specified",
-            )
-            return response
-        if job_script_data_as_string is None:
-            response = self.error_handle(
-                error="--job-script not defined",
-                solution=f"Provide data to update ID: {job_script_id}",
-            )
-            return response
-
-        data = self.jobbergate_request(
-            method="GET",
-            endpoint=self.api_endpoint / f"job-scripts/{job_script_id}",
-        )
-        if "error" in data.keys():
-            return data
-        data["job_script_data_as_string"] = job_script_data_as_string
-        response = self.jobbergate_request(
-            method="PUT",
-            endpoint=self.api_endpoint / f"job-scripts/{job_script_id}/",
-            data=data,
-        )
-
-        return response
-
-    def delete_job_script(self, job_script_id):
-        """
-        DELETE a Job Script.
-
-        Keyword Arguments:
-            job_script_id -- id of job script to delete
-        """
-        if job_script_id is None:
-            response = self.error_handle(
-                error="--id not defined",
-                solution="Please try again with --job-script-id specified",
-            )
-            return response
-
-        response = self.jobbergate_request(
-            method="DELETE",
-            endpoint=self.api_endpoint / f"job-scripts/{job_script_id}",
-        )
-
-        if isinstance(response, dict) and "error" in response:
-            return response
-        else:
-            return f"Successfully deleted job_submission {job_script_id}"
-
-    # Job Submissions
-    def list_job_submissions(self, all):
-        """
-        LIST Job Submissions.
-
-        Keyword Arguments:
-            all  -- optional parameter that will return all job submissions
-                    if NOT specified then only the user's job submissions
-                    will be returned
-        """
-        params = dict(all=True) if all else None
-        envelope = self.jobbergate_request(
-            method="GET",
-            endpoint=self.api_endpoint / "job-submissions",
-            params=params,
-        )
-        results = envelope["results"]
-
-        try:
-            return [{k: v for k, v in d.items() if k not in self.job_submission_suppress} for d in results]
-        except Exception:
-            return results
-
-    def create_job_submission(self, job_script_id, render_only, job_submission_name=""):
-        """
-        CREATE Job Submission.
-
-        Keyword Arguments:
-            job_script_id -- id of job script to submit
-            name          -- name for job submission
-            render_only   -- create record in API and return data to CLI
-                             but DO NOT submit job
-        """
-        if job_script_id is None:
-            response = self.error_handle(
-                error="--job-script-id not defined",
-                solution="Please try again with --job-script-id specified",
-            )
-            return response
-
-        data = self.job_submission_config
-        data["job_submission_name"] = job_submission_name
-        data["job_script_id"] = job_script_id
-
-        job_script = self.jobbergate_request(
-            method="GET",
-            endpoint=self.api_endpoint / f"job-scripts/{job_script_id}",
-        )
-        if "error" in job_script.keys():
-            return job_script
-
-        application_id = job_script["application_id"]
-
-        application = self.jobbergate_request(
-            method="GET",
-            endpoint=self.api_endpoint / f"applications/{application_id}",
-        )
-        if "error" in application.keys():
-            return application
-
-        application_name = application["application_name"]
-
-        rendered_dict = json.loads(job_script["job_script_data_as_string"])
-
-        script_filename = f'{job_script["job_script_name"]}.job'
-        for key, value in rendered_dict.items():
-            filename = key if key != "application.sh" else script_filename
-            file_path = pathlib.Path.cwd() / filename
-            file_path.write_text(value)
-            # with open(filename, 'w') as write_file:
-            #     write_file.write(value)
-
-        data_json = json.dumps(data)
-
-        if render_only:
-            response = self.jobbergate_request(
-                method="POST",
-                endpoint=self.api_endpoint / "job-submissions",
-                data=data_json,
-            )
-            if "error" in response.keys():
-                return response
-        else:
-            try:
-                output, err, rc = self.jobbergate_run(script_filename, application_name)
-            except FileNotFoundError:
-                response = self.error_handle(
-                    error="Failed to execute submission",
-                    solution="Please confirm slurm sbatch is available",
-                )
-                return response
-
-            if rc == 0:
-                print(output)
-                data["slurm_job_id"] = int(output.split()[-1])
-                response = self.jobbergate_request(
-                    method="POST",
-                    endpoint=self.api_endpoint / "job-submissions",
-                    data=data,
-                )
-                if "error" in response.keys():
-                    return response
-            else:
-                response = self.error_handle(
-                    error=f"Failed to execute submission with error: {err}",
-                    solution=ASSISTANCE_MESSAGE,
-                )
-                return response
-        return response
-
-    def get_job_submission(self, job_submission_id):
-        """
-        GET a Job Submission.
-
-        Keyword Arguments:
-            job_submission_id -- id of endpoint to action
-        """
-        if job_submission_id is None:
-            response = self.error_handle(
-                error="--id not defined",
-                solution="Please try again with --id specified",
-            )
-            return response
-
-        response = self.jobbergate_request(
-            method="GET",
-            endpoint=self.api_endpoint / f"job-submissions/{job_submission_id}",
-        )
-
-        return response
-
-    def update_job_submission(self, job_submission_id):
-        """
-        UPDATE a Job Submission.
-
-        Keyword Arguments:
-            job_submission_id -- id of job submission to update
-        """
-        if job_submission_id is None:
-            response = self.error_handle(
-                error="--id not defined",
-                solution="Please try again with --id specified",
-            )
-            return response
-
-        data = self.jobbergate_request(
-            method="GET",
-            endpoint=self.api_endpoint / f"job-submissions/{job_submission_id}",
-        )
-        if "error" in data.keys():
-            return data
-        # TODO how to collect data that will updated for the job-submission
-        response = self.jobbergate_request(
-            method="PUT",
-            endpoint=self.api_endpoint / f"job-submissions/{job_submission_id}",
-        )
-        return response
-
-    def delete_job_submission(self, job_submission_id):
-        """
-        Delete a Job Submission.
-
-        Keyword Arguments:
-            job_submission_id -- id of job submission to delete
-        """
-        if job_submission_id is None:
-            response = self.error_handle(
-                error="--id not defined",
-                solution="Please try again with --id specified",
-            )
-            return response
-
-        response = self.jobbergate_request(
-            method="DELETE",
-            endpoint=self.api_endpoint / f"job-submissions/{job_submission_id}",
-        )
-
-        if isinstance(response, dict) and "error" in response:
-            return response
-        else:
-            return f"Successfully deleted job_submission {job_submission_id}"
-
-    # Applications
-    def list_applications(self, all, user):
-        """
-        LIST available applications.
-
-        Keyword Arguments:
-            all  -- optional parameter that will return all applications, even the ones
-                    without identifier
-            user -- optional parameter that will return only the applications from
-                    the user that have identifier; if both --user and --all is
-                    supplied, then every application for the user will be shown,
-                    even the ones without identifier
-        """
-        params = dict()
-        if all:
-            params["all"] = True
-        if user:
-            params["user"] = True
-        envelope = self.jobbergate_request(
-            method="GET",
-            endpoint=self.api_endpoint / "applications",
-            params=params,
-        )
-        results = envelope["results"]
-
-        try:
-            return sorted(
-                [
-                    {
-                        k: (v if k != "application_description" else _fit_line(v))
-                        for (k, v) in d.items()
-                        if k not in self.application_suppress
-                    }
-                    for d in results
-                ],
-                key=lambda app: app["id"],
-                reverse=True,
-            )
-        except Exception:
-            return results
-
-    def create_application(
-        self,
-        application_name,
-        application_identifier,
-        application_path,
-        application_desc,
-    ):
-        """
-        CREATE an application.
-
-        Keyword Arguments:
-            application_name -- Name of the application
-            application_path -- path to dir where application files are
-        """
-        parameter_check = []
-        if application_name is None:
-            response = self.error_handle(
-                error="--name not defined",
-                solution="Please try again with --name specified",
-            )
-            parameter_check.append(response)
-
-        if application_path is None:
-            response = self.error_handle(
-                error="--application-path not defined",
-                solution="Please try again with --application-path specified",
-            )
-            parameter_check.append(response)
-
-        if len(parameter_check) > 0:
-            response = parameter_check
-            return response
-
-        error_check = self.application_error_check(application_path)
-
-        if len(error_check) > 0:
-            response = error_check
-            return response
-
-        data = self.application_config
-        data["application_name"] = application_name
-
-        if application_identifier:
-            data["application_identifier"] = application_identifier
-
-        if application_desc:
-            data["application_description"] = application_desc
-
-        tar_list = [application_path, os.path.join(application_path, "templates")]
-        self.tardir(application_path, constants.TAR_NAME, tar_list)
-
-        with open(os.path.join(application_path, "jobbergate.py")) as app_file:
-            application_file = app_file.read()
-        with open(os.path.join(application_path, "jobbergate.yaml")) as config_file:
-            application_config_dict = yaml.safe_load(config_file.read())
-        templates = []
-        templates_folder = os.path.join(application_path, "templates")
-        if exists(templates_folder):
-            for template in listdir(templates_folder):
-                if isfile(os.path.join(templates_folder, template)):
-                    templates.append(os.path.join("templates", template))
-
-        application_config_dict["jobbergate_config"]["template_files"] = templates
-        application_config = yaml.dump(application_config_dict)
-
-        data["application_config"] = application_config
-        data["application_file"] = application_file
-
-        create_response = self.jobbergate_request(
-            method="POST",
-            endpoint=self.api_endpoint / "applications",
-            data=data,
-        )
-        if "error" in create_response.keys():
-            return create_response
-
-        application_id = str(create_response.get("id"))
-
-        files = {"upload_file": open(constants.TAR_NAME, "rb")}
-        upload_response = self.jobbergate_upload(
-            endpoint=self.api_endpoint / "applications" / application_id / "upload",
-            files=files,
-        )
-        if upload_response is not None and "error" in upload_response.keys():
-            # This is a very hacky way to add a message. We will do handle this better in the CLI rewrite
-            create_response[" "] = ""
-            create_response["WARNING"] = "The zipped application files could not be uploaded"
-            create_response["  "] = "Try running `update-application` with the application path to re-upload."
-            create_response["   "] = ASSISTANCE_MESSAGE
-        else:
-            create_response["application_uploaded"] = True
-
-        try:
-            for key in self.application_suppress:
-                create_response.pop(key, None)
-        except AttributeError:
-            # response is str of error message
-            return create_response
-
-        os.remove(constants.TAR_NAME)
-
-        return create_response
-
-    def get_application(self, application_id, application_identifier):
-        """
-        GET an Application.
-
-        Keyword Arguments:
-            application_id         -- id of application to be returned
-            application_identifier -- id of application to be returned
-        """
-        parameter_check = []
-        if application_id and application_identifier:
-            response = self.error_handle(
-                error="both identifier and id supplied",
-                solution="Please try again with only one",
-            )
-            parameter_check.append(response)
-
-        if len(parameter_check) > 0:
-            response = parameter_check
-            return response
-        if application_id:
-            response = self.jobbergate_request(
-                method="GET",
-                endpoint=self.api_endpoint / f"applications/{application_id}",
-            )
-        else:
-            response = self.jobbergate_request(
-                method="GET",
-                endpoint=self.api_endpoint / f"applications?identifier={application_identifier}",
-            )
-
-        return response
-
-    def update_application(
-        self,
-        application_id,
-        application_identifier,
-        application_path,
-        application_name,
-        update_identifier,
-        application_desc,
-    ):
-        """
-        UPDATE an Application.
-
-        Keyword Arguments:
-            application_id            -- id application to update
-            application_identifier    -- identifier application to update
-            application_path          -- path to dir for updated application files
-            application_name          -- optional new application name
-            application_desc          -- optional new application description
-            update_identifier         -- the identifier to be set
-        """
-        parameter_check = []
-        if application_id and application_identifier:
-            response = self.error_handle(
-                error="both identifier and id supplied",
-                solution="Please try again with only one",
-            )
-            parameter_check.append(response)
-
-        if len(parameter_check) > 0:
-            response = parameter_check
-            return response
-
-        if update_identifier:
-            id_field = "application_id" if application_id else "identifier"
-            id_value = application_id if application_id else application_identifier
-            response = self.jobbergate_request(
-                method="PUT",
-                endpoint=self.api_endpoint
-                / f"application-update-identifier/?{id_field}={id_value}&new={update_identifier}",
-            )
-            return response
-
-        if application_path is None:
-            response = self.error_handle(
-                error="--application-path not defined",
-                solution="Please try again with --application-path specified",
-            )
-            return response
-
-        error_check = self.application_error_check(application_path)
-
-        if len(error_check) > 0:
-            response = error_check
-            return response
-
-        if application_id:
-            data = self.jobbergate_request(
-                method="GET",
-                endpoint=self.api_endpoint / f"applications/{application_id}",
-            )
-        else:
-            data = self.jobbergate_request(
-                method="GET",
-                endpoint=self.api_endpoint / f"applications?identifier={application_identifier}",
-            )
-
-        if "error" in data.keys():
-            return data
-
-        if application_name:
-            data["application_name"] = application_name
-
-        del data["id"]
-        del data["created_at"]
-        del data["updated_at"]
-        if application_desc:
-            data["application_description"] = application_desc
-
-        tar_list = [application_path, os.path.join(application_path, "templates")]
-        self.tardir(application_path, settings.TAR_NAME, tar_list)
-
-        with open(os.path.join(application_path, "jobbergate.py")) as app_file:
-            application_file = app_file.read()
-        with open(os.path.join(application_path, "jobbergate.yaml")) as config_file:
-            application_config_dict = yaml.safe_load(config_file.read())
-
-        templates = []
-        templates_folder = os.path.join(application_path, "templates")
-        if exists(templates_folder):
-            for template in listdir(templates_folder):
-                if isfile(os.path.join(templates_folder, template)):
-                    templates.append(os.path.join("templates", template))
-
-        application_config_dict["jobbergate_config"]["template_files"] = templates
-        application_config = yaml.dump(application_config_dict)
-
-        data["application_config"] = application_config
-        data["application_file"] = application_file
-
-        response = self.jobbergate_request(
-            method="PUT",
-            endpoint=self.api_endpoint / f"applications/{application_id}",
-            data=data,
-        )
-        if "error" in response.keys():
-            return response
-
-        files = {"upload_file": open(constants.TAR_NAME, "rb")}
-        upload_response = self.jobbergate_upload(endpoint=self.api_endpoint / "applications", files=files)
-        if "error" in upload_response.keys():
-            response = self.error_handle(
-                error=f"The zipped application files could not be uploaded: {str(response)}",
-                solution=f"Try updating the application and {ASSISTANCE_MESSAGE}.",
-            )
-            return upload_response
-
-        try:
-            for key in self.application_suppress:
-                response.pop(key, None)
-
-            os.remove(constants.TAR_NAME)
-        except AttributeError:
-            # response is str of error message
-            return response
-        return response
-
-    def delete_application(self, application_id, application_identifier):
-        """
-        DELETE an Application.
-
-        Keyword Arguments:
-            application_id         -- id of application to delete
-            application_identifier -- identifier of application to delete
-        """
-        parameter_check = []
-        if application_id and application_identifier:
-            response = self.error_handle(
-                error="both identifier and id supplied",
-                solution="Please try again with only one",
-            )
-            parameter_check.append(response)
-
-        if len(parameter_check) > 0:
-            response = parameter_check
-            return response
-
-        if application_id:
-            response = self.jobbergate_request(
-                method="DELETE",
-                endpoint=self.api_endpoint / f"applications/{application_id}",
-            )
-            if isinstance(response, dict) and "error" in response:
-                return response
-            else:
-                return f"Successfully deleted application {application_id}"
-        else:
-            response = self.jobbergate_request(
-                method="DELETE",
-                endpoint=self.api_endpoint / "applications",
-                params=dict(identifier=application_identifier),
-            )
-            if isinstance(response, dict) and "error" in response:
-                return response
-            else:
-                return f"Successfully deleted application with identifier '{application_identifier}'"
-
-
-def _fit_line(s: str, n: int = 79):
-    """
-    Smartly ellipsize a line to fit in n (default 79) characters.
-    This method ensures only one line will be displayed, and an ellipsis is only used
-    if there are several characters to be hidden.
-    """
-    assert n >= 15
-    snip = n - 8
-
-    truncate = False
-    s = s.strip()
-    if "\n" in s:
-        truncate = True
-    s = s.split("\n")[0].strip()
-    if len(s) > n:
-        truncate = True
-
-    if truncate:
-        return s[:snip] + "..."
-
-    return s
diff --git a/jobbergate-cli/jobbergate_cli/logging.py b/jobbergate-cli/jobbergate_cli/logging.py
new file mode 100644
index 00000000..90b485f8
--- /dev/null
+++ b/jobbergate-cli/jobbergate_cli/logging.py
@@ -0,0 +1,42 @@
+"""
+Provide initializers for logging.
+"""
+
+import sys
+
+import sentry_sdk
+from loguru import logger
+
+from jobbergate_cli.config import settings
+
+
+def init_logs(verbose=False):
+    """
+    Initialize logging.
+
+    If JOBBERGATE_LOG_PATH is set in the config, add a rotatating file log handler.
+    Logs will be retained for 1 week.
+
+    If verbose is supplied, add a stdout handler at the DEBUG level.
+    """
+    # Remove default stderr handler at level INFO
+    logger.remove()
+
+    if verbose:
+        logger.add(sys.stdout, level="DEBUG")
+
+    if settings.JOBBERGATE_LOG_PATH is not None:
+        logger.add(settings.JOBBERGATE_LOG_PATH, rotation="00:00", retention="1 week", level="DEBUG")
+    logger.debug("Logging initialized")
+
+
+def init_sentry():
+    """
+    Initialize Sentry if the ``SENTRY_DSN`` environment variable is present.
+    """
+    if settings.SENTRY_DSN:
+        logger.debug("Initializing sentry")
+        sentry_sdk.init(
+            dsn=settings.SENTRY_DSN,
+            traces_sample_rate=settings.SENTRY_TRACE_RATE,
+        )
diff --git a/jobbergate-cli/jobbergate_cli/main.py b/jobbergate-cli/jobbergate_cli/main.py
index 24574cd9..38afb7af 100644
--- a/jobbergate-cli/jobbergate_cli/main.py
+++ b/jobbergate-cli/jobbergate_cli/main.py
@@ -1,992 +1,175 @@
-from dataclasses import dataclass
-from datetime import datetime, timedelta
-import functools
-import json
-from pathlib import Path
-import sys
-import tarfile
-import tempfile
-import textwrap
-import time
-import traceback
-from typing import Optional
-
-import boto3
-import click
-from jose import jwt
-from jose.exceptions import ExpiredSignatureError
-from loguru import logger
-import requests
-import sentry_sdk
-from tabulate import tabulate
-
-from jobbergate_cli import client, constants
-from jobbergate_cli.config import settings
-from jobbergate_cli.jobbergate_api_wrapper import JobbergateApi
-
-
-# These are used in help text for the application commands below
-APPLICATION_ID_EXPLANATION = """
-
-    This id represents the primary key of the application in the database. It
-    will always be a unique integer and is automatically generated by the server
-    when an Application is created. All applications receive an id, so it may
-    be used to target a specific instance of an application whether or not it
-    is provided with a human-friendly "identifier".
 """
-
-
-APPLICATION_IDENTIFIER_EXPLANATION = """
-
-    The identifier allows the user to access commonly used applications with a
-    friendly name that is easy to remember. Identifiers should only be used
-    for applications that are frequently used or should be easy to find in the list.
-    An identifier may be added, removed, or changed on an existing application.
+Provide main entry point for the Jobbergate CLI App.
 """
 
+from importlib.metadata import version as package_version
+from typing import Optional
 
-@dataclass
-class TokenSet:
-    access_token: Optional[str] = None
-    refresh_token: Optional[str] = None
-
-
-def init_logs(verbose=False):
-    """
-    Initialize the rotatating file log handler. Logs will be retained for 1 week.
-    """
-    # Remove default stderr handler at level INFO
-    logger.remove()
-
-    if verbose:
-        logger.add(sys.stdout, level="DEBUG")
-
-    logger.add(settings.JOBBERGATE_LOG_PATH, rotation="00:00", retention="1 week", level="DEBUG")
-    logger.debug("Logging initialized")
-
-
-def tabulate_response(response):
-    """Print a tabulated json response"""
-    if isinstance(response, list):
-        text = tabulate((my_dict for my_dict in response), headers="keys")
-    elif isinstance(response, dict):
-        text = tabulate(response.items())
-    else:
-        text = str(response)
-    print(text)
-
-
-def raw_response(response):
-    """Print a raw, pretty-printed json response"""
-    if isinstance(response, (list, dict)):
-        text = json.dumps(response, indent=2)
-    else:
-        text = str(response)
-    print(text)
-
-
-def jobbergate_command_wrapper(func):
-    """Wraps a jobbergate command to include logging, error handling, and user output
-
-    Reports the command being called and its parameters to the user log. Includes log
-    lines about starting and finishing the command. Also reports errors to the user
-    as well as sending the error report to Sentry. Finally, prints any output provided
-    by the called command to stdout.
-    """
-
-    @functools.wraps(func)
-    def wrapper(ctx, *args, **kwargs):
-        try:
-            message = f"Handling command '{ctx.command.name}'"
-            if ctx.params:
-                message += " with params:"
-                for (key, value) in ctx.params.items():
-                    message += f"\n  {key}={value}"
-            logger.debug(message)
-
-            result = func(ctx, *args, **kwargs)
-            if result:
-                if ctx.obj["raw"]:
-                    raw_response(result)
-                else:
-                    tabulate_response(result)
-            else:
-                print("Received no data")
-
-            logger.debug(f"Finished command '{ctx.command.name}'")
-            return result
-
-        except Exception as err:
-            args_string = ", ".join(list(args) + [f"{k}={v}" for (k, v) in kwargs.items()])
-            message = textwrap.dedent(
-                f"""
-                Caught error {err}:
-                  identity: {ctx.obj["identity"]}
-                  source:   {func.__name__}({args_string})
-                  details:
+import httpx
+import pyperclip
+import typer
 
-                """
-            ).lstrip()
-            message += traceback.format_exc()
+from jobbergate_cli.auth import clear_token_cache, fetch_auth_tokens, init_persona, load_tokens_from_cache
+from jobbergate_cli.config import settings
+from jobbergate_cli.exceptions import Abort, handle_abort
+from jobbergate_cli.logging import init_logs, init_sentry
+from jobbergate_cli.render import terminal_message
+from jobbergate_cli.schemas import JobbergateContext, Persona, TokenSet
+from jobbergate_cli.text_tools import conjoin
 
-            logger.error(message)
 
-            # This allows us to capture exceptions here and still report them to sentry
-            if settings.SENTRY_DSN:
-                with sentry_sdk.push_scope() as scope:
-                    scope.set_context(
-                        "command_info",
-                        dict(
-                            org_name=ctx.obj["identity"].get("org_name"),
-                            user_email=ctx.obj["identity"]["user_email"],
-                            function=func.__name__,
-                            command=ctx.command.name,
-                            args=args,
-                            kwargs=kwargs,
-                        ),
-                    )
-                    sentry_sdk.capture_exception(err)
-                    sentry_sdk.flush()
+app = typer.Typer()
 
-            print(
-                textwrap.dedent(
-                    f"""
-                    There was an error processing command '{ctx.command.name}'.
 
-                    Please check the parameters and the command documentation.
-                    You can check the documentation at any time by adding '--help' to any command.
+# If "compatibility" mode is set through the environment, map the commands at their familiar placement on the main app.
+if settings.JOBBERGATE_COMPATIBILITY_MODE:
+    from jobbergate_cli.compat import add_legacy_compatible_commands
 
-                    If the problem persists, please contact Omnivector <info@omnivector.solutions>
-                    for support.
-                    """
-                ).strip(),
-                file=sys.stderr,
-            )
-            sys.exit(1)
+    add_legacy_compatible_commands(app)
+else:
+    from jobbergate_cli.subapps.applications.app import app as applications_app
+    from jobbergate_cli.subapps.job_scripts.app import app as job_scripts_app
+    from jobbergate_cli.subapps.job_submissions.app import app as job_submissions_app
 
-    return wrapper
+    app.add_typer(applications_app, name="applications")
+    app.add_typer(job_scripts_app, name="job-scripts")
+    app.add_typer(job_submissions_app, name="job-submissions")
 
 
-def abort_with_message(message):
-    """
-    Report an error to the user and exit the cli.
+@app.callback(invoke_without_command=True)
+@handle_abort
+def main(
+    ctx: typer.Context,
+    verbose: bool = typer.Option(False, help="Enable verbose logging to the terminal"),
+    full: bool = typer.Option(False, help="Print all fields from CRUD commands"),
+    raw: bool = typer.Option(False, help="Print output from CRUD commands as raw json"),
+    version: bool = typer.Option(False, help="Print the version of jobbergate-cli and exit"),
+):
     """
-    raise click.ClickException(textwrap.dedent(message).strip())
+    Welcome to the Jobbergate CLI!
 
-
-def validate_token_and_extract_identity(token: str) -> dict:
+    More information can be shown for each command listed below by running it with the --help option.
     """
-    Validate the token and extract the identity data.
-
-    Validations:
-        * Checkstimestamp on the auth token.
-        * Checks for identity data
-        * Checks that all identity elements are present
-
-    Reports an error in the logs and to the user if there is an issue with the token
+    if version:
+        typer.echo(package_version("jobbergate-cli"))
+        raise typer.Exit()
 
-    :param token: The JWT to use for auth on request to the API
-    :returns: The extracted identity data
-    """
-    logger.debug("Validating access token")
-    try:
-        token_data = jwt.decode(
-            token,
-            None,
-            options=dict(
-                verify_signature=False,
-                verify_aud=False,
-                verify_exp=True,
+    if ctx.invoked_subcommand is None:
+        terminal_message(
+            conjoin(
+                "No command provided. Please check the [bold magenta]usage[/bold magenta] and add a command",
+                "",
+                f"[yellow]{ctx.get_help()}[/yellow]",
             ),
+            subject="Need a jobbergate command",
         )
+        raise typer.Exit()
 
-    except ExpiredSignatureError:
-        raise  # Will be handled in calling context
-    except Exception as err:
-        logger.error(f"Unknown error while validating access token: {err}")
-        if settings.SENTRY_DSN:
-            with sentry_sdk.push_scope() as scope:
-                scope.set_context("token", dict(token=token))
-                sentry_sdk.capture_exception(err)
-                sentry_sdk.flush()
-
-        abort_with_message(
-            """
-            There was an unknown error while initializing the access token.
-
-            Please try retrieving the auth token and logging in again.
-
-            If the problem persists, please contact Omnivector <info@omnivector.solutions>
-            for support.
-            """
-        )
-
-    identity_data = token_data.get(settings.IDENTITY_CLAIMS_KEY)
-    if not identity_data:
-        abort_with_message("No identity data found in access token data")
-    if "user_email" not in identity_data:
-        abort_with_message("No user email found in access token data")
-
-    return identity_data
-
-
-def load_tokens_from_cache() -> TokenSet:
-    """
-    Loads an access token (and a refresh token if one exists) from the cache.
-
-    :returns: A TokenSet instance containing the loaded tokens.
-    """
-    token_set = TokenSet()
-
-    if not settings.JOBBERGATE_API_ACCESS_TOKEN_PATH.exists():
-        abort_with_message("Please login with your auth token first using the `jobbergate login` command")
-
-    logger.debug("Retrieving access token from cache")
-    token_set.access_token = settings.JOBBERGATE_API_ACCESS_TOKEN_PATH.read_text()
-
-    if settings.JOBBERGATE_API_REFRESH_TOKEN_PATH.exists():
-        logger.debug("Retrieving refresh token from cache")
-        token_set.refresh_token = settings.JOBBERGATE_API_REFRESH_TOKEN_PATH.read_text()
-
-    return token_set
-
-
-def save_tokens_to_cache(token_set: TokenSet):
-    """
-    Saves tokens from a token_set to the cache.
-
-    :param token_set: A TokenSet instance containing the tokens to save.
-    """
-    logger.debug(f"Caching access token at {settings.JOBBERGATE_API_ACCESS_TOKEN_PATH}")
-    settings.JOBBERGATE_API_ACCESS_TOKEN_PATH.write_text(token_set.access_token)
-
-    if token_set.refresh_token is not None:
-        logger.debug(f"Caching refresh token at {settings.JOBBERGATE_API_REFRESH_TOKEN_PATH}")
-        settings.JOBBERGATE_API_REFRESH_TOKEN_PATH.write_text(token_set.refresh_token)
-
-
-def clear_token_cache():
-    """
-    Clears the token cache.
-    """
-    logger.debug("Clearing cached tokens")
-
-    logger.debug(f"Removing access token at {settings.JOBBERGATE_API_ACCESS_TOKEN_PATH}")
-    if settings.JOBBERGATE_API_ACCESS_TOKEN_PATH.exists():
-        settings.JOBBERGATE_API_ACCESS_TOKEN_PATH.unlink()
-
-    logger.debug(f"Removing refresh token at {settings.JOBBERGATE_API_REFRESH_TOKEN_PATH}")
-    if settings.JOBBERGATE_API_REFRESH_TOKEN_PATH.exists():
-        settings.JOBBERGATE_API_REFRESH_TOKEN_PATH.unlink()
-
-
-def init_access_token(ctx_obj):
-    """
-    Retrieves the access token for the user from the cache.
-
-    Token is retrieved from the cache, validated, and identity data is bound to the context.
-
-    If the access token is expired, a new one will be acquired via the cached refresh token (if there is one).
-
-    :param ctx_obj: The click context object from the main entry point.
-    :returns: the retrieved access token.
-    """
-    token_set = load_tokens_from_cache()
-    try:
-        identity_data = validate_token_and_extract_identity(token_set.access_token)
-
-    except ExpiredSignatureError:
-        if token_set.refresh_token is None:
-            abort_with_message(
-                """
-                The auth token is expired. Please retrieve a new and log in again.
-
-                If the problem persists, please contact Omnivector <info@omnivector.solutions>
-                for support.
-                """
-            )
-
-        logger.debug("The access token is expired. Attempting to refresh token")
-        token_set.access_token = refresh_access_token(token_set.refresh_token)
-        identity_data = validate_token_and_extract_identity(token_set.access_token)
-
-    logger.debug(f"Executing with identity data: {identity_data}")
-    ctx_obj["identity"] = identity_data
-
-    save_tokens_to_cache(token_set)
-
-    return token_set.access_token
-
-
-def refresh_access_token(refresh_token: str) -> str:
-    """
-    Attempt to fetch a new access token given a refresh token.
-
-    If refresh fails, notify the user that they need to log in again.
-
-    :param refresh_token: The refresh token to be used to retrieve a new access token.
-    :returns: The fetched access token.
-    """
-    url = f"https://{settings.AUTH0_LOGIN_DOMAIN}/oauth/token"
-    logger.debug(f"Requesting refreshed access token from {url}")
-    response = requests.post(
-        url,
-        headers={"content-type": "application/x-www-form-urlencoded"},
-        data=dict(
-            client_id=settings.AUTH0_CLIENT_ID,
-            audience=settings.AUTH0_AUDIENCE,
-            grant_type="refresh_token",
-            refresh_token=refresh_token,
-        ),
-    )
-    data = response.json()
-    if response.status_code != 200:
-        logger.debug(f"Error for refresh request: {data['error_description']}")
-        abort_with_message(
-            """
-            The auth token could not be refreshed. Please try logging in again.
-
-            If the problem persists, please contact Omnivector <info@omnivector.solutions>
-            for support.
-            """
-        )
-    access_token = data["access_token"]
-    return access_token
-
-
-def fetch_auth_tokens() -> TokenSet:
-    """
-    Fetch an access token (and possibly a refresh token) from Auth0.
-
-    Prints out a URL for the user to use to authenticate and polls the token endpoint to fetch it when
-    the browser-based process finishes
+    init_logs(verbose=verbose)
+    init_sentry()
+    persona = None
 
-    :returns: A TokenSet object carrying the fetched tokens.
-    """
-    url = f"https://{settings.AUTH0_LOGIN_DOMAIN}/oauth/device/code"
-    response = requests.post(
-        url,
+    client = httpx.Client(
+        base_url=f"https://{settings.AUTH0_LOGIN_DOMAIN}",
         headers={"content-type": "application/x-www-form-urlencoded"},
-        data=dict(
-            client_id=settings.AUTH0_CLIENT_ID,
-            audience=settings.AUTH0_AUDIENCE,
-            scope="offline_access",  # To get refresh token
-        ),
     )
+    context = JobbergateContext(persona=None, client=client)
 
-    try:
-        data = response.json()
-    except Exception:
-        abort_with_message("Failed unpacking response for verification code")
-
-    data = response.json()
-    logger.debug(f"Response for device code request: {data}")
-    if response.status_code != 200:
-        error_message = data.get("error", "unknown error")
-        abort_with_message(f"Could not authenticate: {error_message}")
-
-    device_code = data["device_code"]
-    verification_uri_complete = data["verification_uri_complete"]
-    sleep_interval = data["interval"]
-
-    print()
-    print("To complete login, please open the following link in a browser:")
-    print()
-    print(f"  {verification_uri_complete}")
-    print()
-    print(f"Waiting up to {settings.AUTH0_MAX_POLL_TIME / 60} minutes for you to complete the process...")
-
-    time_limit = timedelta(seconds=settings.AUTH0_MAX_POLL_TIME)
-    start_time = datetime.now()
-    attempt_count = 0
-    while start_time + time_limit > datetime.now():
-        attempt_count += 1
-        token_url = f"https://{settings.AUTH0_LOGIN_DOMAIN}/oauth/token"
-        token_response = requests.post(
-            token_url,
-            headers={"content-type": "application/x-www-form-urlencoded"},
-            data=dict(
-                grant_type="urn:ietf:params:oauth:grant-type:device_code",
-                device_code=device_code,
-                client_id=settings.AUTH0_CLIENT_ID,
-            ),
-        )
-
-        try:
-            data = token_response.json()
-        except Exception:
-            abort_with_message("Failed unpacking response for tokens")
-
-        logger.debug(f"Response for device token request: {data}")
-        if token_response.status_code != 200:
-            error_message = data.get("error", "unknown error")
-            logger.debug(f"Token fetch attempt #{attempt_count} failed: {error_message}")
-            time.sleep(sleep_interval)
-            continue
-
-        token_set = TokenSet(
-            access_token=data["access_token"],
-            refresh_token=data.get("refresh_token"),
-        )
-        return token_set
-
-    abort_with_message(
-        """
-        Timed out while waiting for user to complete login. Please try again.
-
-        If the problem persists, please contact Omnivector <info@omnivector.solutions>
-        for support.
-        """
-    )
-
-
-def init_sentry():
-    """Initialize Sentry."""
-    logger.debug("Initializing sentry")
-    sentry_sdk.init(
-        dsn=settings.SENTRY_DSN,
-        traces_sample_rate=1.0,
-    )
-
-
-# Get the cli input arguments
-@click.group(
-    help="""
-        Jobbergate CLI.
-
-        Provides a command-line interface to the Jobbergate API. Available commands are
-        listed below. Each command may be invoked with --help to see more details and
-        available parameters.
-
-        Before you use any commands besides ``login`` or ``logout``, you must first log in
-        by invoking the ``login`` subcommand with an auth token retrieved from the Armada UI.
-        Once you login, the token will be cached for future commands until it expires. If the
-        token has expired, you will be notified that you need to supply a new one.
-    """,
-)
-@click.option(
-    "--verbose",
-    "-v",
-    is_flag=True,
-    help="Enable verbose logging to the terminal",
-)
-@click.option(
-    "--raw",
-    "-r",
-    is_flag=True,
-    help="Print output as raw json",
-)
-@click.option(
-    "--full",
-    "-f",
-    is_flag=True,
-    help="Print all columns. Must be used with --raw",
-)
-@click.version_option()
-@click.pass_context
-def main(ctx, verbose, raw, full):
-    ctx.ensure_object(dict)
-
-    init_logs(verbose=verbose)
-
-    if full and not raw:
-        abort_with_message("--full option must be used with --raw")
-
-    if settings.SENTRY_DSN:
-        logger.debug(f"Initializing Sentry with {settings.SENTRY_DSN}")
-        init_sentry()
+    # TODO: Figure out how to show help and exit if there are no inoked_subcommands
 
     if ctx.invoked_subcommand not in ("login", "logout"):
-        token = init_access_token(ctx.obj)
-        user_email = ctx.obj["identity"]["user_email"]
-
-        if settings.JOBBERGATE_DEBUG:
-            logger.debug("Enabling debug mode for requests")
-            client.debug_requests_on()
-
-        ctx.obj["api"] = JobbergateApi(
-            token=token,
-            job_script_config=constants.JOBBERGATE_JOB_SCRIPT_CONFIG,
-            job_submission_config=constants.JOBBERGATE_JOB_SUBMISSION_CONFIG,
-            application_config=constants.JOBBERGATE_APPLICATION_CONFIG,
-            api_endpoint=settings.JOBBERGATE_API_ENDPOINT,
-            user_email=user_email,
-            full_output=full,
+        persona = init_persona(context)
+        context.client = httpx.Client(
+            base_url=settings.JOBBERGATE_API_ENDPOINT,
+            headers=dict(Authorization=f"Bearer {persona.token_set.access_token}"),
         )
-        ctx.obj["raw"] = raw
+        context.persona = persona
+        context.full_output = full
+        context.raw_output = raw
 
+    ctx.obj = context
 
-@main.command("list-applications")
-@click.option(
-    "--all",
-    is_flag=True,
-    help="Show all applications, even the ones without identifier",
-)
-@click.option(
-    "--user",
-    is_flag=True,
-    help="Show only the applications for the current user",
-)
-@click.pass_context
-@jobbergate_command_wrapper
-def list_applications(ctx, all=False, user=False):
-    """
-    LIST the available applications.
-    """
-    api = ctx.obj["api"]
-    return api.list_applications(all, user)
 
-
-@main.command("create-application")
-@click.option("--name", "-n", help="Name of the application")
-@click.option(
-    "--identifier",
-    help=f"The human-friendly identifier of the application. {APPLICATION_IDENTIFIER_EXPLANATION}",
-)
-@click.option(
-    "--application-path",
-    "-a",
-    help="The path to the directory where application files are",
-)
-@click.option(
-    "--application-desc",
-    default="",
-    help="A helpful description of the application",
-)
-@click.pass_context
-@jobbergate_command_wrapper
-def create_application(
-    ctx,
-    name,
-    identifier,
-    application_path,
-    application_desc,
-):
+@app.command()
+@handle_abort
+def login(ctx: typer.Context):
     """
-    CREATE an application.
+    Log in to the jobbergate-cli by storing the supplied token argument in the cache.
     """
-    api = ctx.obj["api"]
-    return api.create_application(
-        application_name=name,
-        application_identifier=identifier,
-        application_path=application_path,
-        application_desc=application_desc,
+    token_set: TokenSet = fetch_auth_tokens(ctx.obj)
+    persona: Persona = init_persona(ctx.obj, token_set)
+    terminal_message(
+        f"User was logged in with email '{persona.identity_data.user_email}'",
+        subject="Logged in!",
     )
 
 
-@main.command("get-application")
-@click.option(
-    "--id",
-    "-i",
-    "id_",
-    help=f"The specific id of the application. {APPLICATION_ID_EXPLANATION}",
-)
-@click.option(
-    "--identifier",
-    help=f"The human-friendly identifier of the application. {APPLICATION_IDENTIFIER_EXPLANATION}",
-)
-@click.pass_context
-@jobbergate_command_wrapper
-def get_application(ctx, id_, identifier):
-    """
-    GET an Application.
-    """
-    api = ctx.obj["api"]
-    return api.get_application(application_id=id_, application_identifier=identifier)
-
-
-@main.command("update-application")
-@click.option(
-    "--id",
-    "-i",
-    "id_",
-    help=f"The specific id application to update. {APPLICATION_ID_EXPLANATION}",
-)
-@click.option(
-    "--identifier",
-    help=f"The human-friendly identifier of the application to update. {APPLICATION_IDENTIFIER_EXPLANATION}",
-)
-@click.option(
-    "--application-path",
-    "-a",
-    help="The path to the directory for updated application files",
-)
-@click.option("--update-identifier", help="The application identifier to be set")
-@click.option(
-    "--application-desc",
-    default="",
-    help="Optional new application description",
-)
-@click.pass_context
-@jobbergate_command_wrapper
-def update_application(
-    ctx,
-    id_,
-    identifier,
-    application_path,
-    update_identifier,
-    application_desc,
-):
-    """
-    UPDATE an Application.
-    """
-    api = ctx.obj["api"]
-    return api.update_application(id_, identifier, application_path, update_identifier, application_desc)
-
-
-@main.command("delete-application")
-@click.option(
-    "--id",
-    "-i",
-    "id_",
-    help=f"The specific id of the application to delete. {APPLICATION_ID_EXPLANATION}",
-)
-@click.option(
-    "--identifier",
-    help=f"The human-friendly identifier of the application to delete. {APPLICATION_IDENTIFIER_EXPLANATION}",
-)
-@click.pass_context
-@jobbergate_command_wrapper
-def delete_application(ctx, id_, identifier):
-    """
-    DELETE an Application.
-    """
-    api = ctx.obj["api"]
-    return api.delete_application(id_, identifier)
-
-
-@main.command("list-job-scripts")
-@click.option(
-    "--all",
-    "all_",
-    is_flag=True,
-    help="""
-        Optional parameter that will return all job scripts.
-        If NOT specified then only the user's job scripts will be returned.
-    """,
-)
-@click.pass_context
-@jobbergate_command_wrapper
-def list_job_scripts(ctx, all_=False):
-    """
-    LIST Job Scripts.
-    """
-    api = ctx.obj["api"]
-    return api.list_job_scripts(all_)
-
-
-@main.command("create-job-script")
-@click.option(
-    "--name",
-    "-n",
-    default="default_script_name",
-    help="Name for job script",
-)
-@click.option(
-    "--application-id",
-    "-i",
-    help="The id of the application for the job script",
-)
-@click.option(
-    "--application-identifier",
-    help="The identifier of the application for the job script",
-)
-@click.option(
-    "--sbatch-params",
-    multiple=True,
-    help="Optional parameter to submit raw sbatch parameters",
-)
-@click.option(
-    "--param-file",
-    type=click.Path(),
-    help="""
-        Optional parameter file for populating templates.
-        If answers are not provided, the question asking in jobbergate.py is triggered
-    """,
-)
-@click.option(
-    "--fast",
-    "-f",
-    is_flag=True,
-    help="""
-        Optional parameter to use default answers (when available)
-        instead of asking user.
-    """,
-)
-@click.option(
-    "--no-submit",
-    is_flag=True,
-    help="Optional parameter to not even ask about submitting job",
-)
-@click.option(
-    "--debug",
-    is_flag=True,
-    help="Optional parameter to view job script data in CLI output",
-)
-@click.pass_context
-@jobbergate_command_wrapper
-def create_job_script(
-    ctx,
-    name,
-    application_id,
-    application_identifier,
-    sbatch_params,
-    param_file=None,
-    fast=False,
-    no_submit=False,
-    debug=False,
-):
+@app.command()
+@handle_abort
+def logout():
     """
-    CREATE a Job Script.
+    Logs out of the jobbergate-cli. Clears the saved user credentials.
     """
-    api = ctx.obj["api"]
-    return api.create_job_script(
-        name,
-        application_id,
-        application_identifier,
-        param_file,
-        sbatch_params,
-        fast,
-        no_submit,
-        debug,
+    clear_token_cache()
+    terminal_message(
+        "User was logged out.",
+        subject="Logged out",
     )
 
 
-@main.command("get-job-script")
-@click.option(
-    "--id",
-    "-i",
-    "id_",
-    help="The id of job script to be returned",
-)
-@click.option(
-    "--as-string",
-    is_flag=True,
-)
-@click.pass_context
-@jobbergate_command_wrapper
-def get_job_script(ctx, id_, as_string):
-    """
-    GET a Job Script.
-    """
-    api = ctx.obj["api"]
-    return api.get_job_script(id_, as_string)
-
-
-@main.command("update-job-script")
-@click.option(
-    "--id",
-    "-i",
-    "id_",
-    help="The id of the job script to update",
-)
-@click.option(
-    "--job-script",
-    help="""
-        The data with which to update job script.
-
-        Format: string form of dictionary with main script as entry "application.sh"
-
-        Example: '{"application.sh":"#!/bin/bash \\n hostname"}'
-    """,
-)
-@click.pass_context
-@jobbergate_command_wrapper
-def update_job_script(ctx, id_, job_script):
-    """
-    UPDATE a Job Script.
-    """
-    api = ctx.obj["api"]
-    return api.update_job_script(id_, job_script)
-
-
-@main.command("delete-job-script")
-@click.option("--id", "-i", "id_", help="The id of job script to delete")
-@click.pass_context
-@jobbergate_command_wrapper
-def delete_job_script(ctx, id_):
-    """
-    DELETE a Job Script.
-    """
-    api = ctx.obj["api"]
-    return api.delete_job_script(id_)
-
-
-@main.command("list-job-submissions")
-@click.option(
-    "--all",
-    "all_",
-    is_flag=True,
-    help="""
-        Optional parameter that will return all job submissions.
-        If NOT specified then only the user's job submissions will be returned.
-    """,
-)
-@click.pass_context
-@jobbergate_command_wrapper
-def list_job_submissions(ctx, all_=False):
-    """
-    LIST Job Submissions.
-    """
-    api = ctx.obj["api"]
-    return api.list_job_submissions(all_)
-
-
-@main.command("create-job-submission")
-@click.option(
-    "--job-script-id",
-    "-i",
-    help="The id of the job script to submit",
-)
-@click.option(
-    "--name",
-    "-n",
-    help="The name for job submission",
-)
-@click.option(
-    "--dry-run",
-    is_flag=True,
-    help="""
-        Optional flag that will create record in API and return data to CLI but
-        WILL NOT submit job
-    """,
-)
-@click.pass_context
-@jobbergate_command_wrapper
-def create_job_submission(
-    ctx,
-    job_script_id,
-    name="",
-    dry_run=False,
+@app.command()
+@handle_abort
+def show_token(
+    plain: bool = typer.Option(
+        False,
+        help="Show the token in plain text",
+    ),
+    refresh: bool = typer.Option(
+        False,
+        help="Show the refresh token instead of the access token",
+    ),
+    show_prefix: bool = typer.Option(
+        False,
+        help="Include the 'Bearer' prefix in the output",
+    ),
 ):
     """
-    CREATE Job Submission.
-    """
-    api = ctx.obj["api"]
-    return api.create_job_submission(
-        job_script_id=job_script_id, job_submission_name=name, render_only=dry_run
-    )
-
-
-@main.command("get-job-submission")
-@click.option("--id", "-i", "id_", help="The id of the job submission to be returned")
-@click.pass_context
-@jobbergate_command_wrapper
-def get_job_submission(ctx, id_):
-    """
-    GET a Job Submission.
-    """
-    api = ctx.obj["api"]
-    return api.get_job_submission(id_)
-
-
-@main.command("update-job-submission")
-@click.option("--id", "-i", "id_", help="The id of job submission to update")
-@click.pass_context
-@jobbergate_command_wrapper
-def update_job_submission(ctx, id_):
-    """
-    UPDATE a Job Submission.
-    """
-    api = ctx.obj["api"]
-    return api.update_job_submission(id_)
-
-
-@main.command("delete-job-submission")
-@click.option("--id", "-i", "id_", help="The id of job submission to delete")
-@click.pass_context
-@jobbergate_command_wrapper
-def delete_job_submission(ctx, id_):
-    """
-    DELETE a Job Submission.
-    """
-    api = ctx.obj["api"]
-    return api.delete_job_submission(id_)
-
-
-@main.command("upload-logs")
-@click.pass_context
-@jobbergate_command_wrapper
-def upload_logs(ctx):
-    """
-    Uploads user logs to S3 for analysis. Should only be used after an incident that was
-    reported to the Jobbergate support team.
-    """
-    logger.debug("Initializing S3 client")
-    s3_client = boto3.client(
-        "s3",
-        aws_access_key_id=settings.JOBBERGATE_AWS_ACCESS_KEY_ID,
-        aws_secret_access_key=settings.JOBBERGATE_AWS_SECRET_ACCESS_KEY,
-    )
-
-    tarball_name = "{user}.{timestamp}.tar.gz".format(
-        user=ctx.obj["token"]["username"],
-        timestamp=datetime.utcnow().strftime("%Y%m%d.%H%M%S"),
-    )
+    Show the token for the logged in user.
 
-    logger.debug("Creating tarball of user's logs")
-    log_dir = settings.JOBBERGATE_LOG_PATH.parent
-    with tempfile.TemporaryDirectory() as temp_dir:
-        tarball_path = Path(temp_dir) / tarball_name
-        with tarfile.open(tarball_path, "w:gz") as tarball:
-            for filename in log_dir.iterdir():
-                if filename.match(f"{settings.JOBBERGATE_LOG_PATH}*"):
-                    tarball.add(str(filename))
-
-        logger.debug(f"Uploading {tarball_name} to S3")
-        s3_client.upload_file(str(tarball_path), settings.JOBBERGATE_S3_LOG_BUCKET, tarball_name)
-
-    return "Upload complete. Please notify Omnivector <info@omnivector.solutions>."
-
-
-@main.command("login")
-@click.option(
-    "--token",
-    "-t",
-    help=textwrap.dedent(
-        """
-        Supply a token instead of fetching one.
-        This will clear any cached refresh tokens and automatic refresh will be unavailable until you
-        login in again without an explicit access token.
-        """
-    ).strip(),
-)
-def login(token=None, refresh_token=None):
+    Token output is automatically copied to your clipboard.
     """
-    Log in to the jobbergate-cli by storing the supplied token argument in the cache.
-    """
-    if token is not None:
-        clear_token_cache()
-        token_set = TokenSet(access_token=token)
+    token_set: TokenSet = load_tokens_from_cache()
+    token: Optional[str]
+    if not refresh:
+        token = token_set.access_token
+        subject = "Access Token"
+        Abort.require_condition(
+            token is not None,
+            "User is not logged in. Please log in first.",
+            raise_kwargs=dict(
+                subject="Not logged in",
+            ),
+        )
     else:
-        token_set = fetch_auth_tokens()
-    identity_data = validate_token_and_extract_identity(token_set.access_token)
-    save_tokens_to_cache(token_set)
-    print()
-    print(f"Logged in user {identity_data['user_email']}")
-
-
-@main.command("logout")
-def logout():
-    """
-    Logs out of the jobbergate-cli. Clears the saved user credentials.
-    """
-    clear_token_cache()
-    print("Cleared cached tokens")
-
+        token = token_set.refresh_token
+        subject = "Refresh Token"
+        Abort.require_condition(
+            token is not None,
+            "User is not logged in or does not have a refresh token. Please try loggin in again.",
+            raise_kwargs=dict(
+                subject="No refresh token",
+            ),
+        )
 
-@main.command("show-token")
-@click.option("--refresh", "-r", is_flag=True, help="Show the refresh token instead of the access token")
-@click.option("--show-prefix", "-p", is_flag=True, help="Include the 'Bearer' prefix")
-def show_token(refresh=False, show_prefix=False):
-    """
-    Shows the access (or refresh) token for the current user.
-    """
-    token_set: TokenSet = load_tokens_from_cache()
     prefix = "Bearer " if show_prefix else ""
-    token = token_set.refresh_token if refresh else token_set.access_token
-    print(f"{prefix}{token}")
-
-
-if __name__ == "__main__":
-    main()
+    token_text = f"{prefix}{token}"
+    pyperclip.copy(token_text)
+    if plain:
+        print(token_text)
+    else:
+        terminal_message(
+            token_text,
+            subject=subject,
+            footer="The output was copied to your clipboard",
+            indent=False,
+        )
diff --git a/jobbergate-cli/jobbergate_cli/render.py b/jobbergate-cli/jobbergate_cli/render.py
new file mode 100644
index 00000000..a5f7fb57
--- /dev/null
+++ b/jobbergate-cli/jobbergate_cli/render.py
@@ -0,0 +1,187 @@
+"""
+Provide helpers to render output for users.
+"""
+
+import json
+from typing import Any, Dict, List, Optional, Union
+
+import pydantic
+from rich import print_json
+from rich.console import Console
+from rich.panel import Panel
+from rich.table import Table
+
+from jobbergate_cli.schemas import JobbergateContext, ListResponseEnvelope
+from jobbergate_cli.text_tools import dedent
+from jobbergate_cli.text_tools import indent as indent_text
+
+
+class StyleMapper:
+    """
+    Provide a mapper that can set ``rich`` styles for rendered output of data tables and dicts.
+
+    The subapps have list endpoints that return sets of values. These are rendered as tables in
+    the output. The StyleMapper class provides a way to simply define styles that should be applied
+    to the columns of the table.
+
+    Example:
+
+    The following code will print a table where the columns are colored according to the style_mapper
+
+    .. code-block: python
+
+       style_mapper = StyleMapper(
+           a="bold green",
+           b="red",
+           c="blue",
+       )
+       envelope = dict(
+           results=[
+               dict(a=1, b=2, c=3),
+               dict(a=4, b=5, c=6),
+               dict(a=7, b=8, c=9),
+           ],
+           pagination=dict(total=3)
+       )
+       render_list_results(jb_ctx, envelope, style_mapper)
+
+    """
+
+    colors: Dict[str, str]
+
+    def __init__(self, **colors: str):
+        """
+        Initialize the StyleMapper.
+        """
+        self.colors = colors
+
+    def map_style(self, column: str) -> Dict[str, Any]:
+        """
+        Map a column name from the table to display to the style that should be used to render it.
+        """
+        color = self.colors.get(column, "white")
+        return dict(
+            style=color,
+            header_style=f"bold {color}",
+        )
+
+
+def terminal_message(message, subject=None, color="green", footer=None, indent=True):
+    """
+    Print a nicely formatted message as output to the user using a ``rich`` ``Panel``.
+
+    :param: message: The message to print out
+    :param: subject: An optional subject line to add in the header of the ``Panel``
+    :param: color:   An optional color to style the ``subject`` header with
+    :param: footer:  An optional message to display in the footer of the ``Panel``
+    :param: indent:  Adds padding to the left of the message
+    """
+    panel_kwargs = dict(padding=1)
+    if subject is not None:
+        panel_kwargs["title"] = f"[{color}]{subject}"
+    if footer is not None:
+        panel_kwargs["subtitle"] = f"[dim italic]{footer}[/dim italic]"
+    text = dedent(message)
+    if indent:
+        text = indent_text(text, prefix="  ")
+    console = Console()
+    console.print()
+    console.print(Panel(text, **panel_kwargs))
+    console.print()
+
+
+def render_list_results(
+    ctx: JobbergateContext,
+    envelope: ListResponseEnvelope,
+    style_mapper: Optional[StyleMapper] = None,
+    hidden_fields: Optional[List[str]] = None,
+    title: str = "Results List",
+):
+    """
+    Render a list of result data items in a ``rich`` ``Table``.
+
+    :param: ctx:           The JobbergateContext. This is needed to detect if ``full`` or ``raw`` output is needed
+    :param: envelope:      A ListResponseEnvelope containing the data items
+    :param: style_mapper:  The style mapper that should be used to apply styles to the columns of the table
+    :param: hidden_fields: Columns that should (if not using ``full`` mode) be hidden in the ``Table`` output
+    :param: title:         The title header to include above the ``Table`` output
+    """
+    if envelope.pagination.total == 0:
+        terminal_message("There are no results to display", subject="Nothing here...")
+        return
+
+    console = Console()
+    if ctx.raw_output:
+        console.print_json(json.dumps(envelope.results))
+    else:
+        if ctx.full_output or hidden_fields is None:
+            filtered_results = envelope.results
+        else:
+            filtered_results = [{k: v for (k, v) in d.items() if k not in hidden_fields} for d in envelope.results]
+        first_row = filtered_results[0]
+
+        table = Table(title=title, caption=f"Total items: {envelope.pagination.total}")
+        if style_mapper is None:
+            style_mapper = StyleMapper()
+        for key in first_row.keys():
+            table.add_column(key, **style_mapper.map_style(key))
+
+        for row in filtered_results:
+            table.add_row(*[str(v) for v in row.values()])
+
+        console.print()
+        console.print(table)
+        console.print()
+
+
+def render_dict(
+    data: Dict[str, Any],
+    title: str = "Data",
+    hidden_fields: Optional[List[str]] = None,
+):
+    """
+    Render a dictionary in a ``rich`` ``Table`` That shows the key and value of each item.
+
+    :param: data: The dictionary to render
+    :param: title: The title header to include above the ``Table`` output
+    :param: hidden_fields: Keys that should be hidden in the ``Table`` output
+    """
+    if hidden_fields is None:
+        hidden_fields = []
+
+    table = Table(title=title)
+    table.add_column("Key", header_style="bold yellow", style="yellow")
+    table.add_column("Value", header_style="bold white", style="white")
+
+    for (key, value) in data.items():
+        if key not in hidden_fields:
+            table.add_row(key, str(value))
+
+    console = Console()
+    console.print()
+    console.print(table)
+    console.print()
+
+
+def render_single_result(
+    ctx: JobbergateContext,
+    result: Union[Dict[str, Any], pydantic.BaseModel],
+    hidden_fields: Optional[List[str]] = None,
+    title: str = "Result",
+):
+    """
+    Render a single data item in a ``rich`` ``Table.
+
+    :param: ctx:           The JobbergateContext. This is needed to detect if ``full` or ``raw`` output is needed
+    :param: result:        The data item to display. May be a dict or a pydantic model.
+    :param: hidden_fields: Rows that should (if not using ``full`` mode) be hidden in the ``Table`` output
+    :param: title:         The title header to include above the ``Tale`` output
+    """
+    if isinstance(result, pydantic.BaseModel):
+        result = result.dict()
+    if ctx.raw_output:
+        print_json(json.dumps(result))
+    else:
+        if ctx.full_output or hidden_fields is None:
+            hidden_fields = []
+        render_dict(result, hidden_fields=hidden_fields, title=title)
diff --git a/jobbergate-cli/jobbergate_cli/requests.py b/jobbergate-cli/jobbergate_cli/requests.py
new file mode 100644
index 00000000..f8ecab05
--- /dev/null
+++ b/jobbergate-cli/jobbergate_cli/requests.py
@@ -0,0 +1,144 @@
+"""
+Provide utilities for making requests against the Jobbergate API.
+"""
+from typing import Any, Dict, Optional, Type, TypeVar, Union
+
+import httpx
+import pydantic
+from loguru import logger
+
+from jobbergate_cli.exceptions import Abort
+from jobbergate_cli.schemas import ForeignKeyError
+from jobbergate_cli.text_tools import dedent, unwrap
+
+
+ResponseModel = TypeVar("ResponseModel", bound=pydantic.BaseModel)
+
+
+def make_request(
+    client: httpx.Client,
+    url_path: str,
+    method: str,
+    *,
+    expected_status: Optional[int] = None,
+    expect_response: bool = True,
+    abort_message: str = "There was an error communicating with the API",
+    abort_subject: str = "REQUEST FAILED",
+    support: bool = True,
+    response_model: Optional[Type[ResponseModel]] = None,
+    **request_kwargs: Any,
+) -> Union[ResponseModel, Dict, int]:
+    """
+    Make a request against the Jobbergate API.
+
+    :param: client:          The Httpx client to use for the request
+    :param: url_path:        The path to add to the base url of the client where the request should be sent
+    :param: method:          The REST method to use for the request (GET, PUT, UPDATE, POST, DELETE, etc)
+    :param: expected_status: The status code to expect on the response. If it is not received, raise an Abort
+    :param: expect_response: Indicates if response data (JSON) is expected from the API endpoint
+    :param: abort_message:   The message to show the user if there is a problem and the app must be aborted
+    :param: abort_subject:   The subject to use in Abort output to the user
+    :param: support:         If true, add a message to the output instructing the user to seek help
+    :param: response_model:  If supplied, serialize the response data into this Pydantic model
+    :param: request_kwargs:  Any additional keyword arguments that need to be passed on to the client
+    """
+
+    logger.debug(f"Making request to {url_path=}")
+    request = client.build_request(method, url_path, **request_kwargs)
+    logger.debug(
+        dedent(
+            f"""
+            Request built with:
+              url: {request.url}
+              method: {method}
+              headers: {request.headers}
+              params: {request_kwargs.get("params")}
+              data: {request_kwargs.get("data")}
+              json: {request_kwargs.get("json")}
+            """
+        )
+    )
+
+    try:
+        response = client.send(request)
+    except httpx.RequestError as err:
+        raise Abort(
+            unwrap(
+                f"""
+                {abort_message}:
+                Communication with the API failed.
+                """
+            ),
+            subject=abort_subject,
+            support=support,
+            log_message="There was an error making the request to the API",
+            original_error=err,
+        )
+
+    if expected_status is not None and response.status_code != expected_status:
+        if method == "DELETE" and response.status_code == 409:
+            details = response.json()["detail"]
+            fk_error = ForeignKeyError(**details)
+            raise Abort(
+                dedent(
+                    f"""
+                    {abort_message}:
+                    There are [red]{fk_error.table}[/red] that reference id [bold yellow]{fk_error.pk_id}[/bold yellow].
+                    """,
+                ),
+                subject=abort_subject,
+                log_message=f"Could not delete due to foreign-key constraint: {response.text}",
+            )
+        else:
+            raise Abort(
+                unwrap(
+                    f"""
+                    {abort_message}:
+                    Received an error response.
+                    """
+                ),
+                subject=abort_subject,
+                support=support,
+                log_message=f"Got an error code for request: {response.status_code}: {response.text}",
+            )
+
+    # TODO: constrain methods with a named enum
+    if expect_response is False or method == "DELETE":
+        return response.status_code
+
+    try:
+        data = response.json()
+    except Exception as err:
+        raise Abort(
+            unwrap(
+                f"""
+                {abort_message}:
+                Response carried no data.
+                """
+            ),
+            subject=abort_subject,
+            support=support,
+            log_message=f"Failed unpacking json: {response.text}",
+            original_error=err,
+        )
+    logger.debug(f"Extracted data from response: {data}")
+
+    if response_model is None:
+        return data
+
+    logger.debug("Validating response data with ResponseModel")
+    try:
+        return response_model(**data)
+    except pydantic.ValidationError as err:
+        raise Abort(
+            unwrap(
+                f"""
+                {abort_message}:
+                Unexpected data in response.
+                """
+            ),
+            subject=abort_subject,
+            support=support,
+            log_message=f"Unexpected format in response data: {data}",
+            original_error=err,
+        )
diff --git a/jobbergate-cli/jobbergate_cli/schemas.py b/jobbergate-cli/jobbergate_cli/schemas.py
new file mode 100644
index 00000000..af9752ae
--- /dev/null
+++ b/jobbergate-cli/jobbergate_cli/schemas.py
@@ -0,0 +1,173 @@
+"""
+Provide Pydantic models for various data items.
+"""
+
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+import httpx
+import pydantic
+
+
+class TokenSet(pydantic.BaseModel, extra=pydantic.Extra.ignore):
+    """
+    A model representing a pairing of access and refresh tokens
+    """
+
+    access_token: str
+    refresh_token: Optional[str] = None
+
+
+class IdentityData(pydantic.BaseModel):
+    """
+    A model representing the fields that should appear in our custom identity data claim.
+    """
+
+    user_email: str
+    org_name: Optional[str]
+
+
+class Persona(pydantic.BaseModel):
+    """
+    A model representing a pairing of a TokenSet and Identity data.
+    This is a convenience to combine all of the identifying data and credentials for a given user.
+    """
+
+    token_set: TokenSet
+    identity_data: IdentityData
+
+
+class DeviceCodeData(pydantic.BaseModel, extra=pydantic.Extra.ignore):
+    """
+    A model representing the data that is returned from Auth0's device code endpoint.
+    """
+
+    device_code: str
+    verification_uri_complete: str
+    interval: int
+
+
+class JobbergateContext(pydantic.BaseModel, arbitrary_types_allowed=True):
+    """
+    A data object describing context passed from the main entry point.
+    """
+
+    persona: Optional[Persona]
+    full_output: bool = False
+    raw_output: bool = False
+    client: Optional[httpx.Client]
+
+
+class JobbergateConfig(pydantic.BaseModel):
+    """
+    A data object desribing the config values needed in the "jobbergate_config" section of the
+    JobbergateApplicationConfig model.
+    """
+
+    template_files: List[Path]
+    default_template: Optional[str] = None
+    output_directory: Optional[Path] = None
+    supporting_files_output_name: Optional[Dict[str, Any]] = None
+    supporting_files: Optional[List[Path]] = None
+
+    # For some reason, we support the application_config being about to override the *required*
+    # job_script_name parameter that is passed at job_script creation time.
+    # TODO: Find if this functionality is every used, and, if not, remove it immediately.
+    job_script_name: Optional[str] = None
+
+
+class JobbergateApplicationConfig(pydantic.BaseModel):
+    """
+    A data object describing the config data needed to instantiate a JobbergateAppliation class.
+    """
+
+    jobbergate_config: JobbergateConfig
+    application_config: Dict[str, Any]
+
+
+class ApplicationResponse(pydantic.BaseModel, extra=pydantic.Extra.ignore):
+    """
+    Describes the format of data for applications retrieved from the Jobbergate API endpoints.
+    """
+
+    id: int
+    application_name: str
+    application_identifier: Optional[str] = None
+    application_description: Optional[str] = None
+    application_owner_email: str
+    application_file: str
+    application_config: str
+    application_uploaded: bool
+    created_at: Optional[datetime] = None
+    updated_at: Optional[datetime] = None
+
+
+class JobScriptResponse(pydantic.BaseModel, extra=pydantic.Extra.ignore):
+    """
+    Describes the format of data for job_scripts retrieved from the Jobbergate API endpoints.
+    """
+
+    id: int
+    application_id: int
+    job_script_name: str
+    job_script_description: Optional[str] = None
+    job_script_data_as_string: str
+    job_script_owner_email: str
+    created_at: Optional[datetime] = None
+    updated_at: Optional[datetime] = None
+
+
+class JobSubmissionResponse(pydantic.BaseModel, extra=pydantic.Extra.ignore):
+    """
+    Describes the format of data for job_submissions retrieved from the Jobbergate API endpoints.
+    """
+
+    id: int
+    job_script_id: int
+    slurm_job_id: int
+    job_submission_name: str
+    job_submission_description: Optional[str] = None
+    job_submission_owner_email: str
+    created_at: Optional[datetime]
+    updated_at: Optional[datetime]
+
+
+class JobScriptCreateRequestData(pydantic.BaseModel):
+    """
+    Describes the data that will be sent to the ``create`` endpoint of the Jobbergate API for job scripts.
+    """
+
+    application_id: int
+    job_script_name: str
+    param_dict: Optional[JobbergateApplicationConfig] = None
+    sbatch_params: Optional[List[Any]] = None
+
+
+class Pagination(pydantic.BaseModel):
+    """
+    A model describing the structure of the pagination component of a ListResponseEnvelope.
+    """
+
+    total: int
+    start: Optional[int]
+    limit: Optional[int]
+
+
+class ListResponseEnvelope(pydantic.BaseModel):
+    """
+    A model describing the structure of response envelopes from "list" endpoints.
+    """
+
+    results: List[Dict[str, Any]]
+    pagination: Pagination
+
+
+class ForeignKeyError(pydantic.BaseModel):
+    """
+    A model describing the structure of a foreign-key constraint error on delete.
+    """
+
+    message: str
+    table: str
+    pk_id: int
diff --git a/jobbergate-cli/jobbergate_cli/subapps/__init__.py b/jobbergate-cli/jobbergate_cli/subapps/__init__.py
new file mode 100644
index 00000000..57546793
--- /dev/null
+++ b/jobbergate-cli/jobbergate_cli/subapps/__init__.py
@@ -0,0 +1,3 @@
+"""
+Subapps that are added to the base ``Typer`` application.
+"""
diff --git a/jobbergate-cli/jobbergate_cli/subapps/applications/__init__.py b/jobbergate-cli/jobbergate_cli/subapps/applications/__init__.py
new file mode 100644
index 00000000..3b408868
--- /dev/null
+++ b/jobbergate-cli/jobbergate_cli/subapps/applications/__init__.py
@@ -0,0 +1,3 @@
+"""
+Provide a sub-app for interacting with Applications data.
+"""
diff --git a/jobbergate-cli/jobbergate_cli/subapps/applications/app.py b/jobbergate-cli/jobbergate_cli/subapps/applications/app.py
new file mode 100644
index 00000000..ea8cb0ee
--- /dev/null
+++ b/jobbergate-cli/jobbergate_cli/subapps/applications/app.py
@@ -0,0 +1,366 @@
+"""
+Provide a ``typer`` app that can interact with Application data in a cruddy manner.
+"""
+
+import pathlib
+import tempfile
+from typing import Any, Dict, Optional, cast
+
+import typer
+from loguru import logger
+
+from jobbergate_cli.constants import OV_CONTACT, SortOrder
+from jobbergate_cli.exceptions import handle_abort
+from jobbergate_cli.render import StyleMapper, render_list_results, render_single_result, terminal_message
+from jobbergate_cli.requests import make_request
+from jobbergate_cli.schemas import JobbergateContext, ListResponseEnvelope
+from jobbergate_cli.subapps.applications.tools import (
+    build_application_tarball,
+    dump_full_config,
+    fetch_application_data,
+    load_default_config,
+    read_application_module,
+    upload_application,
+    validate_application_files,
+)
+
+
+# TODO: move hidden field logic to the API
+HIDDEN_FIELDS = [
+    "application_config",
+    "application_file",
+    "created_at",
+    "updated_at",
+]
+
+ID_NOTE = """
+
+    This id represents the primary key of the application in the database. It
+    will always be a unique integer and is automatically generated by the server
+    when an Application is created. All applications receive an id, so it may
+    be used to target a specific instance of an application whether or not it
+    is provided with a human-friendly "identifier".
+"""
+
+
+IDENTIFIER_NOTE = """
+
+    The identifier allows the user to access commonly used applications with a
+    friendly name that is easy to remember. Identifiers should only be used
+    for applications that are frequently used or should be easy to find in the list.
+    An identifier may be added, removed, or changed on an existing application.
+"""
+
+
+style_mapper = StyleMapper(
+    id="green",
+    application_name="cyan",
+    application_identifier="magenta",
+)
+
+
+app = typer.Typer(help="Commands to interact with applications")
+
+
+@app.command()
+@handle_abort
+def list_all(
+    ctx: typer.Context,
+    show_all: bool = typer.Option(False, "--all", help="Show all applications, even the ones without identifier"),
+    user_only: bool = typer.Option(False, "--user", help="Show only applications owned by the current user"),
+    search: Optional[str] = typer.Option(None, help="Apply a search term to results"),
+    sort_order: SortOrder = typer.Option(SortOrder.UNSORTED, help="Specify sort order"),
+    sort_field: Optional[str] = typer.Option(None, help="The field by which results should be sorted"),
+):
+    """
+    Show available applications
+    """
+    jg_ctx: JobbergateContext = ctx.obj
+
+    # Make static type checkers happy
+    assert jg_ctx is not None
+    assert jg_ctx.client is not None
+
+    params: Dict[str, Any] = dict(
+        all=show_all,
+        user=user_only,
+    )
+    if search is not None:
+        params["search"] = search
+    if sort_order is not SortOrder.UNSORTED:
+        params["sort_ascending"] = SortOrder is SortOrder.ASCENDING
+    if sort_field is not None:
+        params["sort_field"] = sort_field
+
+    envelope = cast(
+        ListResponseEnvelope,
+        make_request(
+            jg_ctx.client,
+            "/applications",
+            "GET",
+            expected_status=200,
+            abort_message="Couldn't retrieve applications list from API",
+            support=True,
+            response_model=ListResponseEnvelope,
+            params=params,
+        ),
+    )
+    render_list_results(
+        jg_ctx,
+        envelope,
+        title="Applications List",
+        style_mapper=style_mapper,
+        hidden_fields=HIDDEN_FIELDS,
+    )
+
+
+@app.command()
+@handle_abort
+def get_one(
+    ctx: typer.Context,
+    id: Optional[int] = typer.Option(
+        None,
+        help=f"The specific id of the application. {ID_NOTE}",
+    ),
+    identifier: Optional[str] = typer.Option(
+        None,
+        help=f"The human-friendly identifier of the application. {IDENTIFIER_NOTE}",
+    ),
+):
+    """
+    Get a single application by id or identifier
+    """
+    jg_ctx: JobbergateContext = ctx.obj
+    result = fetch_application_data(jg_ctx, id=id, identifier=identifier)
+    render_single_result(
+        jg_ctx,
+        result,
+        hidden_fields=HIDDEN_FIELDS,
+        title="Application",
+    )
+
+
+def _upload_application(jg_ctx: JobbergateContext, application_path: pathlib.Path, application_id: int) -> bool:
+    """
+    Utility function that uploads an application from a given path to the Jobbergate API.
+
+    :param: jg_ctx:           The JobbergateContext. Needed to access the Httpx client to use for requests
+    :param: application_path: The directory on the system where the application files are found
+    :param: application_id:   The ``id`` of the Application to be uploaded
+    """
+
+    # Make static type checkers happy
+    assert jg_ctx.client is not None
+
+    with tempfile.TemporaryDirectory() as temp_dir_str:
+        build_path = pathlib.Path(temp_dir_str)
+        logger.debug("Building application tar file at {build_path}")
+        tar_path = build_application_tarball(application_path, build_path)
+        response_code = cast(
+            int,
+            make_request(
+                jg_ctx.client,
+                f"/applications/{application_id}/upload",
+                "POST",
+                expect_response=False,
+                abort_message="Request to upload application files was not accepted by the API",
+                support=True,
+                files=dict(upload_file=open(tar_path, "rb")),
+            ),
+        )
+        return response_code == 201
+
+
+@app.command()
+@handle_abort
+def create(
+    ctx: typer.Context,
+    name: str = typer.Option(
+        ...,
+        help="The name of the application to create",
+    ),
+    identifier: Optional[str] = typer.Option(
+        None,
+        help=f"The human-friendly identifier of the application. {IDENTIFIER_NOTE}",
+    ),
+    application_path: pathlib.Path = typer.Option(
+        ...,
+        help="The path to the directory where the application files are located",
+    ),
+    application_desc: Optional[str] = typer.Option(
+        None,
+        help="A helpful description of the application",
+    ),
+):
+    """
+    Create a new application.
+    """
+    req_data = load_default_config()
+    req_data["application_name"] = name
+    if identifier:
+        req_data["application_identifier"] = identifier
+
+    if application_desc:
+        req_data["application_description"] = application_desc
+
+    validate_application_files(application_path)
+
+    req_data["application_config"] = dump_full_config(application_path)
+    req_data["application_file"] = read_application_module(application_path)
+
+    jg_ctx: JobbergateContext = ctx.obj
+
+    # Make static type checkers happy
+    assert jg_ctx.client is not None
+
+    result = cast(
+        Dict[str, Any],
+        make_request(
+            jg_ctx.client,
+            "/applications",
+            "POST",
+            expected_status=201,
+            abort_message="Request to create application was not accepted by the API",
+            support=True,
+            json=req_data,
+        ),
+    )
+    application_id = result["id"]
+
+    successful_upload = upload_application(jg_ctx, application_path, application_id)
+    if not successful_upload:
+        terminal_message(
+            f"""
+            The zipped application files could not be uploaded.
+
+            Try running the `update` command including the application path to re-upload.
+
+            [yellow]If the problem persists, please contact [bold]{OV_CONTACT}[/bold]
+            for support and trouble-shooting[/yellow]
+            """,
+            subject="File upload failed",
+            color="yellow",
+        )
+    else:
+        result["application_uploaded"] = True
+
+    render_single_result(
+        jg_ctx,
+        result,
+        hidden_fields=HIDDEN_FIELDS,
+        title="Created Application",
+    )
+
+
+@app.command()
+@handle_abort
+def update(
+    ctx: typer.Context,
+    id: int = typer.Option(
+        ...,
+        help=f"The specific id of the application to update. {ID_NOTE}",
+    ),
+    application_path: Optional[pathlib.Path] = typer.Option(
+        None,
+        help="The path to the directory where the application files are located",
+    ),
+    identifier: Optional[str] = typer.Option(
+        None,
+        help="Optional new application identifier to be set",
+    ),
+    application_desc: Optional[str] = typer.Option(
+        None,
+        help="Optional new application description to be set",
+    ),
+):
+    """
+    Update an existing application.
+    """
+    req_data = dict()
+
+    if identifier:
+        req_data["application_identifier"] = identifier
+
+    if application_desc:
+        req_data["application_description"] = application_desc
+
+    if application_path is not None:
+        validate_application_files(application_path)
+        req_data["application_config"] = dump_full_config(application_path)
+        req_data["application_file"] = read_application_module(application_path)
+
+    jg_ctx: JobbergateContext = ctx.obj
+
+    # Make static type checkers happy
+    assert jg_ctx.client is not None
+
+    result = cast(
+        Dict[str, Any],
+        make_request(
+            jg_ctx.client,
+            f"/applications/{id}",
+            "PUT",
+            expected_status=200,
+            abort_message="Request to update application was not accepted by the API",
+            support=True,
+            json=req_data,
+        ),
+    )
+
+    if application_path is not None:
+        successful_upload = _upload_application(jg_ctx, application_path, id)
+        if not successful_upload:
+            terminal_message(
+                f"""
+                The zipped application files could not be uploaded.
+
+                [yellow]If the problem persists, please contact [bold]{OV_CONTACT}[/bold]
+                for support and trouble-shooting[/yellow]
+                """,
+                subject="File upload failed",
+                color="yellow",
+            )
+        else:
+            result["application_uploaded"] = True
+
+    render_single_result(
+        jg_ctx,
+        result,
+        hidden_fields=HIDDEN_FIELDS,
+        title="Updated Application",
+    )
+
+
+@app.command()
+@handle_abort
+def delete(
+    ctx: typer.Context,
+    id: int = typer.Option(
+        ...,
+        help=f"the specific id of the application to delete. {ID_NOTE}",
+    ),
+):
+    """
+    Delete an existing application.
+    """
+    jg_ctx: JobbergateContext = ctx.obj
+
+    # Make static type checkers happy
+    assert jg_ctx.client is not None
+
+    # Delete the upload. The API will also remove the application data files
+    make_request(
+        jg_ctx.client,
+        f"/applications/{id}",
+        "DELETE",
+        expected_status=204,
+        expect_response=False,
+        abort_message="Request to delete application was not accepted by the API",
+        support=True,
+    )
+    terminal_message(
+        """
+        The application was successfully deleted.
+        """,
+        subject="Application delete succeeded",
+    )
diff --git a/jobbergate-cli/jobbergate_cli/subapps/applications/application_base.py b/jobbergate-cli/jobbergate_cli/subapps/applications/application_base.py
new file mode 100644
index 00000000..e5c0e448
--- /dev/null
+++ b/jobbergate-cli/jobbergate_cli/subapps/applications/application_base.py
@@ -0,0 +1,48 @@
+"""ApplicationBase."""
+
+import pathlib
+from typing import Any, Dict, List
+
+from jobbergate_cli.render import terminal_message
+from jobbergate_cli.text_tools import dedent_all
+
+
+class JobbergateApplicationBase:
+    """JobbergateApplicationBase."""
+
+    def __init__(self, jobbergate_yaml: Dict[str, Any]):
+        """Initialize class attributes."""
+        self.jobbergate_config = jobbergate_yaml["jobbergate_config"]
+        self.application_config = jobbergate_yaml.get("application_config", dict())
+
+    def mainflow(self, data: Dict[str, Any]):
+        """Implements the main question asking workflow."""
+        data  # Makes linters happy
+        raise NotImplementedError("Inheriting class must override this method.")
+
+    @staticmethod
+    def find_templates(application_path: pathlib.Path) -> List[pathlib.Path]:
+        """
+        Finds templates a given application path.
+        """
+        template_root_path = application_path / "templates"
+        if template_root_path.exists():
+            return sorted(p.relative_to(application_path) for p in template_root_path.glob("**/*") if p.is_file())
+        else:
+            return list()
+
+    def get_template_files(self) -> List[pathlib.Path]:
+        template_file_paths = self.find_templates(pathlib.Path.cwd())
+        terminal_message(
+            dedent_all(
+                f"""
+                Here are all of the included templates:
+                [yellow](in {pathlib.Path.cwd()})[/yellow]
+                ---------------------------------------
+                """,
+                *(f"* {p}" for p in template_file_paths),
+            ),
+            subject="Template Files",
+            color="green",
+        )
+        return template_file_paths
diff --git a/jobbergate-cli/jobbergate_cli/subapps/applications/questions.py b/jobbergate-cli/jobbergate_cli/subapps/applications/questions.py
new file mode 100644
index 00000000..ca6a664f
--- /dev/null
+++ b/jobbergate-cli/jobbergate_cli/subapps/applications/questions.py
@@ -0,0 +1,371 @@
+"""
+Abstraction layer for questions. Each class represents different question types.
+
+The questions describe literal questions that are asked of the user in an interactive
+mode via the ``inquirer`` package.
+
+Questions will be skipped and use the default value if the `ignore` property resolves
+to True.
+
+Questions will also resolve to their default values if running in "fast mode".
+"""
+
+from copy import deepcopy
+from functools import partial
+from typing import Any, Callable, Dict, Optional, Type, TypeVar, cast
+
+import inquirer
+import inquirer.errors
+import inquirer.questions
+
+from jobbergate_cli.exceptions import Abort
+from jobbergate_cli.render import render_dict
+from jobbergate_cli.subapps.applications.application_base import JobbergateApplicationBase
+
+
+TInquirerType = TypeVar("TInquirerType", bound=inquirer.questions.Question)
+
+
+class QuestionBase:
+    """
+    Baseclass for questions.
+
+    All questions have variablename, message and an optional default.
+    """
+
+    def __init__(
+        self,
+        variablename: str,
+        message: str,
+        ignore: bool = False,
+        default: Optional[Any] = None,
+        inquirer_type: Type[TInquirerType] = inquirer.Text,
+    ):
+        """
+        Initialize the Question.
+
+        :param: variablename:  The key in the config dictionary that this question will set
+        :param: message:       The message to show the user that describes what the question is gathering
+        :param: ignore:        If true, do not ask the question and just use the default value instead
+        :param: default:       The default value for the variablename in the answers dict
+        :param: inquirer_type: The ``inquirer`` question type that this ``QuestionBase`` wraps
+        """
+        self.variablename = variablename
+        self.default = default
+        self.inquirer_kwargs = dict(
+            message=message,
+            default=default,
+            ignore=ignore,
+        )
+        self.inquirer_type = inquirer_type
+
+    def make_prompts(self, **override_kwargs):
+        """
+        Create ``inquirer`` prompts from this instance of ``QuestionBase``.
+
+        :param: override_kwargs: A collection of keyword arguments to override in intializing the ``inquirer`` question
+        """
+        final_kwargs = {
+            **self.inquirer_kwargs,
+            **override_kwargs,
+        }
+        return [self.inquirer_type(self.variablename, **final_kwargs)]
+
+
+class Text(QuestionBase):
+    """
+    Asks for a text value.
+    """
+
+
+class Integer(QuestionBase):
+    """
+    Asks for an integer value. Could have min and/or max constrains.
+    """
+
+    def __init__(
+        self,
+        variablename: str,
+        message: str,
+        minval: Optional[int] = None,
+        maxval: Optional[int] = None,
+        **kwargs,
+    ):
+        """
+        Initialize the Integer question.
+
+        :param: variablename:  The key in the config dictionary that this question will set
+        :param: message:       The message to show the user that describes what the question is gathering
+        :param: minval:        The minimum value the integer may be set to. If not specified, use negative infinity.
+        :param: minval:        The maximum value the integer may be set to. If not specified, use infinity.
+        """
+        super().__init__(variablename, message, **kwargs)
+        self.minval = minval
+        self.maxval = maxval
+        self.inquirer_kwargs.update(validate=self._validator)
+
+    def _validator(self, _, current):
+        """
+        Provide a custom validator that checks the value of the integer to make sure it is in range.
+        """
+        try:
+            int_val = int(current)
+        except ValueError:
+            raise inquirer.errors.ValidationError("", reason=f"{current} is not an integer")
+
+        min_str = str(self.minval) if self.minval is not None else "-∞"
+        max_str = str(self.maxval) if self.maxval is not None else "∞"
+        if any(
+            [
+                self.minval is not None and int_val < self.minval,
+                self.maxval is not None and int_val > self.maxval,
+            ]
+        ):
+            raise inquirer.errors.ValidationError("", reason=f"{current} is out of range [{min_str}, {max_str}]")
+        return True
+
+
+class List(QuestionBase):
+    """
+    Gives the user a list to choose one from.
+    """
+
+    def __init__(self, variablename: str, message: str, choices: list, **kwargs):
+        """
+        Initialize the List question.
+
+        :param: variablename:  The key in the config dictionary that this question will set
+        :param: message:       The message to show the user that describes what the question is gathering
+        :param: choices:       A list of the possible values from which the Question will allow the user to select one
+        """
+        super().__init__(variablename, message, inquirer_type=inquirer.List, **kwargs)
+        self.inquirer_kwargs.update(choices=choices)
+
+
+class Directory(QuestionBase):
+    """
+    Asks for a directory name. If `exists` is `True` it checks if path exists and is a directory.
+
+    :param exists: Checks if given directory exists
+    """
+
+    def __init__(self, variablename: str, message: str, exists: Optional[bool] = None, **kwargs):
+        """
+        Initialize the Directory question.
+
+        :param: variablename:  The key in the config dictionary that this question will set
+        :param: message:       The message to show the user that describes what the question is gathering
+        :param: exists:        If True, ensure that the directory exists on the system
+        """
+
+        super().__init__(variablename, message, inquirer_type=inquirer.Path, **kwargs)
+        self.inquirer_kwargs.update(path_type=inquirer.Path.DIRECTORY)
+        if exists is not None:
+            self.inquirer_kwargs.update(exists=exists)
+
+
+class File(QuestionBase):
+    """
+    Asks for a file name.
+    """
+
+    def __init__(self, variablename: str, message: str, exists: Optional[bool] = None, **kwargs):
+        """
+        Initialize the File question.
+
+        :param: variablename:  The key in the config dictionary that this question will set
+        :param: message:       The message to show the user that describes what the question is gathering
+        :param: exists:        If True, ensure that the file path exists on the system
+        """
+        super().__init__(variablename, message, inquirer_type=inquirer.Path, **kwargs)
+        self.inquirer_kwargs.update(path_type=inquirer.Path.FILE)
+        if exists is not None:
+            self.inquirer_kwargs.update(exists=exists)
+
+
+class Checkbox(QuestionBase):
+    """
+    Gives the user a list to choose multiple entries from.
+    """
+
+    def __init__(self, variablename: str, message: str, choices: list, **kwargs):
+        """
+        Initialize the Checkbox question.
+
+        :param: variablename:  The key in the config dictionary that this question will set
+        :param: message:       The message to show the user that describes what the question is gathering
+        :param: choices:       A list of the possible values from which the Question will allow the user to select many
+        """
+
+        super().__init__(variablename, message, inquirer_type=inquirer.Checkbox, **kwargs)
+        self.inquirer_kwargs.update(choices=choices)
+
+
+class Confirm(QuestionBase):
+    """
+    Asks a question with a boolean answer (true/false).
+    """
+
+    def __init__(self, variablename: str, message: str, **kwargs):
+        """
+        Initialize the Confirm question.
+
+        :param: variablename:  The key in the config dictionary that this question will set
+        :param: message:       The message to show the user that describes what the question is gathering
+        """
+        super().__init__(variablename, message, inquirer_type=inquirer.Confirm, **kwargs)
+
+
+class BooleanList(Confirm):
+    """
+    Asks a confirmation question that is followed up by a certain question list when true and a different list if false.
+    """
+
+    def __init__(
+        self,
+        variablename: str,
+        message: str,
+        whentrue=None,
+        whenfalse=None,
+        **kwargs,
+    ):
+        """
+        Initialize the Checkbox question.
+
+        :param: variablename:  The key in the config dictionary that this question will set
+        :param: message:       The message to show the user that describes what the question is gathering
+        :param whentrue:       List of questions to ask if user answers 'true' on this question
+        :param whentrue:       List of questions to show if user answers 'false' on this question
+        """
+        super().__init__(variablename, message, **kwargs)
+        self.whentrue_child_map = {c.variablename: c for c in (whentrue if whentrue is not None else [])}
+        self.whenfalse_child_map = {c.variablename: c for c in (whenfalse if whenfalse is not None else [])}
+
+    def ignore_child(self, child: QuestionBase, answers: Dict[str, Any]) -> bool:
+        """
+        Dynamically check if a child question should be ignored based on the questions that have already been answered.
+
+        :param: child:   The child question that might be ignored
+        :param: answers: Answer values to previously asked questions
+        """
+        my_answer = answers.get(self.variablename)
+        Abort.require_condition(
+            my_answer is not None,
+            "Questions were asked out of order. Please check your Application for consistency",
+        )
+        if my_answer is True:
+            if child.variablename in self.whentrue_child_map:
+                return False
+            elif child.variablename in self.whenfalse_child_map:
+                return True
+            else:
+                return False  # This child wasn't registered. This should not happen. But, don't ignore to be safe.
+        else:
+            if child.variablename in self.whentrue_child_map:
+                return True
+            elif child.variablename in self.whenfalse_child_map:
+                return False
+            else:
+                return False  # This child wasn't registered. This should not happen. But, don't ignore to be safe.
+
+    def make_ignore_partial(self, child: QuestionBase) -> Callable[[Dict[str, Any]], bool]:
+        """
+        Build a partial method for checking if a child should be ignored.
+
+        This method just makes the code more readable so that a non-descriptive lambda does not need to be used inline.
+        """
+        return partial(self.ignore_child, child)
+
+    def make_prompts(self, **override_kwargs):
+        """
+        Create ``inquirer`` prompts from this instance of ``BooleanList`` and for all its child questions.
+
+        :param: override_kwargs: A collection of keyword arguments to override in the base ``make_prompts`` method
+        """
+
+        retval = super().make_prompts(**override_kwargs)
+        all_children = [*self.whentrue_child_map.values(), *self.whenfalse_child_map.values()]
+        for child in all_children:
+            retval.extend(child.make_prompts(ignore=self.make_ignore_partial(child)))
+        return retval
+
+
+class Const(Text):
+    """
+    Sets the variable to the `default` value. Doesn't show anything.
+    """
+
+    def __init__(self, variablename: str, **kwargs):
+        """
+        Initialize the Const "question".
+
+        :param: variablename:  The key in the config dictionary that this question will set
+        """
+        super().__init__(variablename, "", **kwargs)
+
+    def make_prompts(self):
+        """
+        Create ``inquirer`` prompts from this instance of ``Const``.
+        """
+        return super().make_prompts(ignore=True)
+
+
+def gather_param_values(
+    application: JobbergateApplicationBase,
+    supplied_params: Optional[Dict[str, Any]] = None,
+    fast_mode: bool = False,
+) -> Dict[str, Any]:
+    """
+    Gather the parameter values by executing the application methods.
+
+    Prompt users for answers or use defaults as needed.
+
+    :param: application:     The application instance to pull questions from
+    :param: supplied_params: Pre-supplied parameters.
+                             Any questions where the variablename matches a pre-supplied key in the dict
+                             at the start of execution will be skipped.
+    :param: fast_mode:       Do not ask the user questions. Just use the supplied params and defaults.
+    :returns: A dict of the gathered parameter values
+    """
+    if supplied_params is None:
+        supplied_params = dict()
+
+    config = deepcopy(supplied_params)
+
+    next_method = "mainflow"
+
+    while next_method is not None:
+        method_to_call = getattr(application, next_method)
+
+        try:
+            workflow_questions = method_to_call(data=config)
+        except NotImplementedError:
+            raise Abort(
+                f"""
+                Abstract method not implemented.
+
+                Please implement {method_to_call.__name__} in your class.",
+                """,
+                subject="Invalid application module",
+            )
+
+        prompts = []
+        auto_answers = {}
+
+        for question in workflow_questions:
+            if question.variablename in supplied_params:
+                continue
+            elif fast_mode and question.default is not None:
+                auto_answers[question.variablename] = question.default
+            else:
+                prompts.extend(question.make_prompts())
+
+        workflow_answers = cast(Dict[str, Any], inquirer.prompt(prompts, raise_keyboard_interrupt=True))
+        config.update(workflow_answers)
+        config.update(auto_answers)
+        if len(auto_answers) > 0:
+            render_dict(auto_answers, title="Default values used")
+
+        next_method = config.pop("nextworkflow", None)
+
+    return config
diff --git a/jobbergate-cli/jobbergate_cli/subapps/applications/tools.py b/jobbergate-cli/jobbergate_cli/subapps/applications/tools.py
new file mode 100644
index 00000000..52fbb987
--- /dev/null
+++ b/jobbergate-cli/jobbergate_cli/subapps/applications/tools.py
@@ -0,0 +1,310 @@
+"""
+Provide tool functions for working with Application data.
+"""
+
+import ast
+import copy
+import pathlib
+import tarfile
+import tempfile
+from typing import Any, Dict, Optional, Tuple, cast
+
+import yaml
+from loguru import logger
+
+from jobbergate_cli.constants import (
+    JOBBERGATE_APPLICATION_CONFIG,
+    JOBBERGATE_APPLICATION_CONFIG_FILE_NAME,
+    JOBBERGATE_APPLICATION_MODULE_FILE_NAME,
+    TAR_NAME,
+)
+from jobbergate_cli.exceptions import Abort
+from jobbergate_cli.requests import make_request
+from jobbergate_cli.schemas import ApplicationResponse, JobbergateApplicationConfig, JobbergateContext
+from jobbergate_cli.subapps.applications.application_base import JobbergateApplicationBase
+from jobbergate_cli.subapps.applications.questions import gather_param_values
+from jobbergate_cli.text_tools import unwrap
+
+
+def validate_application_files(application_path: pathlib.Path):
+    """
+    Validate application files at a given directory.
+
+    Confirms:
+        application_path exists
+        application_path contains an application python module
+        application_path contains an application configuration file
+    """
+    with Abort.check_expressions(
+        f"The application files in {application_path} were invalid",
+        raise_kwargs=dict(
+            subject="Invalid application files",
+            log_message=f"Application files located at {application_path} failed validation",
+        ),
+    ) as checker:
+        checker(
+            application_path.exists(),
+            f"Application directory {application_path} does not exist",
+        )
+
+        application_module = application_path / JOBBERGATE_APPLICATION_MODULE_FILE_NAME
+        checker(
+            application_module.exists(),
+            unwrap(
+                f"""
+                Application directory does not contain required application module
+                {JOBBERGATE_APPLICATION_MODULE_FILE_NAME}
+                """
+            ),
+        )
+        try:
+            ast.parse(application_module.read_text())
+            is_valid_python = True
+        except Exception:
+            is_valid_python = False
+        checker(is_valid_python, f"The application module at {application_module} is not valid python code")
+
+        application_config = application_path / JOBBERGATE_APPLICATION_CONFIG_FILE_NAME
+        checker(
+            application_config.exists(),
+            unwrap(
+                f"""
+                Application directory does not contain required configuration file
+                {JOBBERGATE_APPLICATION_MODULE_FILE_NAME}
+                """
+            ),
+        )
+        try:
+            yaml.safe_load(application_config.read_text())
+            is_valid_yaml = True
+        except Exception:
+            is_valid_yaml = False
+        checker(is_valid_yaml, f"The application config at {application_config} is not valid YAML")
+
+
+def load_default_config() -> Dict[str, Any]:
+    """
+    Load the default config for an application.
+    """
+    return copy.deepcopy(JOBBERGATE_APPLICATION_CONFIG)
+
+
+def dump_full_config(application_path: pathlib.Path) -> str:
+    """
+    Dump the application config as text. Add existing template file paths into the config.
+    """
+    config_path = application_path / JOBBERGATE_APPLICATION_CONFIG_FILE_NAME
+    config = yaml.safe_load(config_path.read_text())
+    config["jobbergate_config"]["template_files"] = sorted(
+        str(t) for t in JobbergateApplicationBase.find_templates(application_path)
+    )
+    return yaml.dump(config)
+
+
+def read_application_module(application_path: pathlib.Path) -> str:
+    """
+    Read the text from the application module found in the supplied application path.
+    """
+    module_path = application_path / JOBBERGATE_APPLICATION_MODULE_FILE_NAME
+    return module_path.read_text()
+
+
+def build_application_tarball(application_path: pathlib.Path, build_dir: pathlib.Path) -> pathlib.Path:
+    """
+    Build a gzipped tarball from the files found at the target application path.
+
+    :param: application_path: The directory where the application files may be found
+    :param: build_dir:        The directory where the applicaiton files should be staged and zipped
+    """
+    tar_path = build_dir / TAR_NAME
+    with tarfile.open(str(tar_path), "w|gz") as archive:
+        module_path = application_path / JOBBERGATE_APPLICATION_MODULE_FILE_NAME
+        archive.add(module_path, arcname=f"/{module_path.name}")
+
+        config_path = application_path / JOBBERGATE_APPLICATION_CONFIG_FILE_NAME
+        archive.add(config_path, arcname=f"/{config_path.name}")
+
+        template_root_path = application_path / "templates"
+        if template_root_path.exists():
+            for template_path in template_root_path.iterdir():
+                if template_path.is_file:
+                    rel_path = template_path.relative_to(application_path)
+                    archive.add(template_path, arcname=f"/{rel_path}")
+    return tar_path
+
+
+def fetch_application_data(
+    jg_ctx: JobbergateContext,
+    id: Optional[int] = None,
+    identifier: Optional[str] = None,
+) -> ApplicationResponse:
+    """
+    Retrieve an application from the API by ``id`` or ``identifier``.
+
+    :param: jg_ctx:     The JobbergateContext. Needed to access the Httpx client with which to make API calls
+    :param: id:         The id of the application to fetch
+    :param: identifier: If supplied, look for an application instance with the provided identifier
+    :returns: An instance of ApplicationResponse containing the application data
+    """
+    url = f"/applications/{id}"
+    params = dict()
+    if id is None and identifier is None:
+        raise Abort(
+            """
+            You must supply either [yellow]id[/yellow] or [yellow]identifier[/yellow].
+            """,
+            subject="Invalid params",
+            warn_only=True,
+        )
+    elif id is not None and identifier is not None:
+        raise Abort(
+            """
+            You may not supply both [yellow]id[/yellow] and [yellow]identifier[/yellow].
+            """,
+            subject="Invalid params",
+            warn_only=True,
+        )
+    elif identifier is not None:
+        url = "/applications"
+        params["identifier"] = identifier
+
+    # Make static type checkers happy
+    assert jg_ctx.client is not None
+
+    stub = f"{id=}" if id is not None else f"{identifier=}"
+    return cast(
+        ApplicationResponse,
+        make_request(
+            jg_ctx.client,
+            url,
+            "GET",
+            expected_status=200,
+            abort_message=f"Couldn't retrieve application {stub} from API",
+            response_model=ApplicationResponse,
+            support=True,
+            params=params,
+        ),
+    )
+
+
+def load_application_data(
+    app_data: ApplicationResponse,
+) -> Tuple[JobbergateApplicationConfig, JobbergateApplicationBase]:
+    """
+    Validates and loads the data for an application returned from the API's applications GET endpoint.
+
+    :param: app_data: A dictionary containing the application data
+    :returns: A tuple containing the application config and the application module
+    """
+    try:
+        app_config = load_application_config_from_source(app_data.application_config)
+    except Exception as err:
+        print("ERR: ", err)
+        raise Abort(
+            "The application config fetched from the API is not valid",
+            subject="Invalid application config",
+            support=True,
+            log_message="Invalid application config",
+            original_error=err,
+        )
+
+    try:
+        app_module = load_application_from_source(app_data.application_file, app_config)
+    except Exception as err:
+        raise Abort(
+            "The application source fetched from the API is not valid",
+            subject="Invalid application config",
+            support=True,
+            log_message="Invalid application module",
+            original_error=err,
+        )
+
+    return (app_config, app_module)
+
+
+def upload_application(
+    jg_ctx: JobbergateContext,
+    application_path: pathlib.Path,
+    application_id: int,
+) -> bool:
+    """
+    Upload an application given an application path and the application id.
+
+    :param: jg_ctx:           The JobbergateContext. Needed to access the Httpx client with which to make API calls
+    :param: application_path: The directory where the application files to upload may be found
+    :param: application_id:   The id of the application for which to upload  data
+    :returns: True if the upload was successful; False otherwise
+    """
+
+    # Make static type checkers happy
+    assert jg_ctx.client is not None
+
+    with tempfile.TemporaryDirectory() as temp_dir_str:
+        build_path = pathlib.Path(temp_dir_str)
+        logger.debug("Building application tar file at {build_path}")
+        tar_path = build_application_tarball(application_path, build_path)
+        response_code = cast(
+            int,
+            make_request(
+                jg_ctx.client,
+                f"/applications/{application_id}/upload",
+                "POST",
+                expect_response=False,
+                abort_message="Request to upload application files was not accepted by the API",
+                support=True,
+                files=dict(upload_file=open(tar_path, "rb")),
+            ),
+        )
+        return response_code == 201
+
+
+def load_application_config_from_source(config_source: str) -> JobbergateApplicationConfig:
+    """
+    Load the JobbergateApplicationConfig from a text string containing the config as YAML.
+
+    :param: config_source: The YAML containing the config
+    :returns: A JobbergateApplicationConfig instance with the config values
+    """
+    config_data = yaml.safe_load(config_source)
+    config = JobbergateApplicationConfig(**config_data)
+    return config
+
+
+def load_application_from_source(app_source: str, app_config: JobbergateApplicationConfig) -> JobbergateApplicationBase:
+    """
+    Load the JobbergateApplication class from a text string containing the source file.
+
+    Creates the module in a temporary file and importins it with importlib.
+
+    Adapted from: https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly
+
+    :param: app_source: The JobbergateApplication source code to load
+    :param: app_config: The JobbergateApplicationConfig needed to instantiate the JobbergateApplication
+    """
+    app_locals: Dict[str, Any] = dict()
+    exec(app_source, app_locals, app_locals)
+    jobbergate_application_class = app_locals["JobbergateApplication"]
+    application = jobbergate_application_class(app_config.dict())
+    return application
+
+
+def execute_application(
+    app_module: JobbergateApplicationBase,
+    app_config: JobbergateApplicationConfig,
+    supplied_params: Optional[Dict[str, Any]] = None,
+    fast_mode: bool = False,
+):
+    """
+    Execute the jobbergate application python module.
+
+    Updates the app_config with values gathered in the question workflow
+
+    :param: app_module:      The source code for the application to execute
+    :param: app_config:      The configuration for the JobbergateApplication
+    :param: supplied_params: Pre-set values for the parameters. Any questions about these values will be skipped.
+    :param: fast_mode:       If true, do not ask the user questions. Just use supplied_params or defaults
+    :returns: The configuration values collected from the user by executing the application
+    """
+    app_params = gather_param_values(app_module, supplied_params=supplied_params, fast_mode=fast_mode)
+    app_config.application_config.update(**app_params)
+    return app_params
diff --git a/jobbergate-cli/jobbergate_cli/subapps/job_scripts/__init__.py b/jobbergate-cli/jobbergate_cli/subapps/job_scripts/__init__.py
new file mode 100644
index 00000000..8c6dced3
--- /dev/null
+++ b/jobbergate-cli/jobbergate_cli/subapps/job_scripts/__init__.py
@@ -0,0 +1,3 @@
+"""
+Provide a sub-app for interacting with Job Script data.
+"""
diff --git a/jobbergate-cli/jobbergate_cli/subapps/job_scripts/app.py b/jobbergate-cli/jobbergate_cli/subapps/job_scripts/app.py
new file mode 100644
index 00000000..5400e4a0
--- /dev/null
+++ b/jobbergate-cli/jobbergate_cli/subapps/job_scripts/app.py
@@ -0,0 +1,294 @@
+"""
+Provide a ``typer`` app that can interact with Job Script data in a cruddy manner.
+"""
+
+import pathlib
+from typing import Any, Dict, List, Optional, cast
+
+import typer
+
+from jobbergate_cli.constants import SortOrder
+from jobbergate_cli.exceptions import Abort, handle_abort
+from jobbergate_cli.render import StyleMapper, render_list_results, render_single_result, terminal_message
+from jobbergate_cli.requests import make_request
+from jobbergate_cli.schemas import (
+    JobbergateContext,
+    JobScriptCreateRequestData,
+    JobScriptResponse,
+    ListResponseEnvelope,
+)
+from jobbergate_cli.subapps.applications.tools import execute_application, fetch_application_data, load_application_data
+from jobbergate_cli.subapps.job_scripts.tools import fetch_job_script_data, validate_parameter_file
+from jobbergate_cli.subapps.job_submissions.app import HIDDEN_FIELDS as JOB_SUBMISSION_HIDDEN_FIELDS
+from jobbergate_cli.subapps.job_submissions.tools import create_job_submission
+from jobbergate_cli.text_tools import dedent
+
+
+# move hidden field logic to the API
+HIDDEN_FIELDS = [
+    "created_at",
+    "updated_at",
+    "job_script_data_as_string",
+]
+
+
+style_mapper = StyleMapper(
+    id="green",
+    job_script_name="cyan",
+)
+
+
+app = typer.Typer(help="Commands to interact with job scripts")
+
+
+@app.command()
+@handle_abort
+def list_all(
+    ctx: typer.Context,
+    show_all: bool = typer.Option(False, "--all", help="Show all job scripts, even the ones owned by others"),
+    search: Optional[str] = typer.Option(None, help="Apply a search term to results"),
+    sort_order: SortOrder = typer.Option(SortOrder.UNSORTED, help="Specify sort order"),
+    sort_field: Optional[str] = typer.Option(None, help="The field by which results should be sorted"),
+):
+    """
+    Show available job scripts
+    """
+    jg_ctx: JobbergateContext = ctx.obj
+
+    # Make static type checkers happy
+    assert jg_ctx is not None
+    assert jg_ctx.client is not None
+
+    params: Dict[str, Any] = dict(all=show_all)
+    if search is not None:
+        params["search"] = search
+    if sort_order is not SortOrder.UNSORTED:
+        params["sort_ascending"] = SortOrder is SortOrder.ASCENDING
+    if sort_field is not None:
+        params["sort_field"] = sort_field
+
+    envelope = cast(
+        ListResponseEnvelope,
+        make_request(
+            jg_ctx.client,
+            "/job-scripts",
+            "GET",
+            expected_status=200,
+            abort_message="Couldn't retrieve job scripts list from API",
+            support=True,
+            response_model=ListResponseEnvelope,
+            params=params,
+        ),
+    )
+    render_list_results(
+        jg_ctx,
+        envelope,
+        title="Job Scripts List",
+        style_mapper=style_mapper,
+        hidden_fields=HIDDEN_FIELDS,
+    )
+
+
+@app.command()
+@handle_abort
+def get_one(
+    ctx: typer.Context,
+    id: int = typer.Option(int, help="The specific id of the job script."),
+):
+    """
+    Get a single job script by id.
+    """
+    jg_ctx: JobbergateContext = ctx.obj
+    result = fetch_job_script_data(jg_ctx, id)
+    render_single_result(
+        jg_ctx,
+        result,
+        hidden_fields=HIDDEN_FIELDS,
+        title="Job Script",
+    )
+
+
+@app.command()
+@handle_abort
+def create(
+    ctx: typer.Context,
+    name: str = typer.Option(
+        ...,
+        help="The name of the job script to create.",
+    ),
+    application_id: Optional[int] = typer.Option(
+        None,
+        help="The id of the application from which to create the job script.",
+    ),
+    application_identifier: Optional[str] = typer.Option(
+        None,
+        help="The identifier of the application from which to create the job script.",
+    ),
+    sbatch_params: Optional[List[str]] = typer.Option(
+        None,
+        help="Optional parameter to submit raw sbatch parameters.",
+    ),
+    param_file: Optional[pathlib.Path] = typer.Option(
+        None,
+        help=dedent(
+            """
+            Supply a yaml file that contains the parameters for populating templates.
+            If this is not supplied, the question asking in the application is triggered.
+            """
+        ),
+    ),
+    fast: bool = typer.Option(
+        False,
+        help="Use default answers (when available) instead of asking the user.",
+    ),
+    submit: bool = typer.Option(
+        True,
+        help="Do not ask the user if they want to submit a job.",
+    ),
+):
+    """
+    Create a new job script.
+    """
+    jg_ctx: JobbergateContext = ctx.obj
+
+    # Make static type checkers happy
+    assert jg_ctx.client is not None
+
+    app_data = fetch_application_data(jg_ctx, id=application_id, identifier=application_identifier)
+    (app_config, app_module) = load_application_data(app_data)
+
+    request_data = JobScriptCreateRequestData(
+        application_id=app_data.id,
+        job_script_name=name,
+        sbatch_params=sbatch_params,
+        param_dict=app_config,
+    )
+
+    supplied_params = validate_parameter_file(param_file) if param_file else dict()
+    execute_application(app_module, app_config, supplied_params, fast_mode=fast)
+
+    if app_config.jobbergate_config.job_script_name is not None:
+        request_data.job_script_name = app_config.jobbergate_config.job_script_name
+
+    job_script_result = cast(
+        JobScriptResponse,
+        make_request(
+            jg_ctx.client,
+            "/job-scripts",
+            "POST",
+            expected_status=201,
+            abort_message="Couldn't create job script",
+            support=True,
+            content=request_data.json(),
+            response_model=JobScriptResponse,
+        ),
+    )
+    render_single_result(
+        jg_ctx,
+        job_script_result,
+        hidden_fields=HIDDEN_FIELDS,
+        title="Created Job Script",
+    )
+
+    if not fast:
+        submit = typer.confirm("Would you like to submit this job immediately?")
+
+    if not submit:
+        return
+
+    try:
+        job_submission_result = create_job_submission(jg_ctx, job_script_result.id, job_script_result.job_script_name)
+    except Exception as err:
+        raise Abort(
+            "Failed to immediately submit the job after job script creation.",
+            subject="Automatic job submission failed",
+            support=True,
+            log_message=f"There was an issue submitting the job immediately job_script_id={job_script_result.id}.",
+            original_error=err,
+        )
+
+    render_single_result(
+        jg_ctx,
+        job_submission_result,
+        hidden_fields=JOB_SUBMISSION_HIDDEN_FIELDS,
+        title="Created Job Submission (Fast Mode)",
+    )
+
+
+@app.command()
+@handle_abort
+def update(
+    ctx: typer.Context,
+    id: int = typer.Option(
+        ...,
+        help="The id of the job script to update",
+    ),
+    job_script: str = typer.Option(
+        ...,
+        help="""
+            The data with which to update job script.
+
+            Format: string form of dictionary with main script as entry "application.sh"
+
+            Example: '{"application.sh":"#!/bin/bash \\n hostname"}'
+        """,
+    ),
+):
+    """
+    Update an existing job script.
+    """
+    jg_ctx: JobbergateContext = ctx.obj
+
+    # Make static type checkers happy
+    assert jg_ctx.client is not None
+
+    job_script_result = cast(
+        JobScriptResponse,
+        make_request(
+            jg_ctx.client,
+            f"/job-scripts/{id}",
+            "PUT",
+            expected_status=200,
+            abort_message="Couldn't update job script",
+            support=True,
+            json=dict(job_script_data_as_string=job_script),
+            response_model=JobScriptResponse,
+        ),
+    )
+    render_single_result(
+        jg_ctx,
+        job_script_result,
+        hidden_fields=HIDDEN_FIELDS,
+        title="Updated Job Script",
+    )
+
+
+@app.command()
+@handle_abort
+def delete(
+    ctx: typer.Context,
+    id: int = typer.Option(
+        ...,
+        help="The id of the job script to delete",
+    ),
+):
+    """
+    Delete an existing job script.
+    """
+    jg_ctx: JobbergateContext = ctx.obj
+
+    # Make static type checkers happy
+    assert jg_ctx.client is not None
+
+    make_request(
+        jg_ctx.client,
+        f"/job-scripts/{id}",
+        "DELETE",
+        expected_status=204,
+        abort_message="Request to delete job script was not accepted by the API",
+        support=True,
+    )
+    terminal_message(
+        "The job script was successfully deleted.",
+        subject="Job script delete succeeded",
+    )
diff --git a/jobbergate-cli/jobbergate_cli/subapps/job_scripts/tools.py b/jobbergate-cli/jobbergate_cli/subapps/job_scripts/tools.py
new file mode 100644
index 00000000..06514b73
--- /dev/null
+++ b/jobbergate-cli/jobbergate_cli/subapps/job_scripts/tools.py
@@ -0,0 +1,68 @@
+"""
+Provide tool functions for working with Job Script data
+"""
+
+import json
+import pathlib
+from typing import Any, Dict, cast
+
+from jobbergate_cli.exceptions import Abort
+from jobbergate_cli.requests import make_request
+from jobbergate_cli.schemas import JobbergateContext, JobScriptResponse
+
+
+def validate_parameter_file(parameter_path: pathlib.Path) -> Dict[str, Any]:
+    """
+    Validate parameter file at the supplied path and returns the parsed dict.
+
+    Confirms:
+        parameter_path exists
+        parameter_path is a valid json file
+    """
+    data = None
+    with Abort.check_expressions(
+        f"The parameter file at {parameter_path} was invalid",
+        raise_kwargs=dict(
+            subject="Invalid parameter file",
+            log_message=f"Parameter file located at {parameter_path} failed validation",
+        ),
+    ) as checker:
+        checker(
+            parameter_path.exists(),
+            f"Parameter file {parameter_path} does not exist",
+        )
+
+        try:
+            data = json.loads(parameter_path.read_text())
+            is_valid_json = True
+        except Exception:
+            is_valid_json = False
+        checker(is_valid_json, f"The parameter file at {parameter_path} is not valid JSON")
+
+    # Make static type checkers happy
+    assert data is not None
+    return data
+
+
+def fetch_job_script_data(
+    jg_ctx: JobbergateContext,
+    id: int,
+) -> JobScriptResponse:
+    """
+    Retrieve a job_script from the API by ``id``
+    """
+    # Make static type checkers happy
+    assert jg_ctx.client is not None
+
+    return cast(
+        JobScriptResponse,
+        make_request(
+            jg_ctx.client,
+            f"/job-scripts/{id}",
+            "GET",
+            expected_status=200,
+            abort_message=f"Couldn't retrieve job script ({id}) from API",
+            support=True,
+            response_model=JobScriptResponse,
+        ),
+    )
diff --git a/jobbergate-cli/jobbergate_cli/subapps/job_submissions/__init__.py b/jobbergate-cli/jobbergate_cli/subapps/job_submissions/__init__.py
new file mode 100644
index 00000000..5cec7ad6
--- /dev/null
+++ b/jobbergate-cli/jobbergate_cli/subapps/job_submissions/__init__.py
@@ -0,0 +1,3 @@
+"""
+Provide a sub-app for interacting with Job Submission data.
+"""
diff --git a/jobbergate-cli/jobbergate_cli/subapps/job_submissions/app.py b/jobbergate-cli/jobbergate_cli/subapps/job_submissions/app.py
new file mode 100644
index 00000000..64b35874
--- /dev/null
+++ b/jobbergate-cli/jobbergate_cli/subapps/job_submissions/app.py
@@ -0,0 +1,162 @@
+"""
+Provide a ``typer`` app that can interact with Job Submission data in a cruddy manner.
+"""
+
+from typing import Any, Dict, Optional, cast
+
+import typer
+
+from jobbergate_cli.constants import SortOrder
+from jobbergate_cli.exceptions import handle_abort
+from jobbergate_cli.render import StyleMapper, render_list_results, render_single_result, terminal_message
+from jobbergate_cli.requests import make_request
+from jobbergate_cli.schemas import JobbergateContext, ListResponseEnvelope
+from jobbergate_cli.subapps.job_submissions.tools import create_job_submission, fetch_job_submission_data
+
+
+# move hidden field logic to the API
+HIDDEN_FIELDS = [
+    "created_at",
+    "updated_at",
+]
+
+
+style_mapper = StyleMapper(id="green", job_script_name="cyan", slurm_job_id="dark_orange")
+
+
+app = typer.Typer(help="Commands to interact with job submissions")
+
+
+@app.command()
+@handle_abort
+def create(
+    ctx: typer.Context,
+    name: str = typer.Option(
+        ...,
+        help="The name of the job submission to create",
+    ),
+    description: Optional[str] = typer.Option(
+        None,
+        help="A helpful description of the job submission",
+    ),
+    job_script_id: int = typer.Option(
+        ...,
+        help="The id of the job_script from which to create the job submission",
+    ),
+):
+    """
+    Create a new job script.
+    """
+    jg_ctx: JobbergateContext = ctx.obj
+
+    # Make static type checkers happy
+    assert jg_ctx.client is not None, "Client is uninitialized"
+
+    result = create_job_submission(jg_ctx, job_script_id, name, description=description)
+    render_single_result(
+        jg_ctx,
+        result,
+        hidden_fields=HIDDEN_FIELDS,
+        title="Created Job Submission",
+    )
+
+
+@app.command()
+@handle_abort
+def list_all(
+    ctx: typer.Context,
+    show_all: bool = typer.Option(False, "--all", help="Show all job submissions, even the ones owned by others"),
+    search: Optional[str] = typer.Option(None, help="Apply a search term to results"),
+    sort_order: SortOrder = typer.Option(SortOrder.UNSORTED, help="Specify sort order"),
+    sort_field: Optional[str] = typer.Option(None, help="The field by which results should be sorted"),
+):
+    """
+    Show available job scripts
+    """
+    jg_ctx: JobbergateContext = ctx.obj
+
+    # Make static type checkers happy
+    assert jg_ctx is not None, "JobbergateContext is uninitialized"
+    assert jg_ctx.client is not None, "Client is uninitialized"
+
+    params: Dict[str, Any] = dict(all=show_all)
+    if search is not None:
+        params["search"] = search
+    if sort_order is not SortOrder.UNSORTED:
+        params["sort_ascending"] = SortOrder is SortOrder.ASCENDING
+    if sort_field is not None:
+        params["sort_field"] = sort_field
+
+    envelope = cast(
+        ListResponseEnvelope,
+        make_request(
+            jg_ctx.client,
+            "/job-submissions",
+            "GET",
+            expected_status=200,
+            abort_message="Couldn't retrieve job submissions list from API",
+            support=True,
+            response_model=ListResponseEnvelope,
+            params=params,
+        ),
+    )
+    render_list_results(
+        jg_ctx,
+        envelope,
+        title="Job Submission List",
+        style_mapper=style_mapper,
+        hidden_fields=HIDDEN_FIELDS,
+    )
+
+
+@app.command()
+@handle_abort
+def get_one(
+    ctx: typer.Context,
+    id: int = typer.Option(int, help="The specific id of the job submission."),
+):
+    """
+    Get a single job submission by id
+    """
+    jg_ctx: JobbergateContext = ctx.obj
+    result = fetch_job_submission_data(jg_ctx, id)
+    render_single_result(
+        jg_ctx,
+        result,
+        hidden_fields=HIDDEN_FIELDS,
+        title="Job Submission",
+    )
+
+
+# NOTE: job submissions update is not added because it was an effective noop on the former implementation
+
+
+@app.command()
+@handle_abort
+def delete(
+    ctx: typer.Context,
+    id: int = typer.Option(
+        ...,
+        help="The id of the job submission to delete",
+    ),
+):
+    """
+    Delete an existing job submission.
+    """
+    jg_ctx: JobbergateContext = ctx.obj
+
+    # Make static type checkers happy
+    assert jg_ctx.client is not None, "Client is uninitialized"
+
+    make_request(
+        jg_ctx.client,
+        f"/job-submissions/{id}",
+        "DELETE",
+        expected_status=204,
+        abort_message="Request to delete job submission was not accepted by the API",
+        support=True,
+    )
+    terminal_message(
+        "The job submission was successfully deleted.",
+        subject="Job submission delete succeeded",
+    )
diff --git a/jobbergate-cli/jobbergate_cli/subapps/job_submissions/tools.py b/jobbergate-cli/jobbergate_cli/subapps/job_submissions/tools.py
new file mode 100644
index 00000000..00d1e5b6
--- /dev/null
+++ b/jobbergate-cli/jobbergate_cli/subapps/job_submissions/tools.py
@@ -0,0 +1,194 @@
+"""
+Provide tool functions for working with Job Submission data
+"""
+
+import copy
+import json
+import pathlib
+import subprocess
+import tempfile
+from typing import Any, Dict, Optional, cast
+
+from jobbergate_cli.config import settings
+from jobbergate_cli.constants import JOBBERGATE_JOB_SUBMISSION_CONFIG
+from jobbergate_cli.exceptions import Abort
+from jobbergate_cli.requests import make_request
+from jobbergate_cli.schemas import JobbergateContext, JobScriptResponse, JobSubmissionResponse
+from jobbergate_cli.subapps.applications.tools import fetch_application_data
+from jobbergate_cli.subapps.job_scripts.tools import fetch_job_script_data
+from jobbergate_cli.text_tools import dedent
+
+
+def load_default_config() -> Dict[str, Any]:
+    """
+    Load the default config for a job submission.
+    """
+    return copy.deepcopy(JOBBERGATE_JOB_SUBMISSION_CONFIG)
+
+
+def run_job_script(
+    job_script_data: JobScriptResponse,
+    application_name: str,
+    build_path: Optional[pathlib.Path] = None,
+) -> int:
+    """
+    Submit a Job Script to slurm via ``sbatch``.
+
+    :param: job_script_data: A JobScriptResponse including the script and its configuration
+    :param: application_name: The name of the application to pass to ``sbatch``
+    :param: build_path:       An optional directory where the job script templates and job script should be unpacked
+    :returns: The ``slurm_job_id`` for the submitted job
+    """
+    Abort.require_condition(
+        settings.SBATCH_PATH.exists(),
+        dedent(
+            f"""
+            sbatch executable was not found at {settings.SBATCH_PATH}.
+            Please confirm that sbatch is installed and the environment variable SBATCH_PATH directs to it.
+            """
+        ),
+        raise_kwargs=dict(
+            subject="Sbatch executable not found",
+            support=True,
+        ),
+    )
+
+    job_script_name = job_script_data.job_script_name
+    unpacked_data = json.loads(job_script_data.job_script_data_as_string)
+
+    if build_path is None:
+        temp_build_dir = tempfile.TemporaryDirectory()
+        build_path = pathlib.Path(temp_build_dir.name)
+    else:
+        Abort.require_condition(
+            build_path.exists(),
+            dedent(
+                f"""
+                The supplied build directory does not exist: {build_path}.
+                """
+            ),
+            raise_kwargs=dict(
+                subject="Build dir does not exist",
+                support=True,
+            ),
+        )
+
+    script_path = None
+    for (filename, data) in unpacked_data.items():
+        if filename == "application.sh":
+            filename = f"{job_script_name}.job"
+            script_path = build_path / filename
+
+        file_path = build_path / filename
+        file_path.write_text(data)
+
+    Abort.require_condition(
+        script_path is not None,
+        dedent(
+            f"""
+            Could not find an executable script in retrieved job script data.
+            It's likely that this job_script ({job_script_data.id}) is broken.
+            """,
+        ),
+        raise_kwargs=dict(
+            support=True,
+            subject="Invalid job script",
+            log_message="job_script {job_script_id} is missing `application.sh`. It cannot be submitted.",
+        ),
+    )
+
+    # Make static type checkers happy
+    assert settings.SBATCH_PATH is not None
+    assert script_path is not None
+
+    cmd = [settings.SBATCH_PATH, script_path, application_name]
+    proc = subprocess.run(cmd, capture_output=True, text=True, input="sbatch output")
+
+    Abort.require_condition(
+        proc.returncode == 0,
+        f"Failed to execute job submission with error: {proc.stderr}.",
+        raise_kwargs=dict(
+            subject="Job execution failed",
+            support=True,
+        ),
+    )
+    slurm_job_id = int(proc.stdout.split()[-1])
+    return slurm_job_id
+
+
+def create_job_submission(
+    jg_ctx: JobbergateContext,
+    job_script_id: int,
+    name: str,
+    description: Optional[str] = None,
+) -> JobSubmissionResponse:
+    """
+    Creae a Job Submission from the given Job Script.
+
+    :param: jg_ctx:        The JobbergateContext. Used to retrieve the client for requests
+                           and the email of the submitting user
+    :param: job_script_id: The ``id`` of the Job Script to submit to Slurm
+    :param: name:          The name to attach to the Job Submission
+    :param: description:   An optional description that may be added to the Job Submission
+    :returns: The Job Submission data returned by the API after creating the new Job Submission
+    """
+
+    # Make static type checkers happy
+    assert jg_ctx.client is not None, "jg_ctx.client is uninitialized"
+    assert jg_ctx.persona is not None, "jg_ctx.persona is uninitialized"
+
+    job_script_data = fetch_job_script_data(jg_ctx, job_script_id)
+
+    application_id = job_script_data.application_id
+    application_data = fetch_application_data(jg_ctx, id=application_id)
+    application_name = application_data.application_name
+
+    slurm_job_id = run_job_script(job_script_data, application_name)
+    owner_email = jg_ctx.persona.identity_data.user_email
+
+    job_submission_data = dict(
+        job_submission_name=name,
+        job_submission_description=description,
+        job_submission_owner_email=owner_email,
+        job_script_id=job_script_id,
+        slurm_job_id=slurm_job_id,
+    )
+
+    result = cast(
+        JobSubmissionResponse,
+        make_request(
+            jg_ctx.client,
+            "/job-submissions",
+            "POST",
+            expected_status=201,
+            abort_message="Couldn't create job submission",
+            support=True,
+            json=job_submission_data,
+            response_model=JobSubmissionResponse,
+        ),
+    )
+    return result
+
+
+def fetch_job_submission_data(
+    jg_ctx: JobbergateContext,
+    job_submission_id: int,
+) -> JobSubmissionResponse:
+    """
+    Retrieve a job submission from the API by ``id``
+    """
+    # Make static type checkers happy
+    assert jg_ctx.client is not None, "Client is uninitialized"
+
+    return cast(
+        JobSubmissionResponse,
+        make_request(
+            jg_ctx.client,
+            f"/job-submissions/{job_submission_id}",
+            "GET",
+            expected_status=200,
+            abort_message=f"Couldn't retrieve job submission {job_submission_id} from API",
+            support=True,
+            response_model=JobSubmissionResponse,
+        ),
+    )
diff --git a/jobbergate-cli/jobbergate_cli/text_tools.py b/jobbergate-cli/jobbergate_cli/text_tools.py
new file mode 100644
index 00000000..d9ddfa3e
--- /dev/null
+++ b/jobbergate-cli/jobbergate_cli/text_tools.py
@@ -0,0 +1,44 @@
+"""
+Provide some basic tools for manipulating text.
+"""
+
+import textwrap
+
+
+def dedent(text: str) -> str:
+    """
+    Dedents a paragraph after removing leading and trailing whitespace.
+    """
+    return textwrap.dedent(text).strip()
+
+
+def unwrap(text: str) -> str:
+    """
+    Unwraps a paragraph of text into a single line.
+
+    The text may be indented.
+    """
+    return " ".join(dedent(text).split("\n"))
+
+
+def conjoin(*items: str, join_str: str = "\n") -> str:
+    """
+    Joins strings supplied as args.
+
+    Helper that wraps ``str.join()`` without having to pack strings in an iterable.
+    """
+    return join_str.join(items)
+
+
+def dedent_all(*texts: str, join_str: str = "\n") -> str:
+    """
+    Dedents each blob supplied as an argument and then joins them.
+    """
+    return conjoin(*(dedent(b) for b in texts), join_str=join_str)
+
+
+def indent(text: str, prefix: str = "    ", **kwargs) -> str:
+    """
+    Simple wrapper for the textwrap.indent() method but includes a default prefix.
+    """
+    return textwrap.indent(text, prefix=prefix, **kwargs)
diff --git a/jobbergate-cli/jobbergate_cli/time_loop.py b/jobbergate-cli/jobbergate_cli/time_loop.py
new file mode 100644
index 00000000..7b6576a4
--- /dev/null
+++ b/jobbergate-cli/jobbergate_cli/time_loop.py
@@ -0,0 +1,127 @@
+"""
+Provide a time-loop class that can be used to to iterate during a given window of time.
+"""
+
+from typing import Optional, Union
+
+import pendulum
+import pydantic
+from rich.progress import Progress
+
+from jobbergate_cli.exceptions import JobbergateCliError
+
+
+class Tick(pydantic.BaseModel):
+    """
+    A helper class describing a "tick".
+
+    Contains a counter, elapsed time since the last tick, and total elapsed time.
+    """
+
+    counter: int
+    elapsed: pendulum.Duration
+    total_elapsed: pendulum.Duration
+
+
+class TimeLoop:
+    """
+    A special iterator that will iterate for a specified duration of time.
+
+    Uses a progress meter to show the user how much time is left.
+    Each iteration of the time-loop produces a tick.
+    """
+
+    advent: Optional[pendulum.DateTime]
+    moment: Optional[pendulum.DateTime]
+    last_moment: Optional[pendulum.DateTime]
+    counter: int
+    progress: Optional[Progress]
+    duration: pendulum.Duration
+    message: str
+    color: str
+
+    def __init__(
+        self,
+        duration: Union[pendulum.Duration, int],
+        message: str = "Processing",
+        color: str = "green",
+    ):
+        """
+        Initialize the time-loop.
+
+        Duration may be either a count of seconds or a ``pendulum.duration``.
+        """
+        self.moment = None
+        self.last_moment = None
+        self.counter = 0
+        self.progress = None
+        if isinstance(duration, int):
+            JobbergateCliError.require_condition(duration > 0, "The duration must be a positive integer")
+            self.duration = pendulum.duration(seconds=duration)
+        else:
+            self.duration = duration
+        self.message = message
+        self.color = color
+
+    def __del__(self):
+        """
+        Explicitly clear the progress meter if the time-loop is destroyed.
+        """
+        self.clear()
+
+    def __iter__(self) -> "TimeLoop":
+        """
+        Start the iterator.
+
+        Creates and starts the progress meter
+        """
+        self.advent = self.last_moment = self.moment = pendulum.now()
+        self.counter = 0
+        self.progress = Progress()
+        self.progress.add_task(
+            f"[{self.color}]{self.message}...",
+            total=self.duration.total_seconds(),
+        )
+        self.progress.start()
+        return self
+
+    def __next__(self) -> Tick:
+        """
+        Iterates the time loop and returns a tick.
+
+        If the duration is complete, clear the progress meter and stop iteration.
+        """
+        # Keep mypy happy
+        assert self.progress is not None
+
+        self.counter += 1
+        self.last_moment = self.moment
+        self.moment: pendulum.DateTime = pendulum.now()
+        elapsed: pendulum.Duration = self.moment - self.last_moment
+        total_elapsed: pendulum.Duration = self.moment - self.advent
+
+        for task_id in self.progress.task_ids:
+            self.progress.advance(task_id, elapsed.total_seconds())
+
+        if self.progress.finished:
+            self.clear()
+            raise StopIteration
+
+        return Tick(
+            counter=self.counter,
+            elapsed=elapsed,
+            total_elapsed=total_elapsed,
+        )
+
+    def clear(self):
+        """
+        Clear the time-loop.
+
+        Stops the progress meter (if it is set) and reset moments, counter, progress meter.
+        """
+        if self.progress is not None:
+            self.progress.stop()
+        self.counter = 0
+        self.progress = None
+        self.moment = None
+        self.last_moment = None
diff --git a/jobbergate-cli/poetry.lock b/jobbergate-cli/poetry.lock
index 78fb7ffe..a7adfe85 100644
--- a/jobbergate-cli/poetry.lock
+++ b/jobbergate-cli/poetry.lock
@@ -17,6 +17,42 @@ category = "main"
 optional = false
 python-versions = "*"
 
+[[package]]
+name = "anyio"
+version = "3.5.0"
+description = "High level compatibility layer for multiple asynchronous event loop implementations"
+category = "main"
+optional = false
+python-versions = ">=3.6.2"
+
+[package.dependencies]
+contextvars = {version = "*", markers = "python_version < \"3.7\""}
+dataclasses = {version = "*", markers = "python_version < \"3.7\""}
+idna = ">=2.8"
+sniffio = ">=1.1"
+typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
+
+[package.extras]
+doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"]
+test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"]
+trio = ["trio (>=0.16)"]
+
+[[package]]
+name = "appnope"
+version = "0.1.2"
+description = "Disable App Nap on macOS >= 10.9"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "async-generator"
+version = "1.10"
+description = "Async generators and context managers for Python 3.5+"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
 [[package]]
 name = "atomicwrites"
 version = "1.4.0"
@@ -27,21 +63,29 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
 [[package]]
 name = "attrs"
-version = "21.2.0"
+version = "21.4.0"
 description = "Classes Without Boilerplate"
 category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
 [package.extras]
-dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"]
+dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
 docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
-tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"]
-tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"]
+tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
+tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"]
+
+[[package]]
+name = "backcall"
+version = "0.2.0"
+description = "Specifications for callback functions passed in to an API"
+category = "dev"
+optional = false
+python-versions = "*"
 
 [[package]]
 name = "black"
-version = "21.10b0"
+version = "21.12b0"
 description = "The uncompromising code formatter."
 category = "dev"
 optional = false
@@ -53,9 +97,8 @@ dataclasses = {version = ">=0.6", markers = "python_version < \"3.7\""}
 mypy-extensions = ">=0.4.3"
 pathspec = ">=0.9.0,<1"
 platformdirs = ">=2"
-regex = ">=2020.1.8"
 tomli = ">=0.2.6,<2.0.0"
-typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\""}
+typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""}
 typing-extensions = [
     {version = ">=3.10.0.0", markers = "python_version < \"3.10\""},
     {version = "!=3.10.0.1", markers = "python_version >= \"3.10\""},
@@ -70,28 +113,28 @@ uvloop = ["uvloop (>=0.15.2)"]
 
 [[package]]
 name = "blessed"
-version = "1.17.6"
+version = "1.19.0"
 description = "Easy, practical library for making terminal apps, by providing an elegant, well-documented interface to Colors, Keyboard input, and screen Positioning capabilities."
 category = "main"
 optional = false
-python-versions = "*"
+python-versions = ">=2.7"
 
 [package.dependencies]
-jinxed = {version = ">=0.5.4", markers = "platform_system == \"Windows\""}
+jinxed = {version = ">=1.1.0", markers = "platform_system == \"Windows\""}
 six = ">=1.9.0"
 wcwidth = ">=0.1.4"
 
 [[package]]
 name = "boto3"
-version = "1.20.2"
+version = "1.21.25"
 description = "The AWS SDK for Python"
 category = "main"
 optional = false
 python-versions = ">= 3.6"
 
 [package.dependencies]
-botocore = ">=1.23.2,<1.24.0"
-jmespath = ">=0.7.1,<1.0.0"
+botocore = ">=1.24.25,<1.25.0"
+jmespath = ">=0.7.1,<2.0.0"
 s3transfer = ">=0.5.0,<0.6.0"
 
 [package.extras]
@@ -99,19 +142,19 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
 
 [[package]]
 name = "botocore"
-version = "1.23.2"
+version = "1.24.25"
 description = "Low-level, data-driven core of boto 3."
 category = "main"
 optional = false
 python-versions = ">= 3.6"
 
 [package.dependencies]
-jmespath = ">=0.7.1,<1.0.0"
+jmespath = ">=0.7.1,<2.0.0"
 python-dateutil = ">=2.1,<3.0.0"
 urllib3 = ">=1.25.4,<1.27"
 
 [package.extras]
-crt = ["awscrt (==0.12.5)"]
+crt = ["awscrt (==0.13.5)"]
 
 [[package]]
 name = "certifi"
@@ -123,7 +166,7 @@ python-versions = "*"
 
 [[package]]
 name = "charset-normalizer"
-version = "2.0.7"
+version = "2.0.12"
 description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
 category = "main"
 optional = false
@@ -134,7 +177,7 @@ unicode_backport = ["unicodedata2"]
 
 [[package]]
 name = "click"
-version = "8.0.3"
+version = "8.0.4"
 description = "Composable command line interface toolkit"
 category = "main"
 optional = false
@@ -152,6 +195,17 @@ category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
+[[package]]
+name = "commonmark"
+version = "0.9.1"
+description = "Python parser for the CommonMark Markdown spec"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.extras]
+test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"]
+
 [[package]]
 name = "contextvars"
 version = "2.4"
@@ -185,6 +239,14 @@ category = "main"
 optional = false
 python-versions = ">=3.6, <3.7"
 
+[[package]]
+name = "decorator"
+version = "5.1.1"
+description = "Decorators for Humans"
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+
 [[package]]
 name = "ecdsa"
 version = "0.17.0"
@@ -202,29 +264,77 @@ gmpy2 = ["gmpy2"]
 
 [[package]]
 name = "flake8"
-version = "3.9.2"
+version = "4.0.1"
 description = "the modular source code checker: pep8 pyflakes and co"
 category = "dev"
 optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+python-versions = ">=3.6"
 
 [package.dependencies]
-importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
+importlib-metadata = {version = "<4.3", markers = "python_version < \"3.8\""}
 mccabe = ">=0.6.0,<0.7.0"
-pycodestyle = ">=2.7.0,<2.8.0"
-pyflakes = ">=2.3.0,<2.4.0"
+pycodestyle = ">=2.8.0,<2.9.0"
+pyflakes = ">=2.4.0,<2.5.0"
 
 [[package]]
 name = "freezegun"
-version = "1.1.0"
+version = "1.2.1"
 description = "Let your Python tests travel through time"
 category = "dev"
 optional = false
-python-versions = ">=3.5"
+python-versions = ">=3.6"
 
 [package.dependencies]
 python-dateutil = ">=2.7"
 
+[[package]]
+name = "h11"
+version = "0.12.0"
+description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "httpcore"
+version = "0.14.7"
+description = "A minimal low-level HTTP client."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+anyio = ">=3.0.0,<4.0.0"
+certifi = "*"
+h11 = ">=0.11,<0.13"
+sniffio = ">=1.0.0,<2.0.0"
+
+[package.extras]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (>=1.0.0,<2.0.0)"]
+
+[[package]]
+name = "httpx"
+version = "0.22.0"
+description = "The next generation HTTP client."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+async-generator = {version = "*", markers = "python_version < \"3.7\""}
+certifi = "*"
+charset-normalizer = "*"
+httpcore = ">=0.14.5,<0.15.0"
+rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]}
+sniffio = "*"
+
+[package.extras]
+brotli = ["brotlicffi", "brotli"]
+cli = ["click (>=8.0.0,<9.0.0)", "rich (>=10.0.0,<11.0.0)", "pygments (>=2.0.0,<3.0.0)"]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (>=1.0.0,<2.0.0)"]
+
 [[package]]
 name = "idna"
 version = "3.3"
@@ -249,7 +359,7 @@ test = ["flake8 (>=3.8.4,<3.9.0)", "pycodestyle (>=2.6.0,<2.7.0)", "mypy (>=0.91
 
 [[package]]
 name = "importlib-metadata"
-version = "4.8.2"
+version = "4.2.0"
 description = "Read metadata from Python packages"
 category = "main"
 optional = false
@@ -261,8 +371,7 @@ zipp = ">=0.5"
 
 [package.extras]
 docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
-perf = ["ipython"]
-testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
+testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"]
 
 [[package]]
 name = "iniconfig"
@@ -274,17 +383,56 @@ python-versions = "*"
 
 [[package]]
 name = "inquirer"
-version = "2.7.0"
+version = "2.8.0"
 description = "Collection of common interactive command line user interfaces, based on Inquirer.js"
 category = "main"
 optional = false
 python-versions = "*"
 
 [package.dependencies]
-blessed = "1.17.6"
+blessed = "1.19.0"
 python-editor = "1.0.4"
 readchar = "2.0.1"
 
+[[package]]
+name = "ipython"
+version = "7.16.3"
+description = "IPython: Productive Interactive Computing"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+appnope = {version = "*", markers = "sys_platform == \"darwin\""}
+backcall = "*"
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+decorator = "*"
+jedi = ">=0.10,<=0.17.2"
+pexpect = {version = "*", markers = "sys_platform != \"win32\""}
+pickleshare = "*"
+prompt-toolkit = ">=2.0.0,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.1.0"
+pygments = "*"
+traitlets = ">=4.2"
+
+[package.extras]
+all = ["Sphinx (>=1.3)", "ipykernel", "ipyparallel", "ipywidgets", "nbconvert", "nbformat", "nose (>=0.10.1)", "notebook", "numpy (>=1.14)", "pygments", "qtconsole", "requests", "testpath"]
+doc = ["Sphinx (>=1.3)"]
+kernel = ["ipykernel"]
+nbconvert = ["nbconvert"]
+nbformat = ["nbformat"]
+notebook = ["notebook", "ipywidgets"]
+parallel = ["ipyparallel"]
+qtconsole = ["qtconsole"]
+test = ["nose (>=0.10.1)", "requests", "testpath", "pygments", "nbformat", "ipykernel", "numpy (>=1.14)"]
+
+[[package]]
+name = "ipython-genutils"
+version = "0.2.0"
+description = "Vestigial utilities from IPython"
+category = "dev"
+optional = false
+python-versions = "*"
+
 [[package]]
 name = "isort"
 version = "5.10.1"
@@ -299,6 +447,21 @@ requirements_deprecated_finder = ["pipreqs", "pip-api"]
 colors = ["colorama (>=0.4.3,<0.5.0)"]
 plugins = ["setuptools"]
 
+[[package]]
+name = "jedi"
+version = "0.17.2"
+description = "An autocompletion tool for Python that can be used for text editors."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.dependencies]
+parso = ">=0.7.0,<0.8.0"
+
+[package.extras]
+qa = ["flake8 (==3.7.9)"]
+testing = ["Django (<3.1)", "colorama", "docopt", "pytest (>=3.9.0,<5.0.0)"]
+
 [[package]]
 name = "jinxed"
 version = "1.1.0"
@@ -350,6 +513,24 @@ category = "main"
 optional = false
 python-versions = ">=3.6"
 
+[[package]]
+name = "mypy"
+version = "0.931"
+description = "Optional static typing for Python"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+mypy-extensions = ">=0.4.3"
+tomli = ">=1.1.0"
+typed-ast = {version = ">=1.4.0,<2", markers = "python_version < \"3.8\""}
+typing-extensions = ">=3.10"
+
+[package.extras]
+dmypy = ["psutil (>=4.0)"]
+python2 = ["typed-ast (>=1.4.0,<2)"]
+
 [[package]]
 name = "mypy-extensions"
 version = "0.4.3"
@@ -360,14 +541,25 @@ python-versions = "*"
 
 [[package]]
 name = "packaging"
-version = "21.2"
+version = "21.3"
 description = "Core utilities for Python packages"
 category = "dev"
 optional = false
 python-versions = ">=3.6"
 
 [package.dependencies]
-pyparsing = ">=2.0.2,<3"
+pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
+
+[[package]]
+name = "parso"
+version = "0.7.1"
+description = "A Python Parser"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.extras]
+testing = ["docopt", "pytest (>=3.0.7)"]
 
 [[package]]
 name = "pathspec"
@@ -377,6 +569,37 @@ category = "dev"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
 
+[[package]]
+name = "pendulum"
+version = "2.1.2"
+description = "Python datetimes made easy"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.dependencies]
+python-dateutil = ">=2.6,<3.0"
+pytzdata = ">=2020.1"
+
+[[package]]
+name = "pexpect"
+version = "4.8.0"
+description = "Pexpect allows easy control of interactive console applications."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+ptyprocess = ">=0.5"
+
+[[package]]
+name = "pickleshare"
+version = "0.7.5"
+description = "Tiny 'shelve'-like database with concurrency support"
+category = "dev"
+optional = false
+python-versions = "*"
+
 [[package]]
 name = "platformdirs"
 version = "2.4.0"
@@ -404,6 +627,41 @@ importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""}
 dev = ["pre-commit", "tox"]
 testing = ["pytest", "pytest-benchmark"]
 
+[[package]]
+name = "plummet"
+version = "1.1.0"
+description = "Utilities for testing with pendulum timestamps"
+category = "dev"
+optional = false
+python-versions = ">=3.6.2,<4.0.0"
+
+[package.dependencies]
+pendulum = ">=2.1.2,<3.0.0"
+py-buzz = ">=3.1.0,<4.0.0"
+time-machine = {version = ">=2.4.0,<3.0.0", optional = true, markers = "extra == \"time-machine\""}
+
+[package.extras]
+time-machine = ["time-machine (>=2.4.0,<3.0.0)"]
+
+[[package]]
+name = "prompt-toolkit"
+version = "3.0.28"
+description = "Library for building powerful interactive command lines in Python"
+category = "dev"
+optional = false
+python-versions = ">=3.6.2"
+
+[package.dependencies]
+wcwidth = "*"
+
+[[package]]
+name = "ptyprocess"
+version = "0.7.0"
+description = "Run a subprocess in a pseudo terminal"
+category = "dev"
+optional = false
+python-versions = "*"
+
 [[package]]
 name = "py"
 version = "1.11.0"
@@ -412,6 +670,17 @@ category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
+[[package]]
+name = "py-buzz"
+version = "3.1.0"
+description = "\"That's not flying, it's falling with style\": Exceptions with extras"
+category = "main"
+optional = false
+python-versions = ">=3.6.2,<4.0.0"
+
+[package.dependencies]
+dataclasses = {version = ">=0.8,<0.9", markers = "python_version >= \"3.6\" and python_version < \"3.7\""}
+
 [[package]]
 name = "pyasn1"
 version = "0.4.8"
@@ -422,15 +691,15 @@ python-versions = "*"
 
 [[package]]
 name = "pycodestyle"
-version = "2.7.0"
+version = "2.8.0"
 description = "Python style guide checker"
 category = "dev"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
 [[package]]
 name = "pydantic"
-version = "1.8.2"
+version = "1.9.0"
 description = "Data validation and settings management using python 3.6 type hinting"
 category = "main"
 optional = false
@@ -446,23 +715,54 @@ email = ["email-validator (>=1.0.3)"]
 
 [[package]]
 name = "pyflakes"
-version = "2.3.1"
+version = "2.4.0"
 description = "passive checker of Python programs"
 category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
+[[package]]
+name = "pygments"
+version = "2.11.2"
+description = "Pygments is a syntax highlighting package written in Python."
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
 [[package]]
 name = "pyparsing"
-version = "2.4.7"
+version = "3.0.7"
 description = "Python parsing module"
 category = "dev"
 optional = false
-python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+python-versions = ">=3.6"
+
+[package.extras]
+diagrams = ["jinja2", "railroad-diagrams"]
+
+[[package]]
+name = "pyperclip"
+version = "1.8.2"
+description = "A cross-platform clipboard module for Python. (Only handles plain text for now.)"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "pyproject-flake8"
+version = "0.0.1a2"
+description = "pyproject-flake8 (`pflake8`), a monkey patching wrapper to connect flake8 with pyproject.toml configuration"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+flake8 = "*"
+toml = "*"
 
 [[package]]
 name = "pytest"
-version = "6.2.5"
+version = "7.0.1"
 description = "pytest: simple powerful testing with Python"
 category = "dev"
 optional = false
@@ -477,10 +777,10 @@ iniconfig = "*"
 packaging = "*"
 pluggy = ">=0.12,<2.0"
 py = ">=1.8.2"
-toml = "*"
+tomli = ">=1.0.0"
 
 [package.extras]
-testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
+testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
 
 [[package]]
 name = "pytest-cov"
@@ -520,6 +820,20 @@ python-versions = "*"
 freezegun = ">0.3"
 pytest = ">=3.0.0"
 
+[[package]]
+name = "pytest-mock"
+version = "3.6.1"
+description = "Thin-wrapper around the mock package for easier use with pytest"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+pytest = ">=5.0"
+
+[package.extras]
+dev = ["pre-commit", "tox", "pytest-asyncio"]
+
 [[package]]
 name = "pytest-random-order"
 version = "1.0.4"
@@ -556,7 +870,7 @@ six = ">=1.5"
 
 [[package]]
 name = "python-dotenv"
-version = "0.19.1"
+version = "0.19.2"
 description = "Read key-value pairs from a .env file and set them as environment variables"
 category = "main"
 optional = false
@@ -591,6 +905,14 @@ cryptography = ["cryptography (>=3.4.0)"]
 pycrypto = ["pycrypto (>=2.6.0,<2.7.0)", "pyasn1"]
 pycryptodome = ["pycryptodome (>=3.3.1,<4.0.0)", "pyasn1"]
 
+[[package]]
+name = "pytzdata"
+version = "2020.1"
+description = "The Olson timezone database for Python."
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
 [[package]]
 name = "pyyaml"
 version = "5.4.1"
@@ -607,19 +929,11 @@ category = "main"
 optional = false
 python-versions = "*"
 
-[[package]]
-name = "regex"
-version = "2021.11.10"
-description = "Alternative regular expression module, to replace re."
-category = "dev"
-optional = false
-python-versions = "*"
-
 [[package]]
 name = "requests"
-version = "2.26.0"
+version = "2.27.1"
 description = "Python HTTP for Humans."
-category = "main"
+category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
 
@@ -635,7 +949,7 @@ use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
 
 [[package]]
 name = "responses"
-version = "0.15.0"
+version = "0.17.0"
 description = "A utility library for mocking out the `requests` Python library."
 category = "dev"
 optional = false
@@ -649,20 +963,63 @@ urllib3 = ">=1.25.10"
 [package.extras]
 tests = ["coverage (>=3.7.1,<6.0.0)", "pytest-cov", "pytest-localserver", "flake8", "types-mock", "types-requests", "types-six", "pytest (>=4.6,<5.0)", "pytest (>=4.6)", "mypy"]
 
+[[package]]
+name = "respx"
+version = "0.19.2"
+description = "A utility for mocking out the Python HTTPX and HTTP Core libraries."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+httpx = ">=0.21.0"
+
+[[package]]
+name = "rfc3986"
+version = "1.5.0"
+description = "Validating URI References per RFC 3986"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+idna = {version = "*", optional = true, markers = "extra == \"idna2008\""}
+
+[package.extras]
+idna2008 = ["idna"]
+
+[[package]]
+name = "rich"
+version = "11.2.0"
+description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
+category = "main"
+optional = false
+python-versions = ">=3.6.2,<4.0.0"
+
+[package.dependencies]
+colorama = ">=0.4.0,<0.5.0"
+commonmark = ">=0.9.0,<0.10.0"
+dataclasses = {version = ">=0.7,<0.9", markers = "python_version < \"3.7\""}
+pygments = ">=2.6.0,<3.0.0"
+typing-extensions = {version = ">=3.7.4,<5.0", markers = "python_version < \"3.8\""}
+
+[package.extras]
+jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"]
+
 [[package]]
 name = "rsa"
-version = "4.7.2"
+version = "4.8"
 description = "Pure-Python RSA implementation"
 category = "main"
 optional = false
-python-versions = ">=3.5, <4"
+python-versions = ">=3.6,<4"
 
 [package.dependencies]
 pyasn1 = ">=0.1.3"
 
 [[package]]
 name = "s3transfer"
-version = "0.5.0"
+version = "0.5.2"
 description = "An Amazon S3 Transfer Manager"
 category = "main"
 optional = false
@@ -676,7 +1033,7 @@ crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"]
 
 [[package]]
 name = "sentry-sdk"
-version = "1.4.3"
+version = "1.5.8"
 description = "Python client for Sentry (https://sentry.io)"
 category = "main"
 optional = false
@@ -698,6 +1055,7 @@ flask = ["flask (>=0.11)", "blinker (>=1.1)"]
 httpx = ["httpx (>=0.16.0)"]
 pure_eval = ["pure-eval", "executing", "asttokens"]
 pyspark = ["pyspark (>=2.4.4)"]
+quart = ["quart (>=0.16.1)", "blinker (>=1.1)"]
 rq = ["rq (>=0.6)"]
 sanic = ["sanic (>=0.8)"]
 sqlalchemy = ["sqlalchemy (>=1.2)"]
@@ -712,15 +1070,26 @@ optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
 
 [[package]]
-name = "tabulate"
-version = "0.8.9"
-description = "Pretty-print tabular data"
+name = "sniffio"
+version = "1.2.0"
+description = "Sniff out which async library your code is running under"
 category = "main"
 optional = false
-python-versions = "*"
+python-versions = ">=3.5"
 
-[package.extras]
-widechars = ["wcwidth"]
+[package.dependencies]
+contextvars = {version = ">=2.1", markers = "python_version < \"3.7\""}
+
+[[package]]
+name = "time-machine"
+version = "2.5.0"
+description = "Travel through time in your tests."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+python-dateutil = "*"
 
 [[package]]
 name = "toml"
@@ -732,38 +1101,79 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
 
 [[package]]
 name = "tomli"
-version = "1.2.2"
+version = "1.2.3"
 description = "A lil' TOML parser"
 category = "dev"
 optional = false
 python-versions = ">=3.6"
 
+[[package]]
+name = "traitlets"
+version = "4.3.3"
+description = "Traitlets Python config system"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+decorator = "*"
+ipython-genutils = "*"
+six = "*"
+
+[package.extras]
+test = ["pytest", "mock"]
+
 [[package]]
 name = "typed-ast"
-version = "1.4.3"
+version = "1.5.2"
 description = "a fork of Python 2 and 3 ast modules with type comment support"
 category = "dev"
 optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "typer"
+version = "0.4.0"
+description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+click = ">=7.1.1,<9.0.0"
+
+[package.extras]
+all = ["colorama (>=0.4.3,<0.5.0)", "shellingham (>=1.3.0,<2.0.0)"]
+dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)"]
+doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=5.4.0,<6.0.0)", "markdown-include (>=0.5.1,<0.6.0)"]
+test = ["shellingham (>=1.3.0,<2.0.0)", "pytest (>=4.4.0,<5.4.0)", "pytest-cov (>=2.10.0,<3.0.0)", "coverage (>=5.2,<6.0)", "pytest-xdist (>=1.32.0,<2.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "mypy (==0.910)", "black (>=19.10b0,<20.0b0)", "isort (>=5.0.6,<6.0.0)"]
+
+[[package]]
+name = "types-pyyaml"
+version = "6.0.5"
+description = "Typing stubs for PyYAML"
+category = "dev"
+optional = false
 python-versions = "*"
 
 [[package]]
 name = "typing-extensions"
-version = "3.10.0.2"
-description = "Backported and Experimental Type Hints for Python 3.5+"
+version = "4.1.1"
+description = "Backported and Experimental Type Hints for Python 3.6+"
 category = "main"
 optional = false
-python-versions = "*"
+python-versions = ">=3.6"
 
 [[package]]
 name = "urllib3"
-version = "1.26.7"
+version = "1.26.9"
 description = "HTTP library with thread-safe connection pooling, file post, and more."
 category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
 
 [package.extras]
-brotli = ["brotlipy (>=0.6.0)"]
+brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"]
 secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
 socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
 
@@ -777,7 +1187,7 @@ python-versions = "*"
 
 [[package]]
 name = "win32-setctime"
-version = "1.0.3"
+version = "1.1.0"
 description = "A small Python utility to set file creation time on Windows"
 category = "main"
 optional = false
@@ -814,7 +1224,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.6.2"
-content-hash = "66897e588cb7cb7228eb75a014cdb61085c47302d06b41d5ae2f8b6e3cee6776"
+content-hash = "3e244f3c89c2f94360f2255d6592b5bada1e5eaeec24a3dd60ed88388c805e00"
 
 [metadata.files]
 aiocontextvars = [
@@ -825,46 +1235,66 @@ ansicon = [
     {file = "ansicon-1.89.0-py2.py3-none-any.whl", hash = "sha256:f1def52d17f65c2c9682cf8370c03f541f410c1752d6a14029f97318e4b9dfec"},
     {file = "ansicon-1.89.0.tar.gz", hash = "sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1"},
 ]
+anyio = [
+    {file = "anyio-3.5.0-py3-none-any.whl", hash = "sha256:b5fa16c5ff93fa1046f2eeb5bbff2dad4d3514d6cda61d02816dba34fa8c3c2e"},
+    {file = "anyio-3.5.0.tar.gz", hash = "sha256:a0aeffe2fb1fdf374a8e4b471444f0f3ac4fb9f5a5b542b48824475e0042a5a6"},
+]
+appnope = [
+    {file = "appnope-0.1.2-py2.py3-none-any.whl", hash = "sha256:93aa393e9d6c54c5cd570ccadd8edad61ea0c4b9ea7a01409020c9aa019eb442"},
+    {file = "appnope-0.1.2.tar.gz", hash = "sha256:dd83cd4b5b460958838f6eb3000c660b1f9caf2a5b1de4264e941512f603258a"},
+]
+async-generator = [
+    {file = "async_generator-1.10-py3-none-any.whl", hash = "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b"},
+    {file = "async_generator-1.10.tar.gz", hash = "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"},
+]
 atomicwrites = [
     {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
     {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
 ]
 attrs = [
-    {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"},
-    {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"},
+    {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"},
+    {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"},
+]
+backcall = [
+    {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"},
+    {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"},
 ]
 black = [
-    {file = "black-21.10b0-py3-none-any.whl", hash = "sha256:6eb7448da9143ee65b856a5f3676b7dda98ad9abe0f87fce8c59291f15e82a5b"},
-    {file = "black-21.10b0.tar.gz", hash = "sha256:a9952229092e325fe5f3dae56d81f639b23f7131eb840781947e4b2886030f33"},
+    {file = "black-21.12b0-py3-none-any.whl", hash = "sha256:a615e69ae185e08fdd73e4715e260e2479c861b5740057fde6e8b4e3b7dd589f"},
+    {file = "black-21.12b0.tar.gz", hash = "sha256:77b80f693a569e2e527958459634f18df9b0ba2625ba4e0c2d5da5be42e6f2b3"},
 ]
 blessed = [
-    {file = "blessed-1.17.6-py2.py3-none-any.whl", hash = "sha256:8371d69ac55558e4b1591964873d6721136e9ea17a730aeb3add7d27761b134b"},
-    {file = "blessed-1.17.6.tar.gz", hash = "sha256:a9a774fc6eda05248735b0d86e866d640ca2fef26038878f7e4d23f7749a1e40"},
+    {file = "blessed-1.19.0-py2.py3-none-any.whl", hash = "sha256:1f2d462631b2b6d2d4c3c65b54ef79ad87a6ca2dd55255df2f8d739fcc8a1ddb"},
+    {file = "blessed-1.19.0.tar.gz", hash = "sha256:4db0f94e5761aea330b528e84a250027ffe996b5a94bf03e502600c9a5ad7a61"},
 ]
 boto3 = [
-    {file = "boto3-1.20.2-py3-none-any.whl", hash = "sha256:3735a29512db2a64e4cf2087f55f9a8f96472ec97a2aa2801be0b90db8de1af2"},
-    {file = "boto3-1.20.2.tar.gz", hash = "sha256:905a213c8ca1f148c872b79849e04741ba9e648348bbe23726e98dd3fdacc574"},
+    {file = "boto3-1.21.25-py3-none-any.whl", hash = "sha256:61375a673f3a293ea6f9d94c1b781c3bb19233093001a998a4e0bf7b3a67ae33"},
+    {file = "boto3-1.21.25.tar.gz", hash = "sha256:39195734d887bf906629bda9248637f1715522da457a304aa83936b449dd3d8c"},
 ]
 botocore = [
-    {file = "botocore-1.23.2-py3-none-any.whl", hash = "sha256:0a0e7fde67c9aa75ce9013ef7d26597daa474bb606d3dc013e1c2384b7cf095b"},
-    {file = "botocore-1.23.2.tar.gz", hash = "sha256:274475867c3bdc7124b7e47ce4e1adac6461a0e9c2fdfd93913f41cfd3a897cf"},
+    {file = "botocore-1.24.25-py3-none-any.whl", hash = "sha256:7f04738ca133e61f4e6cebfc0c7c09fe63bdf3087029cf9cc527b15b066f360c"},
+    {file = "botocore-1.24.25.tar.gz", hash = "sha256:6a28568e3922212a1c89622bc795a4ecba2ef3395ed17f8a26697bf648b7a95a"},
 ]
 certifi = [
     {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"},
     {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"},
 ]
 charset-normalizer = [
-    {file = "charset-normalizer-2.0.7.tar.gz", hash = "sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0"},
-    {file = "charset_normalizer-2.0.7-py3-none-any.whl", hash = "sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b"},
+    {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"},
+    {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"},
 ]
 click = [
-    {file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"},
-    {file = "click-8.0.3.tar.gz", hash = "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"},
+    {file = "click-8.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"},
+    {file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"},
 ]
 colorama = [
     {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
     {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
 ]
+commonmark = [
+    {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"},
+    {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"},
+]
 contextvars = [
     {file = "contextvars-2.4.tar.gz", hash = "sha256:f38c908aaa59c14335eeea12abea5f443646216c4e29380d7bf34d2018e2c39e"},
 ]
@@ -921,17 +1351,33 @@ dataclasses = [
     {file = "dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf"},
     {file = "dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"},
 ]
+decorator = [
+    {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"},
+    {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"},
+]
 ecdsa = [
     {file = "ecdsa-0.17.0-py2.py3-none-any.whl", hash = "sha256:5cf31d5b33743abe0dfc28999036c849a69d548f994b535e527ee3cb7f3ef676"},
     {file = "ecdsa-0.17.0.tar.gz", hash = "sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa"},
 ]
 flake8 = [
-    {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"},
-    {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"},
+    {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"},
+    {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"},
 ]
 freezegun = [
-    {file = "freezegun-1.1.0-py2.py3-none-any.whl", hash = "sha256:2ae695f7eb96c62529f03a038461afe3c692db3465e215355e1bb4b0ab408712"},
-    {file = "freezegun-1.1.0.tar.gz", hash = "sha256:177f9dd59861d871e27a484c3332f35a6e3f5d14626f2bf91be37891f18927f3"},
+    {file = "freezegun-1.2.1-py3-none-any.whl", hash = "sha256:15103a67dfa868ad809a8f508146e396be2995172d25f927e48ce51c0bf5cb09"},
+    {file = "freezegun-1.2.1.tar.gz", hash = "sha256:b4c64efb275e6bc68dc6e771b17ffe0ff0f90b81a2a5189043550b6519926ba4"},
+]
+h11 = [
+    {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
+    {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"},
+]
+httpcore = [
+    {file = "httpcore-0.14.7-py3-none-any.whl", hash = "sha256:47d772f754359e56dd9d892d9593b6f9870a37aeb8ba51e9a88b09b3d68cfade"},
+    {file = "httpcore-0.14.7.tar.gz", hash = "sha256:7503ec1c0f559066e7e39bc4003fd2ce023d01cf51793e3c173b864eb456ead1"},
+]
+httpx = [
+    {file = "httpx-0.22.0-py3-none-any.whl", hash = "sha256:e35e83d1d2b9b2a609ef367cc4c1e66fd80b750348b20cc9e19d1952fc2ca3f6"},
+    {file = "httpx-0.22.0.tar.gz", hash = "sha256:d8e778f76d9bbd46af49e7f062467e3157a5a3d2ae4876a4bbfd8a51ed9c9cb4"},
 ]
 idna = [
     {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
@@ -967,21 +1413,33 @@ immutables = [
     {file = "immutables-0.16.tar.gz", hash = "sha256:d67e86859598eed0d926562da33325dac7767b7b1eff84e232c22abea19f4360"},
 ]
 importlib-metadata = [
-    {file = "importlib_metadata-4.8.2-py3-none-any.whl", hash = "sha256:53ccfd5c134223e497627b9815d5030edf77d2ed573922f7a0b8f8bb81a1c100"},
-    {file = "importlib_metadata-4.8.2.tar.gz", hash = "sha256:75bdec14c397f528724c1bfd9709d660b33a4d2e77387a3358f20b848bb5e5fb"},
+    {file = "importlib_metadata-4.2.0-py3-none-any.whl", hash = "sha256:057e92c15bc8d9e8109738a48db0ccb31b4d9d5cfbee5a8670879a30be66304b"},
+    {file = "importlib_metadata-4.2.0.tar.gz", hash = "sha256:b7e52a1f8dec14a75ea73e0891f3060099ca1d8e6a462a4dff11c3e119ea1b31"},
 ]
 iniconfig = [
     {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
     {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
 ]
 inquirer = [
-    {file = "inquirer-2.7.0-py2.py3-none-any.whl", hash = "sha256:d15e15de1ad5696f1967e7a23d8e2fce69d2e41a70b008948d676881ed94c3a5"},
-    {file = "inquirer-2.7.0.tar.gz", hash = "sha256:e819188de0ca7985a99c282176c6f50fb08b0d33867fd1965d3f3e97d6c8f83f"},
+    {file = "inquirer-2.8.0-py2.py3-none-any.whl", hash = "sha256:237c14a68bcf0b2950899aa11bcc342de613e9389e4bf6fcd2ef97fcb3b1590a"},
+    {file = "inquirer-2.8.0.tar.gz", hash = "sha256:08cdb7386ee01c76f91cd9813525f8723234f224dd3407019ac340ff89fdd731"},
+]
+ipython = [
+    {file = "ipython-7.16.3-py3-none-any.whl", hash = "sha256:c0427ed8bc33ac481faf9d3acf7e84e0010cdaada945e0badd1e2e74cc075833"},
+    {file = "ipython-7.16.3.tar.gz", hash = "sha256:5ac47dc9af66fc2f5530c12069390877ae372ac905edca75a92a6e363b5d7caa"},
+]
+ipython-genutils = [
+    {file = "ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8"},
+    {file = "ipython_genutils-0.2.0.tar.gz", hash = "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8"},
 ]
 isort = [
     {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"},
     {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"},
 ]
+jedi = [
+    {file = "jedi-0.17.2-py2.py3-none-any.whl", hash = "sha256:98cc583fa0f2f8304968199b01b6b4b94f469a1f4a74c1560506ca2a211378b5"},
+    {file = "jedi-0.17.2.tar.gz", hash = "sha256:86ed7d9b750603e4ba582ea8edc678657fb4007894a12bcf6f4bb97892f31d20"},
+]
 jinxed = [
     {file = "jinxed-1.1.0-py2.py3-none-any.whl", hash = "sha256:6a61ccf963c16aa885304f27e6e5693783676897cea0c7f223270c8b8e78baf8"},
     {file = "jinxed-1.1.0.tar.gz", hash = "sha256:d8f1731f134e9e6b04d95095845ae6c10eb15cb223a5f0cabdea87d4a279c305"},
@@ -1072,18 +1530,75 @@ multidict = [
     {file = "multidict-5.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:c9631c642e08b9fff1c6255487e62971d8b8e821808ddd013d8ac058087591ac"},
     {file = "multidict-5.2.0.tar.gz", hash = "sha256:0dd1c93edb444b33ba2274b66f63def8a327d607c6c790772f448a53b6ea59ce"},
 ]
+mypy = [
+    {file = "mypy-0.931-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3c5b42d0815e15518b1f0990cff7a705805961613e701db60387e6fb663fe78a"},
+    {file = "mypy-0.931-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c89702cac5b302f0c5d33b172d2b55b5df2bede3344a2fbed99ff96bddb2cf00"},
+    {file = "mypy-0.931-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:300717a07ad09525401a508ef5d105e6b56646f7942eb92715a1c8d610149714"},
+    {file = "mypy-0.931-cp310-cp310-win_amd64.whl", hash = "sha256:7b3f6f557ba4afc7f2ce6d3215d5db279bcf120b3cfd0add20a5d4f4abdae5bc"},
+    {file = "mypy-0.931-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:1bf752559797c897cdd2c65f7b60c2b6969ffe458417b8d947b8340cc9cec08d"},
+    {file = "mypy-0.931-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4365c60266b95a3f216a3047f1d8e3f895da6c7402e9e1ddfab96393122cc58d"},
+    {file = "mypy-0.931-cp36-cp36m-win_amd64.whl", hash = "sha256:1b65714dc296a7991000b6ee59a35b3f550e0073411ac9d3202f6516621ba66c"},
+    {file = "mypy-0.931-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e839191b8da5b4e5d805f940537efcaa13ea5dd98418f06dc585d2891d228cf0"},
+    {file = "mypy-0.931-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:50c7346a46dc76a4ed88f3277d4959de8a2bd0a0fa47fa87a4cde36fe247ac05"},
+    {file = "mypy-0.931-cp37-cp37m-win_amd64.whl", hash = "sha256:d8f1ff62f7a879c9fe5917b3f9eb93a79b78aad47b533911b853a757223f72e7"},
+    {file = "mypy-0.931-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f9fe20d0872b26c4bba1c1be02c5340de1019530302cf2dcc85c7f9fc3252ae0"},
+    {file = "mypy-0.931-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1b06268df7eb53a8feea99cbfff77a6e2b205e70bf31743e786678ef87ee8069"},
+    {file = "mypy-0.931-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8c11003aaeaf7cc2d0f1bc101c1cc9454ec4cc9cb825aef3cafff8a5fdf4c799"},
+    {file = "mypy-0.931-cp38-cp38-win_amd64.whl", hash = "sha256:d9d2b84b2007cea426e327d2483238f040c49405a6bf4074f605f0156c91a47a"},
+    {file = "mypy-0.931-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ff3bf387c14c805ab1388185dd22d6b210824e164d4bb324b195ff34e322d166"},
+    {file = "mypy-0.931-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5b56154f8c09427bae082b32275a21f500b24d93c88d69a5e82f3978018a0266"},
+    {file = "mypy-0.931-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8ca7f8c4b1584d63c9a0f827c37ba7a47226c19a23a753d52e5b5eddb201afcd"},
+    {file = "mypy-0.931-cp39-cp39-win_amd64.whl", hash = "sha256:74f7eccbfd436abe9c352ad9fb65872cc0f1f0a868e9d9c44db0893440f0c697"},
+    {file = "mypy-0.931-py3-none-any.whl", hash = "sha256:1171f2e0859cfff2d366da2c7092b06130f232c636a3f7301e3feb8b41f6377d"},
+    {file = "mypy-0.931.tar.gz", hash = "sha256:0038b21890867793581e4cb0d810829f5fd4441aa75796b53033af3aa30430ce"},
+]
 mypy-extensions = [
     {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
     {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
 ]
 packaging = [
-    {file = "packaging-21.2-py3-none-any.whl", hash = "sha256:14317396d1e8cdb122989b916fa2c7e9ca8e2be9e8060a6eff75b6b7b4d8a7e0"},
-    {file = "packaging-21.2.tar.gz", hash = "sha256:096d689d78ca690e4cd8a89568ba06d07ca097e3306a4381635073ca91479966"},
+    {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
+    {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
+]
+parso = [
+    {file = "parso-0.7.1-py2.py3-none-any.whl", hash = "sha256:97218d9159b2520ff45eb78028ba8b50d2bc61dcc062a9682666f2dc4bd331ea"},
+    {file = "parso-0.7.1.tar.gz", hash = "sha256:caba44724b994a8a5e086460bb212abc5a8bc46951bf4a9a1210745953622eb9"},
 ]
 pathspec = [
     {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
     {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
 ]
+pendulum = [
+    {file = "pendulum-2.1.2-cp27-cp27m-macosx_10_15_x86_64.whl", hash = "sha256:b6c352f4bd32dff1ea7066bd31ad0f71f8d8100b9ff709fb343f3b86cee43efe"},
+    {file = "pendulum-2.1.2-cp27-cp27m-win_amd64.whl", hash = "sha256:318f72f62e8e23cd6660dbafe1e346950281a9aed144b5c596b2ddabc1d19739"},
+    {file = "pendulum-2.1.2-cp35-cp35m-macosx_10_15_x86_64.whl", hash = "sha256:0731f0c661a3cb779d398803655494893c9f581f6488048b3fb629c2342b5394"},
+    {file = "pendulum-2.1.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:3481fad1dc3f6f6738bd575a951d3c15d4b4ce7c82dce37cf8ac1483fde6e8b0"},
+    {file = "pendulum-2.1.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9702069c694306297ed362ce7e3c1ef8404ac8ede39f9b28b7c1a7ad8c3959e3"},
+    {file = "pendulum-2.1.2-cp35-cp35m-win_amd64.whl", hash = "sha256:fb53ffa0085002ddd43b6ca61a7b34f2d4d7c3ed66f931fe599e1a531b42af9b"},
+    {file = "pendulum-2.1.2-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:c501749fdd3d6f9e726086bf0cd4437281ed47e7bca132ddb522f86a1645d360"},
+    {file = "pendulum-2.1.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:c807a578a532eeb226150d5006f156632df2cc8c5693d778324b43ff8c515dd0"},
+    {file = "pendulum-2.1.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2d1619a721df661e506eff8db8614016f0720ac171fe80dda1333ee44e684087"},
+    {file = "pendulum-2.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:f888f2d2909a414680a29ae74d0592758f2b9fcdee3549887779cd4055e975db"},
+    {file = "pendulum-2.1.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:e95d329384717c7bf627bf27e204bc3b15c8238fa8d9d9781d93712776c14002"},
+    {file = "pendulum-2.1.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:4c9c689747f39d0d02a9f94fcee737b34a5773803a64a5fdb046ee9cac7442c5"},
+    {file = "pendulum-2.1.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1245cd0075a3c6d889f581f6325dd8404aca5884dea7223a5566c38aab94642b"},
+    {file = "pendulum-2.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:db0a40d8bcd27b4fb46676e8eb3c732c67a5a5e6bfab8927028224fbced0b40b"},
+    {file = "pendulum-2.1.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:f5e236e7730cab1644e1b87aca3d2ff3e375a608542e90fe25685dae46310116"},
+    {file = "pendulum-2.1.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:de42ea3e2943171a9e95141f2eecf972480636e8e484ccffaf1e833929e9e052"},
+    {file = "pendulum-2.1.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7c5ec650cb4bec4c63a89a0242cc8c3cebcec92fcfe937c417ba18277d8560be"},
+    {file = "pendulum-2.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:33fb61601083f3eb1d15edeb45274f73c63b3c44a8524703dc143f4212bf3269"},
+    {file = "pendulum-2.1.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:29c40a6f2942376185728c9a0347d7c0f07905638c83007e1d262781f1e6953a"},
+    {file = "pendulum-2.1.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:94b1fc947bfe38579b28e1cccb36f7e28a15e841f30384b5ad6c5e31055c85d7"},
+    {file = "pendulum-2.1.2.tar.gz", hash = "sha256:b06a0ca1bfe41c990bbf0c029f0b6501a7f2ec4e38bfec730712015e8860f207"},
+]
+pexpect = [
+    {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"},
+    {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"},
+]
+pickleshare = [
+    {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"},
+    {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"},
+]
 platformdirs = [
     {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"},
     {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"},
@@ -1092,10 +1607,26 @@ pluggy = [
     {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
     {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
 ]
+plummet = [
+    {file = "plummet-1.1.0-py3-none-any.whl", hash = "sha256:ebd1f772e1fdad23df78ae10e9755879a877ae77301b5a7f6d7cb18cc4abb2ff"},
+    {file = "plummet-1.1.0.tar.gz", hash = "sha256:c3fecbbecbb7131b4b9c579561f592419cf72ad3f04e0d34739e8a92c0eab2c1"},
+]
+prompt-toolkit = [
+    {file = "prompt_toolkit-3.0.28-py3-none-any.whl", hash = "sha256:30129d870dcb0b3b6a53efdc9d0a83ea96162ffd28ffe077e94215b233dc670c"},
+    {file = "prompt_toolkit-3.0.28.tar.gz", hash = "sha256:9f1cd16b1e86c2968f2519d7fb31dd9d669916f515612c269d14e9ed52b51650"},
+]
+ptyprocess = [
+    {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"},
+    {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"},
+]
 py = [
     {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
     {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
 ]
+py-buzz = [
+    {file = "py-buzz-3.1.0.tar.gz", hash = "sha256:26126780bb815ec7983bc54ca2127b68d0dabb1b23668f05f86ef91221a45ad1"},
+    {file = "py_buzz-3.1.0-py3-none-any.whl", hash = "sha256:0b7460cf2adc58f7470bf8eb791999a9227214305f46520bab13f980ba3265e8"},
+]
 pyasn1 = [
     {file = "pyasn1-0.4.8-py2.4.egg", hash = "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"},
     {file = "pyasn1-0.4.8-py2.5.egg", hash = "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf"},
@@ -1112,44 +1643,68 @@ pyasn1 = [
     {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"},
 ]
 pycodestyle = [
-    {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"},
-    {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"},
+    {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"},
+    {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"},
 ]
 pydantic = [
-    {file = "pydantic-1.8.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739"},
-    {file = "pydantic-1.8.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4"},
-    {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e"},
-    {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840"},
-    {file = "pydantic-1.8.2-cp36-cp36m-win_amd64.whl", hash = "sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b"},
-    {file = "pydantic-1.8.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20"},
-    {file = "pydantic-1.8.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb"},
-    {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1"},
-    {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23"},
-    {file = "pydantic-1.8.2-cp37-cp37m-win_amd64.whl", hash = "sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287"},
-    {file = "pydantic-1.8.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd"},
-    {file = "pydantic-1.8.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505"},
-    {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e"},
-    {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820"},
-    {file = "pydantic-1.8.2-cp38-cp38-win_amd64.whl", hash = "sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3"},
-    {file = "pydantic-1.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316"},
-    {file = "pydantic-1.8.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62"},
-    {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f"},
-    {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b"},
-    {file = "pydantic-1.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3"},
-    {file = "pydantic-1.8.2-py3-none-any.whl", hash = "sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833"},
-    {file = "pydantic-1.8.2.tar.gz", hash = "sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b"},
+    {file = "pydantic-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb23bcc093697cdea2708baae4f9ba0e972960a835af22560f6ae4e7e47d33f5"},
+    {file = "pydantic-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1d5278bd9f0eee04a44c712982343103bba63507480bfd2fc2790fa70cd64cf4"},
+    {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab624700dc145aa809e6f3ec93fb8e7d0f99d9023b713f6a953637429b437d37"},
+    {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8d7da6f1c1049eefb718d43d99ad73100c958a5367d30b9321b092771e96c25"},
+    {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3c3b035103bd4e2e4a28da9da7ef2fa47b00ee4a9cf4f1a735214c1bcd05e0f6"},
+    {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3011b975c973819883842c5ab925a4e4298dffccf7782c55ec3580ed17dc464c"},
+    {file = "pydantic-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:086254884d10d3ba16da0588604ffdc5aab3f7f09557b998373e885c690dd398"},
+    {file = "pydantic-1.9.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0fe476769acaa7fcddd17cadd172b156b53546ec3614a4d880e5d29ea5fbce65"},
+    {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8e9dcf1ac499679aceedac7e7ca6d8641f0193c591a2d090282aaf8e9445a46"},
+    {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1e4c28f30e767fd07f2ddc6f74f41f034d1dd6bc526cd59e63a82fe8bb9ef4c"},
+    {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:c86229333cabaaa8c51cf971496f10318c4734cf7b641f08af0a6fbf17ca3054"},
+    {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:c0727bda6e38144d464daec31dff936a82917f431d9c39c39c60a26567eae3ed"},
+    {file = "pydantic-1.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:dee5ef83a76ac31ab0c78c10bd7d5437bfdb6358c95b91f1ba7ff7b76f9996a1"},
+    {file = "pydantic-1.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d9c9bdb3af48e242838f9f6e6127de9be7063aad17b32215ccc36a09c5cf1070"},
+    {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ee7e3209db1e468341ef41fe263eb655f67f5c5a76c924044314e139a1103a2"},
+    {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b6037175234850ffd094ca77bf60fb54b08b5b22bc85865331dd3bda7a02fa1"},
+    {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b2571db88c636d862b35090ccf92bf24004393f85c8870a37f42d9f23d13e032"},
+    {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8b5ac0f1c83d31b324e57a273da59197c83d1bb18171e512908fe5dc7278a1d6"},
+    {file = "pydantic-1.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:bbbc94d0c94dd80b3340fc4f04fd4d701f4b038ebad72c39693c794fd3bc2d9d"},
+    {file = "pydantic-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e0896200b6a40197405af18828da49f067c2fa1f821491bc8f5bde241ef3f7d7"},
+    {file = "pydantic-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bdfdadb5994b44bd5579cfa7c9b0e1b0e540c952d56f627eb227851cda9db77"},
+    {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:574936363cd4b9eed8acdd6b80d0143162f2eb654d96cb3a8ee91d3e64bf4cf9"},
+    {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c556695b699f648c58373b542534308922c46a1cda06ea47bc9ca45ef5b39ae6"},
+    {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f947352c3434e8b937e3aa8f96f47bdfe6d92779e44bb3f41e4c213ba6a32145"},
+    {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5e48ef4a8b8c066c4a31409d91d7ca372a774d0212da2787c0d32f8045b1e034"},
+    {file = "pydantic-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:96f240bce182ca7fe045c76bcebfa0b0534a1bf402ed05914a6f1dadff91877f"},
+    {file = "pydantic-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:815ddebb2792efd4bba5488bc8fde09c29e8ca3227d27cf1c6990fc830fd292b"},
+    {file = "pydantic-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6c5b77947b9e85a54848343928b597b4f74fc364b70926b3c4441ff52620640c"},
+    {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c68c3bc88dbda2a6805e9a142ce84782d3930f8fdd9655430d8576315ad97ce"},
+    {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a79330f8571faf71bf93667d3ee054609816f10a259a109a0738dac983b23c3"},
+    {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f5a64b64ddf4c99fe201ac2724daada8595ada0d102ab96d019c1555c2d6441d"},
+    {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a733965f1a2b4090a5238d40d983dcd78f3ecea221c7af1497b845a9709c1721"},
+    {file = "pydantic-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cc6a4cb8a118ffec2ca5fcb47afbacb4f16d0ab8b7350ddea5e8ef7bcc53a16"},
+    {file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"},
+    {file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"},
 ]
 pyflakes = [
-    {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"},
-    {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"},
+    {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"},
+    {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"},
+]
+pygments = [
+    {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"},
+    {file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"},
 ]
 pyparsing = [
-    {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"},
-    {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
+    {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"},
+    {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"},
+]
+pyperclip = [
+    {file = "pyperclip-1.8.2.tar.gz", hash = "sha256:105254a8b04934f0bc84e9c24eb360a591aaf6535c9def5f29d92af107a9bf57"},
+]
+pyproject-flake8 = [
+    {file = "pyproject-flake8-0.0.1a2.tar.gz", hash = "sha256:bdeca37f78ecd34bd64a49d3657d53d099f5445831071a31c46e1fe20cd61461"},
+    {file = "pyproject_flake8-0.0.1a2-py2.py3-none-any.whl", hash = "sha256:e61ed1dc088e9f9f8a7170967ac4ec135acfef3a59ab9738c7b58cc11f294a7e"},
 ]
 pytest = [
-    {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"},
-    {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"},
+    {file = "pytest-7.0.1-py3-none-any.whl", hash = "sha256:9ce3ff477af913ecf6321fe337b93a2c0dcf2a0a1439c43f5452112c1e4280db"},
+    {file = "pytest-7.0.1.tar.gz", hash = "sha256:e30905a0c131d3d94b89624a1cc5afec3e0ba2fbdb151867d8e0ebd49850f171"},
 ]
 pytest-cov = [
     {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"},
@@ -1162,6 +1717,10 @@ pytest-freezegun = [
     {file = "pytest-freezegun-0.4.2.zip", hash = "sha256:19c82d5633751bf3ec92caa481fb5cffaac1787bd485f0df6436fd6242176949"},
     {file = "pytest_freezegun-0.4.2-py2.py3-none-any.whl", hash = "sha256:5318a6bfb8ba4b709c8471c94d0033113877b3ee02da5bfcd917c1889cde99a7"},
 ]
+pytest-mock = [
+    {file = "pytest-mock-3.6.1.tar.gz", hash = "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62"},
+    {file = "pytest_mock-3.6.1-py3-none-any.whl", hash = "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3"},
+]
 pytest-random-order = [
     {file = "pytest-random-order-1.0.4.tar.gz", hash = "sha256:6b2159342a4c8c10855bc4fc6d65ee890fc614cb2b4ff688979b008a82a0ff52"},
     {file = "pytest_random_order-1.0.4-py3-none-any.whl", hash = "sha256:72279a7f823969e18b10e438950f58330d17e0fcffb57cbd7929770cd687ecb2"},
@@ -1175,8 +1734,8 @@ python-dateutil = [
     {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
 ]
 python-dotenv = [
-    {file = "python-dotenv-0.19.1.tar.gz", hash = "sha256:14f8185cc8d494662683e6914addcb7e95374771e707601dfc70166946b4c4b8"},
-    {file = "python_dotenv-0.19.1-py2.py3-none-any.whl", hash = "sha256:bbd3da593fc49c249397cbfbcc449cf36cb02e75afc8157fcc6a81df6fb7750a"},
+    {file = "python-dotenv-0.19.2.tar.gz", hash = "sha256:a5de49a31e953b45ff2d2fd434bbc2670e8db5273606c1e737cc6b93eff3655f"},
+    {file = "python_dotenv-0.19.2-py2.py3-none-any.whl", hash = "sha256:32b2bdc1873fd3a3c346da1c6db83d0053c3c62f28f1f38516070c4c8971b1d3"},
 ]
 python-editor = [
     {file = "python-editor-1.0.4.tar.gz", hash = "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b"},
@@ -1189,6 +1748,10 @@ python-jose = [
     {file = "python-jose-3.3.0.tar.gz", hash = "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a"},
     {file = "python_jose-3.3.0-py2.py3-none-any.whl", hash = "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a"},
 ]
+pytzdata = [
+    {file = "pytzdata-2020.1-py2.py3-none-any.whl", hash = "sha256:e1e14750bcf95016381e4d472bad004eef710f2d6417240904070b3d6654485f"},
+    {file = "pytzdata-2020.1.tar.gz", hash = "sha256:3efa13b335a00a8de1d345ae41ec78dd11c9f8807f522d39850f2dd828681540"},
+]
 pyyaml = [
     {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"},
     {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"},
@@ -1224,166 +1787,158 @@ readchar = [
     {file = "readchar-2.0.1-py2-none-any.whl", hash = "sha256:ed00b7a49bb12f345319d9fa393f289f03670310ada2beb55e8c3f017c648f1e"},
     {file = "readchar-2.0.1-py3-none-any.whl", hash = "sha256:3ac34aab28563bc895f73233d5c08b28f951ca190d5850b8d4bec973132a8dca"},
 ]
-regex = [
-    {file = "regex-2021.11.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9345b6f7ee578bad8e475129ed40123d265464c4cfead6c261fd60fc9de00bcf"},
-    {file = "regex-2021.11.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:416c5f1a188c91e3eb41e9c8787288e707f7d2ebe66e0a6563af280d9b68478f"},
-    {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0538c43565ee6e703d3a7c3bdfe4037a5209250e8502c98f20fea6f5fdf2965"},
-    {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ee1227cf08b6716c85504aebc49ac827eb88fcc6e51564f010f11a406c0a667"},
-    {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6650f16365f1924d6014d2ea770bde8555b4a39dc9576abb95e3cd1ff0263b36"},
-    {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30ab804ea73972049b7a2a5c62d97687d69b5a60a67adca07eb73a0ddbc9e29f"},
-    {file = "regex-2021.11.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:68a067c11463de2a37157930d8b153005085e42bcb7ad9ca562d77ba7d1404e0"},
-    {file = "regex-2021.11.10-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:162abfd74e88001d20cb73ceaffbfe601469923e875caf9118333b1a4aaafdc4"},
-    {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9ed0b1e5e0759d6b7f8e2f143894b2a7f3edd313f38cf44e1e15d360e11749b"},
-    {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:473e67837f786404570eae33c3b64a4b9635ae9f00145250851a1292f484c063"},
-    {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2fee3ed82a011184807d2127f1733b4f6b2ff6ec7151d83ef3477f3b96a13d03"},
-    {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:d5fd67df77bab0d3f4ea1d7afca9ef15c2ee35dfb348c7b57ffb9782a6e4db6e"},
-    {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5d408a642a5484b9b4d11dea15a489ea0928c7e410c7525cd892f4d04f2f617b"},
-    {file = "regex-2021.11.10-cp310-cp310-win32.whl", hash = "sha256:98ba568e8ae26beb726aeea2273053c717641933836568c2a0278a84987b2a1a"},
-    {file = "regex-2021.11.10-cp310-cp310-win_amd64.whl", hash = "sha256:780b48456a0f0ba4d390e8b5f7c661fdd218934388cde1a974010a965e200e12"},
-    {file = "regex-2021.11.10-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:dba70f30fd81f8ce6d32ddeef37d91c8948e5d5a4c63242d16a2b2df8143aafc"},
-    {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1f54b9b4b6c53369f40028d2dd07a8c374583417ee6ec0ea304e710a20f80a0"},
-    {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fbb9dc00e39f3e6c0ef48edee202f9520dafb233e8b51b06b8428cfcb92abd30"},
-    {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:666abff54e474d28ff42756d94544cdfd42e2ee97065857413b72e8a2d6a6345"},
-    {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5537f71b6d646f7f5f340562ec4c77b6e1c915f8baae822ea0b7e46c1f09b733"},
-    {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed2e07c6a26ed4bea91b897ee2b0835c21716d9a469a96c3e878dc5f8c55bb23"},
-    {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ca5f18a75e1256ce07494e245cdb146f5a9267d3c702ebf9b65c7f8bd843431e"},
-    {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:74cbeac0451f27d4f50e6e8a8f3a52ca074b5e2da9f7b505c4201a57a8ed6286"},
-    {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:3598893bde43091ee5ca0a6ad20f08a0435e93a69255eeb5f81b85e81e329264"},
-    {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:50a7ddf3d131dc5633dccdb51417e2d1910d25cbcf842115a3a5893509140a3a"},
-    {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:61600a7ca4bcf78a96a68a27c2ae9389763b5b94b63943d5158f2a377e09d29a"},
-    {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:563d5f9354e15e048465061509403f68424fef37d5add3064038c2511c8f5e00"},
-    {file = "regex-2021.11.10-cp36-cp36m-win32.whl", hash = "sha256:93a5051fcf5fad72de73b96f07d30bc29665697fb8ecdfbc474f3452c78adcf4"},
-    {file = "regex-2021.11.10-cp36-cp36m-win_amd64.whl", hash = "sha256:b483c9d00a565633c87abd0aaf27eb5016de23fed952e054ecc19ce32f6a9e7e"},
-    {file = "regex-2021.11.10-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fff55f3ce50a3ff63ec8e2a8d3dd924f1941b250b0aac3d3d42b687eeff07a8e"},
-    {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e32d2a2b02ccbef10145df9135751abea1f9f076e67a4e261b05f24b94219e36"},
-    {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53db2c6be8a2710b359bfd3d3aa17ba38f8aa72a82309a12ae99d3c0c3dcd74d"},
-    {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2207ae4f64ad3af399e2d30dde66f0b36ae5c3129b52885f1bffc2f05ec505c8"},
-    {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5ca078bb666c4a9d1287a379fe617a6dccd18c3e8a7e6c7e1eb8974330c626a"},
-    {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd33eb9bdcfbabab3459c9ee651d94c842bc8a05fabc95edf4ee0c15a072495e"},
-    {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:05b7d6d7e64efe309972adab77fc2af8907bb93217ec60aa9fe12a0dad35874f"},
-    {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:42b50fa6666b0d50c30a990527127334d6b96dd969011e843e726a64011485da"},
-    {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:6e1d2cc79e8dae442b3fa4a26c5794428b98f81389af90623ffcc650ce9f6732"},
-    {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:0416f7399e918c4b0e074a0f66e5191077ee2ca32a0f99d4c187a62beb47aa05"},
-    {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:ce298e3d0c65bd03fa65ffcc6db0e2b578e8f626d468db64fdf8457731052942"},
-    {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dc07f021ee80510f3cd3af2cad5b6a3b3a10b057521d9e6aaeb621730d320c5a"},
-    {file = "regex-2021.11.10-cp37-cp37m-win32.whl", hash = "sha256:e71255ba42567d34a13c03968736c5d39bb4a97ce98188fafb27ce981115beec"},
-    {file = "regex-2021.11.10-cp37-cp37m-win_amd64.whl", hash = "sha256:07856afef5ffcc052e7eccf3213317fbb94e4a5cd8177a2caa69c980657b3cb4"},
-    {file = "regex-2021.11.10-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ba05430e819e58544e840a68b03b28b6d328aff2e41579037e8bab7653b37d83"},
-    {file = "regex-2021.11.10-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7f301b11b9d214f83ddaf689181051e7f48905568b0c7017c04c06dfd065e244"},
-    {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aaa4e0705ef2b73dd8e36eeb4c868f80f8393f5f4d855e94025ce7ad8525f50"},
-    {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:788aef3549f1924d5c38263104dae7395bf020a42776d5ec5ea2b0d3d85d6646"},
-    {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f8af619e3be812a2059b212064ea7a640aff0568d972cd1b9e920837469eb3cb"},
-    {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85bfa6a5413be0ee6c5c4a663668a2cad2cbecdee367630d097d7823041bdeec"},
-    {file = "regex-2021.11.10-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f23222527b307970e383433daec128d769ff778d9b29343fb3496472dc20dabe"},
-    {file = "regex-2021.11.10-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:da1a90c1ddb7531b1d5ff1e171b4ee61f6345119be7351104b67ff413843fe94"},
-    {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f5be7805e53dafe94d295399cfbe5227f39995a997f4fd8539bf3cbdc8f47ca8"},
-    {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a955b747d620a50408b7fdf948e04359d6e762ff8a85f5775d907ceced715129"},
-    {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:139a23d1f5d30db2cc6c7fd9c6d6497872a672db22c4ae1910be22d4f4b2068a"},
-    {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:ca49e1ab99593438b204e00f3970e7a5f70d045267051dfa6b5f4304fcfa1dbf"},
-    {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:96fc32c16ea6d60d3ca7f63397bff5c75c5a562f7db6dec7d412f7c4d2e78ec0"},
-    {file = "regex-2021.11.10-cp38-cp38-win32.whl", hash = "sha256:0617383e2fe465732af4509e61648b77cbe3aee68b6ac8c0b6fe934db90be5cc"},
-    {file = "regex-2021.11.10-cp38-cp38-win_amd64.whl", hash = "sha256:a3feefd5e95871872673b08636f96b61ebef62971eab044f5124fb4dea39919d"},
-    {file = "regex-2021.11.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f7f325be2804246a75a4f45c72d4ce80d2443ab815063cdf70ee8fb2ca59ee1b"},
-    {file = "regex-2021.11.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:537ca6a3586931b16a85ac38c08cc48f10fc870a5b25e51794c74df843e9966d"},
-    {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eef2afb0fd1747f33f1ee3e209bce1ed582d1896b240ccc5e2697e3275f037c7"},
-    {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:432bd15d40ed835a51617521d60d0125867f7b88acf653e4ed994a1f8e4995dc"},
-    {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b43c2b8a330a490daaef5a47ab114935002b13b3f9dc5da56d5322ff218eeadb"},
-    {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:962b9a917dd7ceacbe5cd424556914cb0d636001e393b43dc886ba31d2a1e449"},
-    {file = "regex-2021.11.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fa8c626d6441e2d04b6ee703ef2d1e17608ad44c7cb75258c09dd42bacdfc64b"},
-    {file = "regex-2021.11.10-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3c5fb32cc6077abad3bbf0323067636d93307c9fa93e072771cf9a64d1c0f3ef"},
-    {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cd410a1cbb2d297c67d8521759ab2ee3f1d66206d2e4328502a487589a2cb21b"},
-    {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e6096b0688e6e14af6a1b10eaad86b4ff17935c49aa774eac7c95a57a4e8c296"},
-    {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:529801a0d58809b60b3531ee804d3e3be4b412c94b5d267daa3de7fadef00f49"},
-    {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0f594b96fe2e0821d026365f72ac7b4f0b487487fb3d4aaf10dd9d97d88a9737"},
-    {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2409b5c9cef7054dde93a9803156b411b677affc84fca69e908b1cb2c540025d"},
-    {file = "regex-2021.11.10-cp39-cp39-win32.whl", hash = "sha256:3b5df18db1fccd66de15aa59c41e4f853b5df7550723d26aa6cb7f40e5d9da5a"},
-    {file = "regex-2021.11.10-cp39-cp39-win_amd64.whl", hash = "sha256:83ee89483672b11f8952b158640d0c0ff02dc43d9cb1b70c1564b49abe92ce29"},
-    {file = "regex-2021.11.10.tar.gz", hash = "sha256:f341ee2df0999bfdf7a95e448075effe0db212a59387de1a70690e4acb03d4c6"},
-]
 requests = [
-    {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"},
-    {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"},
+    {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"},
+    {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"},
 ]
 responses = [
-    {file = "responses-0.15.0-py2.py3-none-any.whl", hash = "sha256:5955ad3468fe8eb5fb736cdab4943457b7768f8670fa3624b4e26ff52dfe20c0"},
-    {file = "responses-0.15.0.tar.gz", hash = "sha256:866757987d1962aa908d9c8b3185739faefd72a359e95459de0c2e4e5369c9b2"},
+    {file = "responses-0.17.0-py2.py3-none-any.whl", hash = "sha256:e4fc472fb7374fb8f84fcefa51c515ca4351f198852b4eb7fc88223780b472ea"},
+    {file = "responses-0.17.0.tar.gz", hash = "sha256:ec675e080d06bf8d1fb5e5a68a1e5cd0df46b09c78230315f650af5e4036bec7"},
+]
+respx = [
+    {file = "respx-0.19.2-py2.py3-none-any.whl", hash = "sha256:417f986fec599b9cc6531e93e494b7a75d1cb7bccff9dde5b53edc51f7954494"},
+    {file = "respx-0.19.2.tar.gz", hash = "sha256:f3d210bb4de0ccc4c5afabeb87c3c1b03b3765a9c1a73eb042a07bb18ac33705"},
+]
+rfc3986 = [
+    {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"},
+    {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"},
+]
+rich = [
+    {file = "rich-11.2.0-py3-none-any.whl", hash = "sha256:d5f49ad91fb343efcae45a2b2df04a9755e863e50413623ab8c9e74f05aee52b"},
+    {file = "rich-11.2.0.tar.gz", hash = "sha256:1a6266a5738115017bb64a66c59c717e7aa047b3ae49a011ede4abdeffc6536e"},
 ]
 rsa = [
-    {file = "rsa-4.7.2-py3-none-any.whl", hash = "sha256:78f9a9bf4e7be0c5ded4583326e7461e3a3c5aae24073648b4bdfa797d78c9d2"},
-    {file = "rsa-4.7.2.tar.gz", hash = "sha256:9d689e6ca1b3038bc82bf8d23e944b6b6037bc02301a574935b2dd946e0353b9"},
+    {file = "rsa-4.8-py3-none-any.whl", hash = "sha256:95c5d300c4e879ee69708c428ba566c59478fd653cc3a22243eeb8ed846950bb"},
+    {file = "rsa-4.8.tar.gz", hash = "sha256:5c6bd9dc7a543b7fe4304a631f8a8a3b674e2bbfc49c2ae96200cdbe55df6b17"},
 ]
 s3transfer = [
-    {file = "s3transfer-0.5.0-py3-none-any.whl", hash = "sha256:9c1dc369814391a6bda20ebbf4b70a0f34630592c9aa520856bf384916af2803"},
-    {file = "s3transfer-0.5.0.tar.gz", hash = "sha256:50ed823e1dc5868ad40c8dc92072f757aa0e653a192845c94a3b676f4a62da4c"},
+    {file = "s3transfer-0.5.2-py3-none-any.whl", hash = "sha256:7a6f4c4d1fdb9a2b640244008e142cbc2cd3ae34b386584ef044dd0f27101971"},
+    {file = "s3transfer-0.5.2.tar.gz", hash = "sha256:95c58c194ce657a5f4fb0b9e60a84968c808888aed628cd98ab8771fe1db98ed"},
 ]
 sentry-sdk = [
-    {file = "sentry-sdk-1.4.3.tar.gz", hash = "sha256:b9844751e40710e84a457c5bc29b21c383ccb2b63d76eeaad72f7f1c808c8828"},
-    {file = "sentry_sdk-1.4.3-py2.py3-none-any.whl", hash = "sha256:c091cc7115ff25fe3a0e410dbecd7a996f81a3f6137d2272daef32d6c3cfa6dc"},
+    {file = "sentry-sdk-1.5.8.tar.gz", hash = "sha256:38fd16a92b5ef94203db3ece10e03bdaa291481dd7e00e77a148aa0302267d47"},
+    {file = "sentry_sdk-1.5.8-py2.py3-none-any.whl", hash = "sha256:32af1a57954576709242beb8c373b3dbde346ac6bd616921def29d68846fb8c3"},
 ]
 six = [
     {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
     {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
 ]
-tabulate = [
-    {file = "tabulate-0.8.9-py3-none-any.whl", hash = "sha256:d7c013fe7abbc5e491394e10fa845f8f32fe54f8dc60c6622c6cf482d25d47e4"},
-    {file = "tabulate-0.8.9.tar.gz", hash = "sha256:eb1d13f25760052e8931f2ef80aaf6045a6cceb47514db8beab24cded16f13a7"},
+sniffio = [
+    {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"},
+    {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"},
+]
+time-machine = [
+    {file = "time-machine-2.5.0.tar.gz", hash = "sha256:f61e582dfe1018c9b1c035d2da948b1ac7f298e3153d704a55f305a7aaefea57"},
+    {file = "time_machine-2.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:176a18266394d66a2565a6d71874a945dad3b837ee7db6b26d3329792d086adc"},
+    {file = "time_machine-2.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fd081d951ba84d27bce13a988884bf746877073e2bc4220c0d093dd579dd9081"},
+    {file = "time_machine-2.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30a9a19ff9ec66cca00944830b3c5d89080a6a222615997a8db251b4dda13acd"},
+    {file = "time_machine-2.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8afbb3d7445b2fd26c3d322e77cc5c1734367a55cdeab4198507f735e2a51a8b"},
+    {file = "time_machine-2.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:089cea08787d3863bf0dd8ac79d5d9474231f8ce70c8b028ab7788d4196361ea"},
+    {file = "time_machine-2.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8fd7a95421f8979d47035e295443ad3e617eccb02f9e669ccb6a02a900e40321"},
+    {file = "time_machine-2.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:31863586d37c72264f795353f70f3f076530941bef4e2936a0aafab28d70a5a1"},
+    {file = "time_machine-2.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:51baf21f138dace274a2d628f28a004db5ce020eb5bbcb0908949141c993868b"},
+    {file = "time_machine-2.5.0-cp310-cp310-win32.whl", hash = "sha256:0992030c0c58006610e1d8d65a61df176561b8baa416f22ee129cd8c0a092120"},
+    {file = "time_machine-2.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:7116e9685513917c57fec0230024e27037d5eff92414a709999c21644ee3ac0a"},
+    {file = "time_machine-2.5.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:5a95e99f0f29bd6fd5142e033709a3415246f314ed225661f2bcbe2759a41197"},
+    {file = "time_machine-2.5.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a76092104d682a38b1081941bbbf030d47ae03001554425434b8a9f21ca1d5b"},
+    {file = "time_machine-2.5.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3e7889e2496c64b85a760e79e25f791055f63c10a6d27abeb4ec4cd12146b6c9"},
+    {file = "time_machine-2.5.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c213d5f4d708cdbcfe5be671f1110a7fbe9b8d2ceae9899fbb09dd4e1424d58"},
+    {file = "time_machine-2.5.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:a50222840f2702a5f7efd13d58506cc45484b22e7371bb86ce4eee7b615053de"},
+    {file = "time_machine-2.5.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:9a10c379ebf7a80d8034538016af736103a6c3348670d297788146abaac2c606"},
+    {file = "time_machine-2.5.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:0873edc3d22a09c606771c899e95b9a9b23d66d9a9fd1f302c778287ece59481"},
+    {file = "time_machine-2.5.0-cp36-cp36m-win32.whl", hash = "sha256:1288a7283b5efc53b69ce846d0ec5598ea6b2682053cd706f5d5e52fb6b2120c"},
+    {file = "time_machine-2.5.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f4ed5b43ab71f10dfeaf9c8a0c377cb091c3a3dac3e21ee0ec132ad76d0e771d"},
+    {file = "time_machine-2.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:47ba6e193bd6a755abb7dde5000ea62b66fcb6ae468ca122652ed8dbd3ad6bd3"},
+    {file = "time_machine-2.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4ac68af561565bce073230a6038209cf5a19fe0f1cb5e94ebf2ea10cc4a845a"},
+    {file = "time_machine-2.5.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28ad158c80187c17931c1667ced28294af5597ee28479b5b145da3d07ae2f34d"},
+    {file = "time_machine-2.5.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8b3f8178aa8676dce482100415e035eaf065664ecf1ab5b32dc88a6d07a5481"},
+    {file = "time_machine-2.5.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:fc915a4c32084384acd88ebc39400a5dbf62129f2c694a58f71b35aa45663096"},
+    {file = "time_machine-2.5.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:936890909eb86e6c735ba76ce06e42e0da41d888d41130e65d41819663446bf1"},
+    {file = "time_machine-2.5.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7acf47de70fca46042b19f8b97b6f7c6cb37f6ddd157d0f56d22774e9bcc5cae"},
+    {file = "time_machine-2.5.0-cp37-cp37m-win32.whl", hash = "sha256:30c533c30eb7dbf452b59170ea5584b9aca4bccf17b4382d42ad79481041d930"},
+    {file = "time_machine-2.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:42614b6a234cc0c60ff9e8ff3567da480e7f69d1f19a70706fa7a95a382b21bf"},
+    {file = "time_machine-2.5.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e6fd3ca3f49e2116889255e11f7243b79f8b15586f219538a8ed14c8d0677c64"},
+    {file = "time_machine-2.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8dc833bc503e8f3afe5fbebb7ab5887a848534ee2de9883f3ce9b770efd78e5a"},
+    {file = "time_machine-2.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36d3f90b98bbb7bfaa45a8755d43bb290c27cd5e04b37329bda7617bfe745682"},
+    {file = "time_machine-2.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78a159d57131244c2a6591c26c75f26fce4c26f3b23ecac918cfe0de1812cde7"},
+    {file = "time_machine-2.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37df69104c38af80e465b8530a2929a6bc4ea51e332b6ae66aa4fdade6a454e7"},
+    {file = "time_machine-2.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:701f2e56205411d2d1644f26dc4a98a39e901a41868819d1826b7d2e00f5d37a"},
+    {file = "time_machine-2.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:98800bb5db89815d9d1ade51d8690b6418d93b0b9664bf4a4a8492380e95dc38"},
+    {file = "time_machine-2.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0e51562bad97ada3b00f996db40b9de2ff0e2cb61b54b017ede7738fc08edb7b"},
+    {file = "time_machine-2.5.0-cp38-cp38-win32.whl", hash = "sha256:6c76ee141f4e33c64a9de7cda1f6793985c66bcfa3cc6d284ab8aae1ec468444"},
+    {file = "time_machine-2.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:e7a0430b25c7f6487b7455774bbe0e447753bc2ac43f25cbc82aba6453bf126a"},
+    {file = "time_machine-2.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b6c59c2fca5b573bbcd7b5eab8799e685d61c17ed31906f29e465e4d747f38f2"},
+    {file = "time_machine-2.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8b567dc27997bc7cef3aeaf5aa5f6481560dced0a1fc0ca6d7de2cbc592a019b"},
+    {file = "time_machine-2.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10532dcf6a6eb37eead208f6307886e6abf388af4d0bd1d50502b8bece9ca895"},
+    {file = "time_machine-2.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd2edef76fa21de1238c54beb751863e75928b075a25d62d5ac65f8e909e4628"},
+    {file = "time_machine-2.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ff6a4c60177d68489a8fc8f98d60692aa57572d1ddc4cd8209914010d8436a3"},
+    {file = "time_machine-2.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cfae49174892c138f9b127dcb820f4f287f603af6771581e130feedcbdd6c320"},
+    {file = "time_machine-2.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:8b093449198841fc008f7d2af71e28485dc46958a488f2068bf31a59caff6887"},
+    {file = "time_machine-2.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a486d59f966872732492e2139d76c4cd03460d6600de9239f4f8ab606b9b7f37"},
+    {file = "time_machine-2.5.0-cp39-cp39-win32.whl", hash = "sha256:739ac3d5b75a1236ab3e7833a8a4936f046b94fae86e97ea124aa59fc060c2cc"},
+    {file = "time_machine-2.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:ae0f33be3ab2550af3af8545e74382adc4c1fac3a6e4bf42924c7ebefc42d17a"},
 ]
 toml = [
     {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
     {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
 ]
 tomli = [
-    {file = "tomli-1.2.2-py3-none-any.whl", hash = "sha256:f04066f68f5554911363063a30b108d2b5a5b1a010aa8b6132af78489fe3aade"},
-    {file = "tomli-1.2.2.tar.gz", hash = "sha256:c6ce0015eb38820eaf32b5db832dbc26deb3dd427bd5f6556cf0acac2c214fee"},
+    {file = "tomli-1.2.3-py3-none-any.whl", hash = "sha256:e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c"},
+    {file = "tomli-1.2.3.tar.gz", hash = "sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f"},
+]
+traitlets = [
+    {file = "traitlets-4.3.3-py2.py3-none-any.whl", hash = "sha256:70b4c6a1d9019d7b4f6846832288f86998aa3b9207c6821f3578a6a6a467fe44"},
+    {file = "traitlets-4.3.3.tar.gz", hash = "sha256:d023ee369ddd2763310e4c3eae1ff649689440d4ae59d7485eb4cfbbe3e359f7"},
 ]
 typed-ast = [
-    {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"},
-    {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"},
-    {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"},
-    {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"},
-    {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"},
-    {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"},
-    {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"},
-    {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"},
-    {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"},
-    {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"},
-    {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"},
-    {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"},
-    {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"},
-    {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"},
-    {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"},
-    {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"},
-    {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"},
-    {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"},
-    {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"},
-    {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"},
-    {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"},
-    {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"},
-    {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"},
-    {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"},
-    {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"},
-    {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"},
-    {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"},
-    {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"},
-    {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"},
-    {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"},
+    {file = "typed_ast-1.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:183b183b7771a508395d2cbffd6db67d6ad52958a5fdc99f450d954003900266"},
+    {file = "typed_ast-1.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:676d051b1da67a852c0447621fdd11c4e104827417bf216092ec3e286f7da596"},
+    {file = "typed_ast-1.5.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc2542e83ac8399752bc16e0b35e038bdb659ba237f4222616b4e83fb9654985"},
+    {file = "typed_ast-1.5.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74cac86cc586db8dfda0ce65d8bcd2bf17b58668dfcc3652762f3ef0e6677e76"},
+    {file = "typed_ast-1.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:18fe320f354d6f9ad3147859b6e16649a0781425268c4dde596093177660e71a"},
+    {file = "typed_ast-1.5.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:31d8c6b2df19a777bc8826770b872a45a1f30cfefcfd729491baa5237faae837"},
+    {file = "typed_ast-1.5.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:963a0ccc9a4188524e6e6d39b12c9ca24cc2d45a71cfdd04a26d883c922b4b78"},
+    {file = "typed_ast-1.5.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0eb77764ea470f14fcbb89d51bc6bbf5e7623446ac4ed06cbd9ca9495b62e36e"},
+    {file = "typed_ast-1.5.2-cp36-cp36m-win_amd64.whl", hash = "sha256:294a6903a4d087db805a7656989f613371915fc45c8cc0ddc5c5a0a8ad9bea4d"},
+    {file = "typed_ast-1.5.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:26a432dc219c6b6f38be20a958cbe1abffcc5492821d7e27f08606ef99e0dffd"},
+    {file = "typed_ast-1.5.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7407cfcad702f0b6c0e0f3e7ab876cd1d2c13b14ce770e412c0c4b9728a0f88"},
+    {file = "typed_ast-1.5.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f30ddd110634c2d7534b2d4e0e22967e88366b0d356b24de87419cc4410c41b7"},
+    {file = "typed_ast-1.5.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8c08d6625bb258179b6e512f55ad20f9dfef019bbfbe3095247401e053a3ea30"},
+    {file = "typed_ast-1.5.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:90904d889ab8e81a956f2c0935a523cc4e077c7847a836abee832f868d5c26a4"},
+    {file = "typed_ast-1.5.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bbebc31bf11762b63bf61aaae232becb41c5bf6b3461b80a4df7e791fabb3aca"},
+    {file = "typed_ast-1.5.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c29dd9a3a9d259c9fa19d19738d021632d673f6ed9b35a739f48e5f807f264fb"},
+    {file = "typed_ast-1.5.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:58ae097a325e9bb7a684572d20eb3e1809802c5c9ec7108e85da1eb6c1a3331b"},
+    {file = "typed_ast-1.5.2-cp38-cp38-win_amd64.whl", hash = "sha256:da0a98d458010bf4fe535f2d1e367a2e2060e105978873c04c04212fb20543f7"},
+    {file = "typed_ast-1.5.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:33b4a19ddc9fc551ebabca9765d54d04600c4a50eda13893dadf67ed81d9a098"},
+    {file = "typed_ast-1.5.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1098df9a0592dd4c8c0ccfc2e98931278a6c6c53cb3a3e2cf7e9ee3b06153344"},
+    {file = "typed_ast-1.5.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42c47c3b43fe3a39ddf8de1d40dbbfca60ac8530a36c9b198ea5b9efac75c09e"},
+    {file = "typed_ast-1.5.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f290617f74a610849bd8f5514e34ae3d09eafd521dceaa6cf68b3f4414266d4e"},
+    {file = "typed_ast-1.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:df05aa5b241e2e8045f5f4367a9f6187b09c4cdf8578bb219861c4e27c443db5"},
+    {file = "typed_ast-1.5.2.tar.gz", hash = "sha256:525a2d4088e70a9f75b08b3f87a51acc9cde640e19cc523c7e41aa355564ae27"},
+]
+typer = [
+    {file = "typer-0.4.0-py3-none-any.whl", hash = "sha256:d81169725140423d072df464cad1ff25ee154ef381aaf5b8225352ea187ca338"},
+    {file = "typer-0.4.0.tar.gz", hash = "sha256:63c3aeab0549750ffe40da79a1b524f60e08a2cbc3126c520ebf2eeaf507f5dd"},
+]
+types-pyyaml = [
+    {file = "types-PyYAML-6.0.5.tar.gz", hash = "sha256:464e050914f3d1d83a8c038e1cf46da5cb96b7cd02eaa096bcaa03675edd8a2e"},
+    {file = "types_PyYAML-6.0.5-py3-none-any.whl", hash = "sha256:2fd21310870addfd51db621ad9f3b373f33ee3cbb81681d70ef578760bd22d35"},
 ]
 typing-extensions = [
-    {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"},
-    {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"},
-    {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"},
+    {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"},
+    {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"},
 ]
 urllib3 = [
-    {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"},
-    {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"},
+    {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"},
+    {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"},
 ]
 wcwidth = [
     {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"},
     {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"},
 ]
 win32-setctime = [
-    {file = "win32_setctime-1.0.3-py3-none-any.whl", hash = "sha256:dc925662de0a6eb987f0b01f599c01a8236cb8c62831c22d9cada09ad958243e"},
-    {file = "win32_setctime-1.0.3.tar.gz", hash = "sha256:4e88556c32fdf47f64165a2180ba4552f8bb32c1103a2fafd05723a0bd42bd4b"},
+    {file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"},
+    {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"},
 ]
 yarl = [
     {file = "yarl-1.7.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f2a8508f7350512434e41065684076f640ecce176d262a7d54f0da41d99c5a95"},
diff --git a/jobbergate-cli/pyproject.toml b/jobbergate-cli/pyproject.toml
index e0f2a8e4..31264018 100644
--- a/jobbergate-cli/pyproject.toml
+++ b/jobbergate-cli/pyproject.toml
@@ -13,15 +13,18 @@ click = "^8.0.1"
 inquirer = "^2.7.0"
 PyYAML = "^5.4.1"
 python-dotenv = "^0.19.0"
-requests = "^2.26.0"
-tabulate = "^0.8.9"
-urllib3 = "^1.26.7"
 sentry-sdk = "^1.4.3"
 boto3 = "^1.18.64"
 loguru = "^0.6.0"
 python-jose = "^3.3.0"
 pydantic = "^1.8.2"
 yarl = "^1.7.2"
+httpx = "^0.22.0"
+pendulum = "^2.1.2"
+py-buzz = "^3.1.0"
+rich = "^11.2.0"
+typer = "^0.4.0"
+pyperclip = "^1.8.2"
 
 [tool.poetry.dev-dependencies]
 black = "^21.9b0"
@@ -29,9 +32,18 @@ pytest-cov = "^3.0.0"
 pytest-random-order = "^1.0.4"
 pytest-freezegun = "^0.4.2"
 pytest-responsemock = "^1.0.1"
-flake8 = "^3.9.2"
 isort = "^5.9.3"
 pytest-env = "^0.6.2"
+respx = "^0.19.2"
+plummet = {extras = ["time-machine"], version = "^1.1.0"}
+pyproject-flake8 = "^0.0.1-alpha.2"
+mypy = "^0.931"
+pytest-mock = "^3.6.0"
+types-PyYAML = "^6.0.4"
+ipython = "^7.10"
+
+[tool.poetry.scripts]
+jobbergate = "jobbergate_cli.main:app"
 
 [tool.pytest.ini_options]
 minversion = "6.0"
@@ -39,7 +51,7 @@ addopts = [
     "--random-order",
     "--cov=jobbergate_cli",
     "--cov-report=term-missing",
-    "--cov-fail-under=25",
+    "--cov-fail-under=85",
 ]
 env = [
     "JOBBERGATE_API_ENDPOINT = https://jobbergate-api-staging.omnivector.solutions",
@@ -50,20 +62,29 @@ env = [
     "AUTH0_CLIENT_SECRET = dummy_client_secret",
     "IDENTITY_CLAIMS_KEY = https://test-domain.test",
 ]
-testpaths = ["jobbergate_cli/test"]
 
 [tool.black]
-line-length = 110
+line-length = 120
 
 [tool.isort]
-line_length = 110
+line_length = 120
 lines_after_imports = "2"
-# ignore from..import vs import while sorting a section
-force_sort_within_sections = "1"
 profile = "black"
+multi_line_output = 3
+include_trailing_comma = true
 
-[tool.poetry.scripts]
-jobbergate = "jobbergate_cli.main:main"
+[tool.flake8]
+max-line-length = 120
+max-complexity = 40
+
+[[tool.mypy.overrides]]
+module = [
+    "urllib3",
+    "pyperclip",
+    "inquirer.*",
+    "jose.*",
+]
+ignore_missing_imports = true
 
 [build-system]
 requires = ["poetry-core>=1.0.0"]


Test patch:
diff --git a/fake-sbatch/tests/conftest.py b/fake-sbatch/tests/conftest.py
new file mode 100644
index 00000000..24e4e5eb
--- /dev/null
+++ b/fake-sbatch/tests/conftest.py
@@ -0,0 +1,8 @@
+import pytest
+
+from typer.testing import CliRunner
+
+
+@pytest.fixture
+def cli_runner():
+    return CliRunner()
diff --git a/fake-sbatch/tests/test_main.py b/fake-sbatch/tests/test_main.py
new file mode 100644
index 00000000..958ce123
--- /dev/null
+++ b/fake-sbatch/tests/test_main.py
@@ -0,0 +1,20 @@
+import sys
+
+from fake_sbatch.config import settings
+from fake_sbatch.main import app
+
+
+def test_prints_out_fake_slurm_job_id_when_roll_is_large_enough(mocker, cli_runner):
+    mocker.patch("fake_sbatch.main.random", return_value = settings.FAKE_SBATCH_FAIL_PCT + sys.float_info.epsilon)
+    result = cli_runner.invoke(app)
+    assert result.exit_code == 0, f"submit failed: {result.stdout}"
+
+    assert "Submitted batch job" in result.stdout
+    slurm_job_id = int(result.stdout.split()[-1])
+    assert settings.FAKE_SBATCH_MIN_JOB_ID <= slurm_job_id <= settings.FAKE_SBATCH_MAX_JOB_ID
+
+
+def test_exits_with_fail_code_when_roll_is_too_small(mocker, cli_runner):
+    mocker.patch("fake_sbatch.main.random", return_value = settings.FAKE_SBATCH_FAIL_PCT - sys.float_info.epsilon)
+    result = cli_runner.invoke(app)
+    assert result.exit_code == 1, f"submit succeeded but should have failed: {result.stdout}"
diff --git a/jobbergate-api/jobbergate_api/tests/apps/applications/test_routers.py b/jobbergate-api/jobbergate_api/tests/apps/applications/test_routers.py
index 2acbdbba..3ec5c94c 100644
--- a/jobbergate-api/jobbergate_api/tests/apps/applications/test_routers.py
+++ b/jobbergate-api/jobbergate_api/tests/apps/applications/test_routers.py
@@ -677,7 +677,7 @@ async def test_update_application(
                 application_description="new_description",
             ),
         )
-    assert response.status_code == status.HTTP_201_CREATED
+    assert response.status_code == status.HTTP_200_OK
 
     data = response.json()
     assert data["application_name"] == "new_name"
diff --git a/jobbergate-api/jobbergate_api/tests/apps/job_scripts/test_routers.py b/jobbergate-api/jobbergate_api/tests/apps/job_scripts/test_routers.py
index 57f81225..d16d7f76 100644
--- a/jobbergate-api/jobbergate_api/tests/apps/job_scripts/test_routers.py
+++ b/jobbergate-api/jobbergate_api/tests/apps/job_scripts/test_routers.py
@@ -124,9 +124,7 @@ async def test_create_job_script(
             s3man_client_mock.get_object.return_value = s3_object
             response = await client.post(
                 "/jobbergate/job-scripts/",
-                json=fill_job_script_data(
-                    application_id=inserted_application_id, param_dict=json.dumps(param_dict),
-                ),
+                json=fill_job_script_data(application_id=inserted_application_id, param_dict=param_dict,),
             )
 
     assert response.status_code == status.HTTP_201_CREATED
@@ -169,7 +167,7 @@ async def test_create_job_script_bad_permission(
     inject_security_header("owner1@org.com", "INVALID_PERMISSION")
     response = await client.post(
         "/jobbergate/job-scripts/",
-        json=fill_job_script_data(application_id=inserted_application_id, param_dict=json.dumps(param_dict),),
+        json=fill_job_script_data(application_id=inserted_application_id, param_dict=param_dict,),
     )
 
     assert response.status_code == status.HTTP_403_FORBIDDEN
@@ -192,8 +190,7 @@ async def test_create_job_script_without_application(
     """
     inject_security_header("owner1@org.com", Permissions.JOB_SCRIPTS_EDIT)
     response = await client.post(
-        "/jobbergate/job-scripts/",
-        json=fill_job_script_data(application_id=9999, param_dict=json.dumps(param_dict)),
+        "/jobbergate/job-scripts/", json=fill_job_script_data(application_id=9999, param_dict=param_dict),
     )
     assert response.status_code == status.HTTP_404_NOT_FOUND
 
@@ -225,9 +222,7 @@ async def test_create_job_script_file_not_found(
         s3man_client_mock.get_object.side_effect = BotoCoreError()
         response = await client.post(
             "/jobbergate/job-scripts/",
-            json=fill_job_script_data(
-                application_id=inserted_application_id, param_dict=json.dumps(param_dict),
-            ),
+            json=fill_job_script_data(application_id=inserted_application_id, param_dict=param_dict,),
         )
 
     assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -711,7 +706,7 @@ async def test_update_job_script(
             },
         )
 
-    assert response.status_code == status.HTTP_201_CREATED
+    assert response.status_code == status.HTTP_200_OK
     data = response.json()
 
     assert data["job_script_name"] == "new name"
diff --git a/jobbergate-api/jobbergate_api/tests/apps/job_submissions/test_routers.py b/jobbergate-api/jobbergate_api/tests/apps/job_submissions/test_routers.py
index 39c84b36..b794d22a 100644
--- a/jobbergate-api/jobbergate_api/tests/apps/job_submissions/test_routers.py
+++ b/jobbergate-api/jobbergate_api/tests/apps/job_submissions/test_routers.py
@@ -711,7 +711,7 @@ async def test_update_job_submission(
             json=dict(job_submission_name="new name", job_submission_description="new description"),
         )
 
-    assert response.status_code == status.HTTP_201_CREATED
+    assert response.status_code == status.HTTP_200_OK
     data = response.json()
 
     assert data["job_submission_name"] == "new name"
diff --git a/jobbergate-cli/jobbergate_cli/test/test_jobbergate_api_wrapper.py b/jobbergate-cli/jobbergate_cli/test/test_jobbergate_api_wrapper.py
deleted file mode 100644
index 69fbdac0..00000000
--- a/jobbergate-cli/jobbergate_cli/test/test_jobbergate_api_wrapper.py
+++ /dev/null
@@ -1,26 +0,0 @@
-"""
-Tests of the API client architecture and related functions
-"""
-
-from pytest import mark
-
-from jobbergate_cli import jobbergate_api_wrapper
-
-
-@mark.parametrize(
-    "input,expected",
-    [
-        ["hello world", "hello world"],
-        ["\nhello world\n", "hello world"],
-        ["hello world\nhello world", "hello world..."],
-        ["hello world \nhello world", "hello world..."],
-        ["hello world how are you?", "hello world..."],
-        ["\nhello world how are you?\n2", "hello world..."],
-    ],
-    ids=["plain", "strip-ws", "short-nl", "short-nl-ws", "long", "long-nl"],
-)
-def test_fit_line(input, expected):
-    """
-    Do we truncate a string in the expected ways?
-    """
-    assert jobbergate_api_wrapper._fit_line(input, n=19) == expected
diff --git a/jobbergate-cli/jobbergate_cli/test/test_main.py b/jobbergate-cli/jobbergate_cli/test/test_main.py
deleted file mode 100644
index 679f3a77..00000000
--- a/jobbergate-cli/jobbergate_cli/test/test_main.py
+++ /dev/null
@@ -1,64 +0,0 @@
-"""
-Unit test helper functions in main
-"""
-import pathlib
-from unittest.mock import create_autospec, patch
-
-import click
-import pytest
-from pytest import fixture, mark
-
-from jobbergate_cli import config, main
-
-
-@fixture
-def token_cache_mock(sample_token):
-    """
-    Mock access to the filesystem path where the jwt token is cached
-    """
-    mock_access_token_path = create_autospec(pathlib.Path, instance=True)
-    mock_access_token_path.exists.return_value = True
-    mock_access_token_path.read_text.return_value = sample_token
-
-    mock_refresh_token_path = create_autospec(pathlib.Path, instance=True)
-    mock_refresh_token_path.exists.return_value = False
-
-    with patch.object(config.settings, "JOBBERGATE_API_ACCESS_TOKEN_PATH", mock_access_token_path):
-        with patch.object(config.settings, "JOBBERGATE_API_REFRESH_TOKEN_PATH", mock_refresh_token_path):
-            yield (mock_access_token_path, mock_refresh_token_path)
-
-
-@fixture
-def sample_token():
-    """
-    A sample JWT that contains info needed for auth through the jobbergate-cli.
-    The token expires 2021-11-19 15:52:53.
-    """
-    return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaHR0cHM6Ly90ZXN0LWRvbWFpbi50ZXN0Ijp7InVzZXJfZW1haWwiOiJvd25lcjFAb3JnLmNvbSJ9LCJleHAiOjE2MzczMzY1NzN9.wfXxrwvVSQrirQn7D_tDCNAPPk-jmGMxaJMiuWC7iaU"  # noqa
-
-
-@mark.parametrize(
-    "when,is_valid",
-    [
-        ["2021-11-19 00:00:00", True],
-        ["2021-11-19 23:59:59", False],
-    ],
-    ids=[
-        "dwf;valid",
-        "dwf;expired",
-    ],
-)
-@mark.freeze_time()
-@mark.usefixtures("token_cache_mock")
-def test_init_access_token(when, is_valid, freezer):
-    """
-    Do I successfully parse an access token from the cache? Do I identify an invalid access token?
-    """
-    freezer.move_to(when)
-    ctx_obj = {}
-    if is_valid:
-        assert main.init_access_token(ctx_obj)
-        assert "identity" in ctx_obj
-    else:
-        with pytest.raises(click.ClickException, match="The auth token is expired"):
-            main.init_access_token(ctx_obj)
diff --git a/jobbergate-cli/jobbergate_cli/test/__init__.py b/jobbergate-cli/tests/__init__.py
similarity index 100%
rename from jobbergate-cli/jobbergate_cli/test/__init__.py
rename to jobbergate-cli/tests/__init__.py
diff --git a/jobbergate-cli/tests/conftest.py b/jobbergate-cli/tests/conftest.py
new file mode 100644
index 00000000..24ca09e1
--- /dev/null
+++ b/jobbergate-cli/tests/conftest.py
@@ -0,0 +1,35 @@
+import contextlib
+
+import pytest
+from loguru import logger
+
+from jobbergate_cli.config import settings
+
+
+@pytest.fixture
+def caplog(caplog):
+    handler_id = logger.add(caplog.handler, format="{message}")
+    yield caplog
+    logger.remove(handler_id)
+
+
+@pytest.fixture
+def tweak_settings():
+    """
+    Provides a fixture to use as a context manager where the cli settings may be temporarily changed.
+    """
+
+    @contextlib.contextmanager
+    def _helper(**kwargs):
+        """
+        Context manager for tweaking app settings temporarily.
+        """
+        previous_values = {}
+        for (key, value) in kwargs.items():
+            previous_values[key] = getattr(settings, key)
+            setattr(settings, key, value)
+        yield
+        for (key, value) in previous_values.items():
+            setattr(settings, key, value)
+
+    return _helper
diff --git a/jobbergate-cli/tests/subapps/__init__.py b/jobbergate-cli/tests/subapps/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/jobbergate-cli/tests/subapps/applications/__init__.py b/jobbergate-cli/tests/subapps/applications/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/jobbergate-cli/tests/subapps/applications/test_app.py b/jobbergate-cli/tests/subapps/applications/test_app.py
new file mode 100644
index 00000000..e85e958b
--- /dev/null
+++ b/jobbergate-cli/tests/subapps/applications/test_app.py
@@ -0,0 +1,393 @@
+import shlex
+
+import httpx
+
+from jobbergate_cli.schemas import ApplicationResponse, ListResponseEnvelope, Pagination
+from jobbergate_cli.subapps.applications.app import (
+    HIDDEN_FIELDS,
+    create,
+    delete,
+    get_one,
+    list_all,
+    style_mapper,
+    update,
+)
+from jobbergate_cli.text_tools import unwrap
+
+
+def test_list_all__makes_request_and_renders_results(
+    respx_mock,
+    make_test_app,
+    dummy_context,
+    dummy_application_data,
+    dummy_domain,
+    cli_runner,
+    mocker,
+):
+    respx_mock.get(f"{dummy_domain}/applications").mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=dict(
+                results=dummy_application_data,
+                pagination=dict(
+                    total=3,
+                ),
+            ),
+        ),
+    )
+    test_app = make_test_app("list-all", list_all)
+    mocked_render = mocker.patch("jobbergate_cli.subapps.applications.app.render_list_results")
+    result = cli_runner.invoke(test_app, ["list-all"])
+    assert result.exit_code == 0, f"list-all failed: {result.stdout}"
+    mocked_render.assert_called_once_with(
+        dummy_context,
+        ListResponseEnvelope(
+            results=dummy_application_data,
+            pagination=Pagination(total=3),
+        ),
+        title="Applications List",
+        style_mapper=style_mapper,
+        hidden_fields=HIDDEN_FIELDS,
+    )
+
+
+def test_get_one__success__using_id(
+    respx_mock,
+    make_test_app,
+    dummy_context,
+    dummy_application_data,
+    dummy_domain,
+    cli_runner,
+    mocker,
+):
+    respx_mock.get(f"{dummy_domain}/applications/1").mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=dummy_application_data[0],
+        ),
+    )
+    test_app = make_test_app("get-one", get_one)
+    mocked_render = mocker.patch("jobbergate_cli.subapps.applications.app.render_single_result")
+    result = cli_runner.invoke(test_app, shlex.split("get-one --id=1"))
+    assert result.exit_code == 0, f"get-one failed: {result.stdout}"
+    mocked_render.assert_called_once_with(
+        dummy_context,
+        ApplicationResponse(**dummy_application_data[0]),
+        title="Application",
+        hidden_fields=HIDDEN_FIELDS,
+    )
+
+
+def test_get_one__success__using_identifier(
+    respx_mock,
+    make_test_app,
+    dummy_context,
+    dummy_application_data,
+    dummy_domain,
+    cli_runner,
+    mocker,
+):
+    respx_mock.get(f"{dummy_domain}/applications?identifier=dummy").mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=dummy_application_data[0],
+        ),
+    )
+    test_app = make_test_app("get-one", get_one)
+    mocked_render = mocker.patch("jobbergate_cli.subapps.applications.app.render_single_result")
+    result = cli_runner.invoke(test_app, shlex.split("get-one --identifier=dummy"))
+    assert result.exit_code == 0, f"get-one failed: {result.stdout}"
+    mocked_render.assert_called_once_with(
+        dummy_context,
+        ApplicationResponse(**dummy_application_data[0]),
+        title="Application",
+        hidden_fields=HIDDEN_FIELDS,
+    )
+
+
+def test_get_one__fails_with_neither_id_or_identifier(make_test_app, cli_runner):
+    test_app = make_test_app("get-one", get_one)
+    result = cli_runner.invoke(test_app, shlex.split("get-one"))
+    assert result.exit_code != 0
+    assert "You must supply either" in result.stdout
+
+
+def test_get_one__fails_with_both_id_and_identifier(make_test_app, cli_runner):
+    test_app = make_test_app("get-one", get_one)
+    result = cli_runner.invoke(test_app, shlex.split("get-one --id=1 --identifier=dummy"))
+    assert result.exit_code != 0
+    assert "You may not supply both" in result.stdout
+
+
+def test_create__success(
+    respx_mock,
+    make_test_app,
+    dummy_context,
+    dummy_application_data,
+    dummy_application_dir,
+    dummy_domain,
+    cli_runner,
+    mocker,
+):
+    response_data = dummy_application_data[0]
+    response_data["application_uploaded"] = False
+    application_id = response_data["id"]
+
+    create_route = respx_mock.post(f"{dummy_domain}/applications")
+    create_route.mock(
+        return_value=httpx.Response(
+            httpx.codes.CREATED,
+            json=response_data,
+        ),
+    )
+
+    upload_route = respx_mock.post(f"{dummy_domain}/applications/{application_id}/upload")
+    upload_route.mock(return_value=httpx.Response(httpx.codes.CREATED))
+
+    test_app = make_test_app("create", create)
+    mocked_render = mocker.patch("jobbergate_cli.subapps.applications.app.render_single_result")
+    result = cli_runner.invoke(
+        test_app,
+        shlex.split(
+            unwrap(
+                f"""
+                create --name=dummy-name --identifier=dummy-identifier
+                       --application-path={dummy_application_dir}
+                       --application-desc="This application is kinda dumb, actually"
+                """
+            )
+        ),
+    )
+    assert result.exit_code == 0, f"create failed: {result.stdout}"
+    assert create_route.called
+    assert upload_route.called
+
+    mocked_render.assert_called_once_with(
+        dummy_context,
+        {**response_data, "application_uploaded": True},
+        title="Created Application",
+        hidden_fields=HIDDEN_FIELDS,
+    )
+
+
+def test_create__warns_but_does_not_abort_if_upload_fails(
+    respx_mock,
+    make_test_app,
+    dummy_context,
+    dummy_application_data,
+    dummy_application_dir,
+    dummy_domain,
+    cli_runner,
+    mocker,
+):
+    response_data = dummy_application_data[0]
+    response_data["application_uploaded"] = False
+    application_id = response_data["id"]
+
+    create_route = respx_mock.post(f"{dummy_domain}/applications")
+    create_route.mock(
+        return_value=httpx.Response(
+            httpx.codes.CREATED,
+            json=response_data,
+        ),
+    )
+
+    upload_route = respx_mock.post(f"{dummy_domain}/applications/{application_id}/upload")
+    upload_route.mock(
+        return_value=httpx.Response(httpx.codes.BAD_REQUEST),
+    )
+
+    test_app = make_test_app("create", create)
+    mocked_render = mocker.patch("jobbergate_cli.subapps.applications.app.render_single_result")
+    result = cli_runner.invoke(
+        test_app,
+        shlex.split(
+            unwrap(
+                f"""
+                create --name=dummy-name --identifier=dummy-identifier
+                       --application-path={dummy_application_dir}
+                       --application-desc="This application is kinda dumb, actually"
+                """
+            )
+        ),
+    )
+    assert result.exit_code == 0, f"create failed: {result.stdout}"
+    assert create_route.called
+    assert upload_route.called
+    assert "zipped application files could not be uploaded" in result.stdout
+
+    mocked_render.assert_called_once_with(
+        dummy_context,
+        {**response_data, "application_uploaded": False},
+        title="Created Application",
+        hidden_fields=HIDDEN_FIELDS,
+    )
+
+
+def test_update__success(
+    respx_mock,
+    make_test_app,
+    dummy_context,
+    dummy_application_data,
+    dummy_application_dir,
+    dummy_domain,
+    cli_runner,
+    mocker,
+):
+    response_data = dummy_application_data[0]
+    response_data["application_uploaded"] = False
+    application_id = response_data["id"]
+
+    update_route = respx_mock.put(f"{dummy_domain}/applications/{application_id}")
+    update_route.mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=response_data,
+        ),
+    )
+
+    upload_route = respx_mock.post(f"{dummy_domain}/applications/{application_id}/upload")
+    upload_route.mock(
+        return_value=httpx.Response(httpx.codes.CREATED),
+    )
+
+    test_app = make_test_app("update", update)
+    mocked_render = mocker.patch("jobbergate_cli.subapps.applications.app.render_single_result")
+    result = cli_runner.invoke(
+        test_app,
+        shlex.split(
+            unwrap(
+                f"""
+                update --id={application_id} --identifier=dummy-identifier
+                       --application-path={dummy_application_dir}
+                       --application-desc="This application is kinda dumb, actually"
+                """
+            )
+        ),
+    )
+    assert result.exit_code == 0, f"update failed: {result.stdout}"
+    assert update_route.called
+    assert upload_route.called
+
+    mocked_render.assert_called_once_with(
+        dummy_context,
+        {**response_data, "application_uploaded": True},
+        title="Updated Application",
+        hidden_fields=HIDDEN_FIELDS,
+    )
+
+
+def test_update__does_not_upload_if_application_path_is_not_supplied(
+    respx_mock,
+    make_test_app,
+    dummy_context,
+    dummy_application_data,
+    dummy_domain,
+    cli_runner,
+    mocker,
+):
+    response_data = dummy_application_data[0]
+    response_data["application_uploaded"] = False
+    application_id = response_data["id"]
+
+    update_route = respx_mock.put(f"{dummy_domain}/applications/{application_id}")
+    update_route.mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=response_data,
+        ),
+    )
+
+    upload_route = respx_mock.post(f"{dummy_domain}/applications/{application_id}/upload")
+    upload_route.mock(
+        return_value=httpx.Response(httpx.codes.CREATED),
+    )
+
+    test_app = make_test_app("update", update)
+    mocked_render = mocker.patch("jobbergate_cli.subapps.applications.app.render_single_result")
+    result = cli_runner.invoke(
+        test_app,
+        shlex.split(
+            unwrap(
+                f"""
+                update --id={application_id} --identifier=dummy-identifier
+                       --application-desc="This application is kinda dumb, actually"
+                """
+            )
+        ),
+    )
+    assert result.exit_code == 0, f"update failed: {result.stdout}"
+    assert update_route.called
+    assert not upload_route.called
+
+    mocked_render.assert_called_once_with(
+        dummy_context,
+        {**response_data, "application_uploaded": False},
+        title="Updated Application",
+        hidden_fields=HIDDEN_FIELDS,
+    )
+
+
+def test_update__warns_but_does_not_abort_if_upload_fails(
+    respx_mock,
+    make_test_app,
+    dummy_context,
+    dummy_application_data,
+    dummy_application_dir,
+    dummy_domain,
+    cli_runner,
+    mocker,
+):
+    response_data = dummy_application_data[0]
+    response_data["application_uploaded"] = False
+    application_id = response_data["id"]
+
+    update_route = respx_mock.put(f"{dummy_domain}/applications/{application_id}")
+    update_route.mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=response_data,
+        ),
+    )
+
+    upload_route = respx_mock.post(f"{dummy_domain}/applications/{application_id}/upload")
+    upload_route.mock(
+        return_value=httpx.Response(httpx.codes.BAD_REQUEST),
+    )
+
+    test_app = make_test_app("update", update)
+    mocked_render = mocker.patch("jobbergate_cli.subapps.applications.app.render_single_result")
+    result = cli_runner.invoke(
+        test_app,
+        shlex.split(
+            unwrap(
+                f"""
+                update --id={application_id} --identifier=dummy-identifier
+                       --application-path={dummy_application_dir}
+                       --application-desc="This application is kinda dumb, actually"
+                """
+            )
+        ),
+    )
+    assert result.exit_code == 0, f"update failed: {result.stdout}"
+    assert update_route.called
+    assert upload_route.called
+    assert "zipped application files could not be uploaded" in result.stdout
+
+    mocked_render.assert_called_once_with(
+        dummy_context,
+        {**response_data, "application_uploaded": False},
+        title="Updated Application",
+        hidden_fields=HIDDEN_FIELDS,
+    )
+
+
+def test_delete__success(respx_mock, make_test_app, dummy_domain, cli_runner):
+    delete_route = respx_mock.delete(f"{dummy_domain}/applications/1")
+    delete_route.mock(return_value=httpx.Response(httpx.codes.NO_CONTENT))
+
+    test_app = make_test_app("delete", delete)
+    result = cli_runner.invoke(test_app, shlex.split("delete --id=1"))
+    assert result.exit_code == 0, f"delete failed: {result.stdout}"
+    assert delete_route.called
+    assert "Application delete succeeded" in result.stdout
diff --git a/jobbergate-cli/tests/subapps/applications/test_application_base.py b/jobbergate-cli/tests/subapps/applications/test_application_base.py
new file mode 100644
index 00000000..19d96cf1
--- /dev/null
+++ b/jobbergate-cli/tests/subapps/applications/test_application_base.py
@@ -0,0 +1,80 @@
+import contextlib
+import os
+import pathlib
+
+import pytest
+
+from jobbergate_cli.subapps.applications.application_base import JobbergateApplicationBase
+
+
+@pytest.fixture
+def temp_cd():
+    @contextlib.contextmanager
+    def _helper(path: pathlib.Path):
+        """
+        Helper method to temporarily change directory.
+        Pretty much copied from:
+        https://dev.to/teckert/changing-directory-with-a-python-context-manager-2bj8
+        """
+        anchor = pathlib.Path().absolute()
+        try:
+            os.chdir(path)
+            yield
+        finally:
+            os.chdir(anchor)
+
+    return _helper
+
+
+class DummyApplication(JobbergateApplicationBase):
+    def mainflow(self):
+        pass
+
+
+def test_find_templates(tmp_path):
+    application_path = tmp_path / "dummy"
+    assert JobbergateApplicationBase.find_templates(application_path) == []
+
+    application_path.mkdir()
+    template_root_path = application_path / "templates"
+    template_root_path.mkdir()
+    file1 = template_root_path / "file1"
+    file1.write_text("foo")
+    file2 = template_root_path / "file2"
+    file2.write_text("bar")
+    dir1 = template_root_path / "dir1"
+    dir1.mkdir()
+    file3 = dir1 / "file3"
+    file3.write_text("baz")
+    assert JobbergateApplicationBase.find_templates(application_path) == [
+        pathlib.Path("templates/dir1/file3"),
+        pathlib.Path("templates/file1"),
+        pathlib.Path("templates/file2"),
+    ]
+
+
+def test_get_template_files(temp_cd, tmp_path):
+
+    templates_path = tmp_path / "templates"
+    templates_path.mkdir()
+    template1 = templates_path / "template1.j2"
+    template1.write_text("template1")
+    template2 = templates_path / "template2.j2"
+    template2.write_text("template2")
+
+    subdir_path = templates_path / "subdir"
+    subdir_path.mkdir()
+    template3 = subdir_path / "template3.j2"
+    template3.write_text("template3")
+
+    with temp_cd(tmp_path):
+        dummy = DummyApplication(
+            dict(
+                jobbergate_config=dict(),
+                application_config=dict(),
+            )
+        )
+        template_file_paths = dummy.get_template_files()
+        assert pathlib.Path("templates/template1.j2") in template_file_paths
+        assert pathlib.Path("templates/template2.j2") in template_file_paths
+        assert pathlib.Path("templates/subdir/template3.j2") in template_file_paths
diff --git a/jobbergate-cli/tests/subapps/applications/test_questions.py b/jobbergate-cli/tests/subapps/applications/test_questions.py
new file mode 100644
index 00000000..543931f4
--- /dev/null
+++ b/jobbergate-cli/tests/subapps/applications/test_questions.py
@@ -0,0 +1,331 @@
+import importlib
+
+import pytest
+from inquirer import prompt
+from inquirer.errors import ValidationError
+
+from jobbergate_cli.exceptions import Abort
+from jobbergate_cli.subapps.applications.questions import (
+    BooleanList,
+    Checkbox,
+    Confirm,
+    Const,
+    Directory,
+    File,
+    Integer,
+    List,
+    Text,
+    gather_param_values,
+)
+
+
+def test_Text__success(dummy_render_class, mocker):
+    variablename = "foo"
+    question = Text(variablename, "gimme the foo!")
+    prompts = question.make_prompts()
+
+    dummy_render_class.prepared_input = dict(foo="bar")
+
+    mocker.patch.object(importlib.import_module("inquirer.prompt"), "ConsoleRender", new=dummy_render_class)
+    answers = prompt(prompts)
+    assert answers["foo"] == "bar"
+
+
+def test_Integer__success(dummy_render_class, mocker):
+    variablename = "foo"
+    question = Integer(variablename, "gimme the foo!")
+    prompts = question.make_prompts()
+
+    dummy_render_class.prepared_input = dict(foo=13)
+
+    mocker.patch.object(importlib.import_module("inquirer.prompt"), "ConsoleRender", new=dummy_render_class)
+    answers = prompt(prompts)
+    assert answers["foo"] == 13
+
+
+def test_Integer__fails_with_outside_of_range(dummy_render_class, mocker):
+    variablename = "foo"
+    question = Integer(variablename, "gimme the foo!", minval=14, maxval=16)
+    prompts = question.make_prompts()
+
+    dummy_render_class.prepared_input = dict(foo=13)
+
+    mocker.patch.object(importlib.import_module("inquirer.prompt"), "ConsoleRender", new=dummy_render_class)
+    with pytest.raises(ValidationError):
+        prompt(prompts)
+
+    dummy_render_class.prepared_input = dict(foo=17)
+
+    mocker.patch.object(importlib.import_module("inquirer.prompt"), "ConsoleRender", new=dummy_render_class)
+    with pytest.raises(ValidationError):
+        prompt(prompts)
+
+
+def test_List__success(dummy_render_class, mocker):
+    variablename = "foo"
+    question = List(variablename, "gimme the foo!", ["a", "b", "c"])
+    prompts = question.make_prompts()
+
+    dummy_render_class.prepared_input = dict(foo="b")
+
+    mocker.patch.object(importlib.import_module("inquirer.prompt"), "ConsoleRender", new=dummy_render_class)
+    answers = prompt(prompts)
+    assert answers["foo"] == "b"
+
+
+def test_Directory__success(tmp_path, dummy_render_class, mocker):
+    variablename = "foo"
+    question = Directory(variablename, "gimme the foo!", exists=True)
+    prompts = question.make_prompts()
+
+    dummy_render_class.prepared_input = dict(foo=tmp_path)
+
+    mocker.patch.object(importlib.import_module("inquirer.prompt"), "ConsoleRender", new=dummy_render_class)
+    answers = prompt(prompts)
+    assert answers["foo"] == tmp_path
+
+
+def test_Directory__fails_if_directory_does_not_exist(dummy_render_class, mocker):
+    variablename = "foo"
+    question = Directory(variablename, "gimme the foo!", exists=True)
+    prompts = question.make_prompts()
+
+    dummy_render_class.prepared_input = dict(foo="not/a/real/path")
+
+    mocker.patch.object(importlib.import_module("inquirer.prompt"), "ConsoleRender", new=dummy_render_class)
+    with pytest.raises(ValidationError):
+        prompt(prompts)
+
+
+def test_File__success(tmp_path, dummy_render_class, mocker):
+    variablename = "foo"
+    question = File(variablename, "gimme the foo!", exists=True)
+    prompts = question.make_prompts()
+
+    dummy_file = tmp_path / "dummy"
+    dummy_file.write_text("just some dome stuff")
+    dummy_render_class.prepared_input = dict(foo=dummy_file)
+
+    mocker.patch.object(importlib.import_module("inquirer.prompt"), "ConsoleRender", new=dummy_render_class)
+    answers = prompt(prompts)
+    assert answers["foo"] == dummy_file
+
+
+def test_File__fails_if_file_does_not_exist(dummy_render_class, mocker):
+    variablename = "foo"
+    question = File(variablename, "gimme the foo!", exists=True)
+    prompts = question.make_prompts()
+
+    dummy_render_class.prepared_input = dict(foo="not/a/real/path")
+
+    mocker.patch.object(importlib.import_module("inquirer.prompt"), "ConsoleRender", new=dummy_render_class)
+    with pytest.raises(ValidationError):
+        prompt(prompts)
+
+
+def test_Checkbox__success(dummy_render_class, mocker):
+    variablename = "foo"
+    question = Checkbox(variablename, "gimme the foo!", ["a", "b", "c"])
+    prompts = question.make_prompts()
+
+    dummy_render_class.prepared_input = dict(foo=["a", "b"])
+
+    mocker.patch.object(importlib.import_module("inquirer.prompt"), "ConsoleRender", new=dummy_render_class)
+    answers = prompt(prompts)
+    assert answers["foo"] == ["a", "b"]
+
+
+def test_Confirm__success(dummy_render_class, mocker):
+    variablename = "foo"
+    question = Confirm(variablename, "gimme the foo?")
+    prompts = question.make_prompts()
+
+    dummy_render_class.prepared_input = dict(foo=True)
+
+    mocker.patch.object(importlib.import_module("inquirer.prompt"), "ConsoleRender", new=dummy_render_class)
+    answers = prompt(prompts)
+    assert answers["foo"] is True
+
+
+def test_Const__success(dummy_render_class, mocker):
+    variablename = "foo"
+    question = Const(variablename, default="bar")
+    prompts = question.make_prompts()
+
+    dummy_render_class.prepared_input = dict()
+
+    answers = prompt(prompts, render=dummy_render_class())
+    assert answers["foo"] == "bar"
+
+
+def test_BooleanList__success(dummy_render_class, mocker):
+    variablenameTT1 = "fooTT1"
+    questionTT1 = Confirm(variablenameTT1, message="gimme the fooTT1!", default=False)
+
+    variablenameT1 = "fooT1"
+    questionT1 = BooleanList(variablenameT1, message="gimme the fooT1!", whentrue=[questionTT1], default=False)
+
+    variablenameT2 = "fooT2"
+    questionT2 = Confirm(variablenameT2, message="gimme the fooT2!", default=False)
+
+    variablenameFF1 = "fooFF1"
+    questionFF1 = Confirm(variablenameFF1, message="gimme the fooFF1!", default=False)
+
+    variablenameF1 = "fooF1"
+    questionF1 = BooleanList(variablenameF1, message="gimme the fooF1!", whentrue=[questionFF1], default=False)
+
+    variablename = "foo"
+    question = BooleanList(
+        variablename,
+        message="gimme the foo1!",
+        whentrue=[questionT1, questionT2],
+        whenfalse=[questionF1],
+        default=False,
+    )
+
+    prompts = question.make_prompts()
+
+    # We'll answer True to any questions asked to make sure we are ignoring the correct ones
+    dummy_render_class.prepared_input = dict(
+        fooTT1=True,
+        fooT1=True,
+        fooT2=True,
+        fooFF1=True,
+        fooF1=True,
+        foo=True,
+    )
+
+    mocker.patch.object(importlib.import_module("inquirer.prompt"), "ConsoleRender", new=dummy_render_class)
+    answers = dict()
+    answers = prompt(prompts, answers=answers, render=dummy_render_class())
+
+    # Only ignored questions should be false
+    assert answers == dict(
+        fooTT1=True,
+        fooT1=True,
+        fooT2=True,
+        fooFF1=False,
+        fooF1=False,
+        foo=True,
+    )
+
+
+def test_gather_config_values__basic(dummy_render_class, mocker):
+    variablename1 = "foo"
+    question1 = Text(variablename1, message="gimme the foo!")
+
+    variablename2 = "bar"
+    question2 = Text(variablename2, message="gimme the bar!")
+
+    variablename3 = "baz"
+    question3 = Text(variablename3, message="gimme the baz!")
+
+    class DummyApplication:
+        def mainflow(self, data):
+            data["nextworkflow"] = "subflow"
+            return [question1, question2]
+
+        def subflow(self, data):
+            return [question3]
+
+    dummy_render_class.prepared_input = dict(
+        foo="FOO",
+        bar="BAR",
+        baz="BAZ",
+    )
+
+    mocker.patch.object(importlib.import_module("inquirer.prompt"), "ConsoleRender", new=dummy_render_class)
+    params = gather_param_values(DummyApplication())
+    assert params == dict(
+        foo="FOO",
+        bar="BAR",
+        baz="BAZ",
+    )
+
+
+def test_gather_config_values__fast_mode(dummy_render_class, mocker):
+    variablename1 = "foo"
+    question1 = Text(variablename1, message="gimme the foo!", default="oof")
+
+    variablename2 = "bar"
+    question2 = Text(variablename2, message="gimme the bar!")
+
+    variablename3 = "baz"
+    question3 = Text(variablename3, message="gimme the baz!")
+
+    class DummyApplication:
+        def mainflow(self, data):
+            data["nextworkflow"] = "subflow"
+            return [question1, question2]
+
+        def subflow(self, data):
+            return [question3]
+
+    dummy_render_class.prepared_input = dict(
+        foo="FOO",
+        bar="BAR",
+        baz="BAZ",
+    )
+
+    mocker.patch.object(importlib.import_module("inquirer.prompt"), "ConsoleRender", new=dummy_render_class)
+    params = gather_param_values(DummyApplication(), fast_mode=True)
+    assert params == dict(
+        foo="oof",
+        bar="BAR",
+        baz="BAZ",
+    )
+
+
+def test_gather_config_values__skips_supplied_params(dummy_render_class, mocker):
+    variablename1 = "foo"
+    question1 = Text(variablename1, message="gimme the foo!", default="oof")
+
+    variablename2 = "bar"
+    question2 = Text(variablename2, message="gimme the bar!")
+
+    variablename3 = "baz"
+    question3 = Text(variablename3, message="gimme the baz!")
+
+    class DummyApplication:
+        def mainflow(self, data):
+            data["nextworkflow"] = "subflow"
+            return [question1, question2]
+
+        def subflow(self, data):
+            return [question3]
+
+    dummy_render_class.prepared_input = dict(
+        foo="FOO",
+        bar="BAR",
+        baz="BAZ",
+    )
+
+    supplied_params = dict(bar="rab")
+    mocker.patch.object(importlib.import_module("inquirer.prompt"), "ConsoleRender", new=dummy_render_class)
+    params = gather_param_values(DummyApplication(), supplied_params=supplied_params)
+    assert params == dict(
+        foo="FOO",
+        bar="rab",
+        baz="BAZ",
+    )
+
+
+def test_gather_config_values__raises_Abort_if_method_not_implemented(dummy_render_class, mocker):
+    variablename1 = "foo"
+    question1 = Text(variablename1, message="gimme the foo!")
+
+    class DummyApplication:
+        def mainflow(self, data):
+            data["nextworkflow"] = "subflow"
+            return [question1]
+
+        def subflow(self, data):
+            raise NotImplementedError("BOOM!")
+
+    dummy_render_class.prepared_input = dict(
+        foo="FOO",
+    )
+
+    mocker.patch.object(importlib.import_module("inquirer.prompt"), "ConsoleRender", new=dummy_render_class)
+    with pytest.raises(Abort, match="not implemented"):
+        gather_param_values(DummyApplication())
diff --git a/jobbergate-cli/tests/subapps/applications/test_tools.py b/jobbergate-cli/tests/subapps/applications/test_tools.py
new file mode 100644
index 00000000..8ae68543
--- /dev/null
+++ b/jobbergate-cli/tests/subapps/applications/test_tools.py
@@ -0,0 +1,421 @@
+import importlib
+import pathlib
+import re
+import tarfile
+
+import httpx
+import pytest
+import yaml
+
+from jobbergate_cli.constants import (
+    JOBBERGATE_APPLICATION_CONFIG,
+    JOBBERGATE_APPLICATION_CONFIG_FILE_NAME,
+    JOBBERGATE_APPLICATION_MODULE_FILE_NAME,
+)
+from jobbergate_cli.exceptions import Abort
+from jobbergate_cli.schemas import ApplicationResponse, JobbergateApplicationConfig
+from jobbergate_cli.subapps.applications.application_base import JobbergateApplicationBase
+from jobbergate_cli.subapps.applications.tools import (
+    build_application_tarball,
+    dump_full_config,
+    execute_application,
+    fetch_application_data,
+    load_application_data,
+    load_application_from_source,
+    load_default_config,
+    read_application_module,
+    validate_application_files,
+)
+from jobbergate_cli.text_tools import dedent
+
+
+def test_validate_application_files__success(tmp_path):
+    application_path = tmp_path / "dummy"
+    application_path.mkdir()
+    application_module = application_path / JOBBERGATE_APPLICATION_MODULE_FILE_NAME
+    application_module.write_text(
+        dedent(
+            """
+            import sys
+
+            print(f"Got some args, yo: {sys.argv}")
+            """
+        )
+    )
+    application_config = application_path / JOBBERGATE_APPLICATION_CONFIG_FILE_NAME
+    application_config.write_text(
+        dedent(
+            """
+            foo:
+              bar: baz
+            """
+        )
+    )
+    validate_application_files(application_path)
+
+
+def test_validate_application_files__fails_if_application_directory_does_not_exist(tmp_path):
+    application_path = tmp_path / "dummy"
+
+    match_pattern = re.compile(
+        f"application files in {application_path} were invalid.*directory {application_path} does not exist",
+        flags=re.DOTALL,
+    )
+
+    with pytest.raises(Abort, match=match_pattern):
+        validate_application_files(application_path)
+
+
+def test_validate_application_files__fails_if_application_module_does_not_exist(tmp_path):
+    application_path = tmp_path / "dummy"
+    application_path.mkdir()
+
+    match_pattern = re.compile(
+        f"application files in {application_path} were invalid.*does not contain required application module",
+        flags=re.DOTALL,
+    )
+
+    with pytest.raises(Abort, match=match_pattern):
+        validate_application_files(application_path)
+
+
+def test_validate_application_files__fails_if_application_module_is_not_valid_python(tmp_path):
+    application_path = tmp_path / "dummy"
+    application_path.mkdir()
+    application_module = application_path / JOBBERGATE_APPLICATION_MODULE_FILE_NAME
+    application_module.write_text("[")
+
+    match_pattern = re.compile(
+        f"application files in {application_path} were invalid.*not valid python",
+        flags=re.DOTALL,
+    )
+
+    with pytest.raises(Abort, match=match_pattern):
+        validate_application_files(application_path)
+
+
+def test_validate_application_files__fails_if_application_config_does_not_exist(tmp_path):
+    application_path = tmp_path / "dummy"
+    application_path.mkdir()
+
+    match_pattern = re.compile(
+        f"application files in {application_path} were invalid.*does not contain required configuration file",
+        flags=re.DOTALL,
+    )
+
+    with pytest.raises(Abort, match=match_pattern):
+        validate_application_files(application_path)
+
+
+def test_validate_application_files__fails_if_application_config_is_not_valid_yaml(tmp_path):
+    application_path = tmp_path / "dummy"
+    application_path.mkdir()
+    application_config = application_path / JOBBERGATE_APPLICATION_CONFIG_FILE_NAME
+    application_config.write_text(":")
+
+    match_pattern = re.compile(
+        f"application files in {application_path} were invalid.*not valid YAML",
+        flags=re.DOTALL,
+    )
+
+    with pytest.raises(Abort, match=match_pattern):
+        validate_application_files(application_path)
+
+
+def test_dump_full_config(tmp_path):
+    application_path = tmp_path / "dummy"
+    application_path.mkdir()
+    template_root_path = application_path / "templates"
+    template_root_path.mkdir()
+    file1 = template_root_path / "file1"
+    file1.write_text("foo")
+    file2 = template_root_path / "file2"
+    file2.write_text("bar")
+    config_path = application_path / JOBBERGATE_APPLICATION_CONFIG_FILE_NAME
+    config_path.write_text(
+        dedent(
+            """
+            jobbergate_config:
+              default_template: test-job-script.py.j2
+              output_directory: .
+            application_config:
+              partition: debug
+            """
+        )
+    )
+
+    assert yaml.safe_load(dump_full_config(application_path)) == dict(
+        jobbergate_config=dict(
+            default_template="test-job-script.py.j2",
+            output_directory=".",
+            template_files=[
+                "templates/file1",
+                "templates/file2",
+            ],
+        ),
+        application_config=dict(
+            partition="debug",
+        ),
+    )
+
+
+def test_load_default_config():
+    default_config = load_default_config()
+    assert default_config == JOBBERGATE_APPLICATION_CONFIG
+    default_config["foo"] = "bar"
+    assert default_config != JOBBERGATE_APPLICATION_CONFIG
+
+
+def test_read_application_module(tmp_path):
+    application_path = tmp_path / "dummy"
+    application_path.mkdir()
+
+    module_path = application_path / JOBBERGATE_APPLICATION_MODULE_FILE_NAME
+    module_path.write_text("print('foo')")
+
+    assert read_application_module(application_path) == "print('foo')"
+
+
+def test_build_application_tarball(
+    tmp_path,
+    dummy_application_dir,
+    dummy_config_source,
+    dummy_module_source,
+    dummy_template_source,
+):
+    build_path = tmp_path / "build"
+    build_path.mkdir()
+    tar_path = build_application_tarball(dummy_application_dir, build_path)
+
+    assert tar_path.exists()
+    assert tarfile.is_tarfile(tar_path)
+
+    extract_path = tmp_path / "extract"
+    extract_path.mkdir()
+    with tarfile.open(tar_path) as tar_file:
+        tar_file.extractall(extract_path)
+
+    module_path = extract_path / JOBBERGATE_APPLICATION_MODULE_FILE_NAME
+    assert module_path.exists()
+    assert module_path.read_text() == dummy_module_source
+
+    config_path = extract_path / JOBBERGATE_APPLICATION_CONFIG_FILE_NAME
+    assert config_path.exists()
+    assert config_path.read_text() == dummy_config_source
+
+    template_root_path = extract_path / "templates"
+    assert template_root_path.exists()
+
+    template_path = template_root_path / "job-script-template.py.j2"
+    assert template_path.exists()
+    assert template_path.read_text() == dummy_template_source
+
+    ignored_path = extract_path / "ignored"
+    assert not ignored_path.exists()
+
+
+def test_fetch_application_data__success__using_id(
+    respx_mock,
+    dummy_context,
+    dummy_application_data,
+    dummy_domain,
+):
+    app_data = dummy_application_data[0]
+    app_id = app_data["id"]
+    fetch_route = respx_mock.get(f"{dummy_domain}/applications/{app_id}")
+    fetch_route.mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=app_data,
+        ),
+    )
+
+    result = fetch_application_data(dummy_context, id=app_id)
+    assert fetch_route.called
+    assert result == ApplicationResponse(**app_data)
+
+
+def test_fetch_application_data__success__using_identifier(
+    respx_mock,
+    dummy_context,
+    dummy_application_data,
+    dummy_domain,
+):
+    app_data = dummy_application_data[0]
+    app_identifier = app_data["application_identifier"]
+    fetch_route = respx_mock.get(f"{dummy_domain}/applications?identifier={app_identifier}")
+    fetch_route.mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=app_data,
+        ),
+    )
+
+    result = fetch_application_data(dummy_context, identifier=app_identifier)
+    assert fetch_route.called
+    assert result == ApplicationResponse(**app_data)
+
+
+def test_fetch_application_data__fails_with_both_id_or_identifier(dummy_context):
+    with pytest.raises(Abort, match="You may not supply both"):
+        fetch_application_data(dummy_context, id=1, identifier="one")
+
+
+def test_fetch_application_data__fails_with_neither_id_or_identifier(dummy_context):
+    with pytest.raises(Abort, match="You must supply either"):
+        fetch_application_data(dummy_context)
+
+
+def test_load_application_data__success(dummy_module_source, dummy_config_source):
+    app_data = ApplicationResponse(
+        id=13,
+        application_name="dummy",
+        application_owner_email="dummy@dummy.org",
+        application_uploaded=True,
+        application_file=dummy_module_source,
+        application_config=dummy_config_source,
+    )
+    (app_config, app_module) = load_application_data(app_data)
+    assert isinstance(app_module, JobbergateApplicationBase)
+    assert isinstance(app_config, JobbergateApplicationConfig)
+
+
+def test_load_application_data__fails_if_application_module_cannot_be_loaded_from_source(dummy_config_source):
+    app_data = ApplicationResponse(
+        id=13,
+        application_name="dummy",
+        application_owner_email="dummy@dummy.org",
+        application_uploaded=True,
+        application_file="Not python at all",
+        application_config=dummy_config_source,
+    )
+
+    with pytest.raises(Abort, match="The application source fetched from the API is not valid"):
+        load_application_data(app_data)
+
+
+def test_load_application_data__fails_if_application_config_is_not_valid_YAML(dummy_module_source):
+    app_data = ApplicationResponse(
+        id=13,
+        application_name="dummy",
+        application_owner_email="dummy@dummy.org",
+        application_uploaded=True,
+        application_file=dummy_module_source,
+        application_config=":",
+    )
+
+    with pytest.raises(Abort, match="The application config fetched from the API is not valid"):
+        load_application_data(app_data)
+
+
+def test_load_application_data__fails_if_application_config_is_not_valid_JobbergateApplicationConfig(
+    dummy_module_source,
+):
+    app_data = ApplicationResponse(
+        id=13,
+        application_name="dummy",
+        application_owner_email="dummy@dummy.org",
+        application_uploaded=True,
+        application_file=dummy_module_source,
+        application_config=dedent(
+            """
+            foo: bar
+            """
+        ),
+    )
+
+    with pytest.raises(Abort, match="The application config fetched from the API is not valid"):
+        load_application_data(app_data)
+
+
+def test_load_application_from_source__success(dummy_module_source, dummy_jobbergate_application_config):
+    application = load_application_from_source(dummy_module_source, dummy_jobbergate_application_config)
+    assert isinstance(application, JobbergateApplicationBase)
+    assert application.mainflow
+    assert application.jobbergate_config == dict(
+        default_template="test-job-script.py.j2",
+        template_files=[pathlib.Path("test-job-script.py.j2")],
+        output_directory=pathlib.Path("."),
+        supporting_files=None,
+        supporting_files_output_name=None,
+        job_script_name=None,
+    )
+    assert application.application_config == dict(
+        foo="foo",
+        bar="bar",
+        baz="baz",
+    )
+
+
+def test_execute_application__basic(
+    dummy_render_class,
+    dummy_jobbergate_application_config,
+    dummy_jobbergate_application_module,
+    mocker,
+):
+    dummy_render_class.prepared_input = dict(
+        foo="FOO",
+        bar="BAR",
+        baz="BAZ",
+    )
+
+    mocker.patch.object(importlib.import_module("inquirer.prompt"), "ConsoleRender", new=dummy_render_class)
+    app_params = execute_application(
+        dummy_jobbergate_application_module,
+        dummy_jobbergate_application_config,
+    )
+    assert app_params == dict(
+        foo="FOO",
+        bar="BAR",
+        baz="BAZ",
+    )
+
+
+def test_execute_application__with_supplied_params(
+    dummy_render_class,
+    dummy_jobbergate_application_config,
+    dummy_jobbergate_application_module,
+    mocker,
+):
+    dummy_render_class.prepared_input = dict(
+        foo="FOO",
+        bar="BAR",
+        baz="BAZ",
+    )
+
+    mocker.patch.object(importlib.import_module("inquirer.prompt"), "ConsoleRender", new=dummy_render_class)
+    app_params = execute_application(
+        dummy_jobbergate_application_module,
+        dummy_jobbergate_application_config,
+        supplied_params=dict(foo="oof"),
+    )
+    assert app_params == dict(
+        foo="oof",
+        bar="BAR",
+        baz="BAZ",
+    )
+
+
+def test_execute_application__with_fast_mode(
+    dummy_render_class,
+    dummy_jobbergate_application_config,
+    dummy_jobbergate_application_module,
+    mocker,
+):
+    dummy_render_class.prepared_input = dict(
+        foo="FOO",
+        bar="BAR",
+        baz="BAZ",
+    )
+
+    mocker.patch.object(importlib.import_module("inquirer.prompt"), "ConsoleRender", new=dummy_render_class)
+    app_params = execute_application(
+        dummy_jobbergate_application_module,
+        dummy_jobbergate_application_config,
+        fast_mode=True,
+    )
+    assert app_params == dict(
+        foo="FOO",
+        bar="BAR",
+        baz="zab",  # Only 'baz' has a default value, so it should be used
+    )
diff --git a/jobbergate-cli/tests/subapps/conftest.py b/jobbergate-cli/tests/subapps/conftest.py
new file mode 100644
index 00000000..a397ed1a
--- /dev/null
+++ b/jobbergate-cli/tests/subapps/conftest.py
@@ -0,0 +1,292 @@
+import json
+from typing import Any, Callable, Dict
+
+import httpx
+import pytest
+import yaml
+from typer import Context, Typer
+from typer.testing import CliRunner
+
+from jobbergate_cli.constants import JOBBERGATE_APPLICATION_CONFIG_FILE_NAME, JOBBERGATE_APPLICATION_MODULE_FILE_NAME
+from jobbergate_cli.schemas import IdentityData, JobbergateApplicationConfig, JobbergateContext, Persona, TokenSet
+from jobbergate_cli.subapps.applications.tools import load_application_from_source
+from jobbergate_cli.text_tools import dedent
+
+
+@pytest.fixture
+def dummy_domain():
+    return "https://dummy.com"
+
+
+@pytest.fixture
+def cli_runner():
+    return CliRunner()
+
+
+@pytest.fixture
+def make_test_app(dummy_context):
+    def _main_callback(ctx: Context):
+        ctx.obj = dummy_context
+
+    def _helper(command_name: str, command_function: Callable):
+        main_app = Typer()
+        main_app.callback()(_main_callback)
+        main_app.command(name=command_name)(command_function)
+        return main_app
+
+    return _helper
+
+
+@pytest.fixture
+def dummy_context(dummy_domain):
+    return JobbergateContext(
+        persona=None,
+        client=httpx.Client(base_url=dummy_domain, headers={"Authorization": "Bearer XXXXXXXX"}),
+    )
+
+
+@pytest.fixture
+def attach_persona(dummy_context):
+    def _helper(user_email: str, org_name: str = "dumb-org", access_token: str = "foo"):
+        dummy_context.persona = Persona(
+            token_set=TokenSet(access_token=access_token),
+            identity_data=IdentityData(
+                user_email=user_email,
+                org_name=org_name,
+            ),
+        )
+
+    return _helper
+
+
+@pytest.fixture
+def dummy_application_data(dummy_module_source, dummy_config_source):
+    return [
+        dict(
+            id=1,
+            application_name="test-app-1",
+            application_identifier="test-app-1",
+            application_description="Test Application Number 1",
+            application_owner_email="tucker.beck@omnivector.solutions",
+            application_file=dummy_module_source,
+            application_config=dummy_config_source,
+            application_uploaded=True,
+            created_at="2022-03-01 17:31:00",
+            updated_at="2022-03-01 17:31:00",
+        ),
+        dict(
+            id=2,
+            application_name="test-app-2",
+            application_identifier="test-app-2",
+            application_description="Test Application Number 2",
+            application_owner_email="tucker.beck@omnivector.solutions",
+            application_file="print('test2')",
+            application_config="config2",
+            application_uploaded=True,
+            created_at="2022-03-01 17:31:00",
+            updated_at="2022-03-01 17:31:00",
+        ),
+        dict(
+            id=3,
+            application_name="test-app-3",
+            application_identifier="test-app-3",
+            application_description="Test Application Number 3",
+            application_owner_email="tucker.beck@omnivector.solutions",
+            application_file="print('test3')",
+            application_config="config3",
+            application_uploaded=True,
+            created_at="2022-03-01 17:31:00",
+            updated_at="2022-03-01 17:31:00",
+        ),
+    ]
+
+
+@pytest.fixture
+def dummy_job_script_data(dummy_application_data, dummy_template_source):
+    return [
+        dict(
+            id=1,
+            created_at="2022-03-02 22:08:00",
+            updated_at="2022-03-02 22:08:00",
+            job_script_name="script1",
+            job_script_description="Job Script 1",
+            job_script_data_as_string=json.dumps({"application.sh": dummy_template_source}),
+            job_script_owner_email="tucker@omnivector.solutions",
+            application_id=dummy_application_data[0]["id"],
+        ),
+        dict(
+            id=2,
+            created_at="2022-03-02 22:08:00",
+            updated_at="2022-03-02 22:08:00",
+            job_script_name="script2",
+            job_script_description="Job Script 2",
+            job_script_data_as_string="{}",
+            job_script_owner_email="tucker@omnivector.solutions",
+            application_id=1,
+        ),
+        dict(
+            id=3,
+            created_at="2022-03-02 22:08:00",
+            updated_at="2022-03-02 22:08:00",
+            job_script_name="script3",
+            job_script_description="Job Script 3",
+            job_script_data_as_string="{}",
+            job_script_owner_email="james@omnivector.solutions",
+            application_id=1,
+        ),
+    ]
+
+
+@pytest.fixture
+def dummy_job_submission_data(dummy_job_script_data):
+    return [
+        dict(
+            id=1,
+            created_at="2022-03-02 22:08:00",
+            updated_at="2022-03-02 22:08:00",
+            job_submission_name="sub1",
+            job_submission_description="Job Submission 1",
+            job_submission_owner_email="tucker@omnivector.solutions",
+            job_script_id=dummy_job_script_data[0]["id"],
+            slurm_job_id=13,
+        ),
+        dict(
+            id=1,
+            created_at="2022-03-02 22:08:00",
+            updated_at="2022-03-02 22:08:00",
+            job_submission_name="sub1",
+            job_submission_description="Job Submission 1",
+            job_submission_owner_email="tucker@omnivector.solutions",
+            job_script_id=88,
+            slurm_job_id=8888,
+        ),
+        dict(
+            id=3,
+            created_at="2022-03-02 22:08:00",
+            updated_at="2022-03-02 22:08:00",
+            job_submission_name="sub3",
+            job_submission_description="Job Submission 3",
+            job_submission_owner_email="tucker@omnivector.solutions",
+            job_script_id=99,
+            slurm_job_id=9999,
+        ),
+    ]
+
+
+@pytest.fixture(scope="module")
+def dummy_config_source():
+    return dedent(
+        """
+        jobbergate_config:
+          default_template: test-job-script.py.j2
+          template_files:
+            - test-job-script.py.j2
+          output_directory: .
+          supporting_files_output_name:
+          supporting_files:
+          job_script_name:
+        application_config:
+          foo: foo
+          bar: bar
+          baz: baz
+        """
+    )
+
+
+@pytest.fixture(scope="module")
+def dummy_module_source():
+    return dedent(
+        """
+        from jobbergate_cli.subapps.applications.application_base import JobbergateApplicationBase
+        from jobbergate_cli.subapps.applications.questions import Text
+
+
+        class JobbergateApplication(JobbergateApplicationBase):
+
+            def mainflow(self, data):
+                data["nextworkflow"] = "subflow"
+                return [Text("foo", message="gimme the foo!"), Text("bar", message="gimme the bar!")]
+
+            def subflow(self, data):
+                return [Text("baz", message="gimme the baz!", default="zab")]
+        """
+    )
+
+
+@pytest.fixture(scope="module")
+def dummy_template_source():
+    return dedent(
+        """
+        #!/bin/python3
+
+        #SBATCH -J dummy_job
+        #SBATCH -t 60
+        print("I am a very, very dumb job script")
+        print(f"foo='{{foo}}'")
+        print(f"bar='{{bar}}'")
+        print(f"baz='{{baz}}'")
+        """
+    )
+
+
+@pytest.fixture
+def dummy_jobbergate_application_config(dummy_config_source):
+    return JobbergateApplicationConfig(**yaml.safe_load(dummy_config_source))
+
+
+@pytest.fixture
+def dummy_jobbergate_application_module(dummy_module_source, dummy_jobbergate_application_config):
+    return load_application_from_source(dummy_module_source, dummy_jobbergate_application_config)
+
+
+@pytest.fixture
+def dummy_application_dir(tmp_path, dummy_config_source, dummy_module_source, dummy_template_source):
+    application_path = tmp_path / "dummy"
+    application_path.mkdir()
+
+    module_path = application_path / JOBBERGATE_APPLICATION_MODULE_FILE_NAME
+    module_path.write_text(dummy_module_source)
+
+    config_path = application_path / JOBBERGATE_APPLICATION_CONFIG_FILE_NAME
+    config_path.write_text(dummy_config_source)
+
+    template_root_path = application_path / "templates"
+    template_root_path.mkdir()
+
+    template_path = template_root_path / "job-script-template.py.j2"
+    template_path.write_text(dummy_template_source)
+
+    ignored_path = application_path / "ignored"
+    ignored_path.mkdir()
+
+    ignored_file = ignored_path / "ignored.txt"
+    ignored_file.write_text("This file should be ignored")
+
+    return application_path
+
+
+@pytest.fixture
+def dummy_render_class():
+    class DummyRender:
+        prepared_input: Dict[str, Any]
+
+        def __init__(self, *args, **kwargs):
+            self.args = args
+            self.kwargs = kwargs
+
+        def render(self, question, answers=None):
+            question.answers = answers if answers is not None else dict()
+
+            try:
+                ignore = question.ignore(answers)
+            except TypeError:
+                ignore = question.ignore
+
+            if ignore:
+                return question.default
+
+            value = self.prepared_input[question.name]
+            question.validate(value)
+            return value
+
+    return DummyRender
diff --git a/jobbergate-cli/tests/subapps/job_scripts/__init__.py b/jobbergate-cli/tests/subapps/job_scripts/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/jobbergate-cli/tests/subapps/job_scripts/test_app.py b/jobbergate-cli/tests/subapps/job_scripts/test_app.py
new file mode 100644
index 00000000..b8698681
--- /dev/null
+++ b/jobbergate-cli/tests/subapps/job_scripts/test_app.py
@@ -0,0 +1,367 @@
+import importlib
+import json
+import shlex
+
+import httpx
+
+from jobbergate_cli.schemas import (
+    ApplicationResponse,
+    JobScriptResponse,
+    JobSubmissionResponse,
+    ListResponseEnvelope,
+    Pagination,
+)
+from jobbergate_cli.subapps.job_scripts.app import (
+    HIDDEN_FIELDS,
+    JOB_SUBMISSION_HIDDEN_FIELDS,
+    create,
+    delete,
+    get_one,
+    list_all,
+    style_mapper,
+    update,
+)
+from jobbergate_cli.text_tools import unwrap
+
+
+def test_list_all__makes_request_and_renders_results(
+    respx_mock,
+    make_test_app,
+    dummy_context,
+    dummy_job_script_data,
+    dummy_domain,
+    cli_runner,
+    mocker,
+):
+    respx_mock.get(f"{dummy_domain}/job-scripts?all=false").mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=dict(
+                results=dummy_job_script_data,
+                pagination=dict(
+                    total=3,
+                ),
+            ),
+        ),
+    )
+    test_app = make_test_app("list-all", list_all)
+    mocked_render = mocker.patch("jobbergate_cli.subapps.job_scripts.app.render_list_results")
+    result = cli_runner.invoke(test_app, ["list-all"])
+    assert result.exit_code == 0, f"list-all failed: {result.stdout}"
+    mocked_render.assert_called_once_with(
+        dummy_context,
+        ListResponseEnvelope(
+            results=dummy_job_script_data,
+            pagination=Pagination(total=3, start=None, limit=None),
+        ),
+        title="Job Scripts List",
+        style_mapper=style_mapper,
+        hidden_fields=HIDDEN_FIELDS,
+    )
+
+
+def test_get_one__success(
+    respx_mock,
+    make_test_app,
+    dummy_context,
+    dummy_job_script_data,
+    dummy_domain,
+    cli_runner,
+    mocker,
+):
+    respx_mock.get(f"{dummy_domain}/job-scripts/1").mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=dummy_job_script_data[0],
+        ),
+    )
+    test_app = make_test_app("get-one", get_one)
+    mocked_render = mocker.patch("jobbergate_cli.subapps.job_scripts.app.render_single_result")
+    result = cli_runner.invoke(test_app, shlex.split("get-one --id=1"))
+    assert result.exit_code == 0, f"get-one failed: {result.stdout}"
+    mocked_render.assert_called_once_with(
+        dummy_context,
+        JobScriptResponse(**dummy_job_script_data[0]),
+        title="Job Script",
+        hidden_fields=HIDDEN_FIELDS,
+    )
+
+
+def test_create__full_run_including_non_fast_mode_and_job_submission(
+    respx_mock,
+    make_test_app,
+    dummy_context,
+    dummy_application_data,
+    dummy_job_script_data,
+    dummy_job_submission_data,
+    dummy_domain,
+    dummy_render_class,
+    cli_runner,
+    tmp_path,
+    attach_persona,
+    mocker,
+):
+    application_response = ApplicationResponse(**dummy_application_data[0])
+
+    job_script_data = dummy_job_script_data[0]
+    job_script_id = job_script_data["id"]
+
+    job_submission_data = dummy_job_submission_data[0]
+
+    create_route = respx_mock.post(f"{dummy_domain}/job-scripts")
+    create_route.mock(
+        return_value=httpx.Response(
+            httpx.codes.CREATED,
+            json=job_script_data,
+        ),
+    )
+
+    sbatch_params = " ".join(f"--sbatch-params={i}" for i in (1, 2, 3))
+
+    param_file_path = tmp_path / "param_file.json"
+    param_file_path.write_text(json.dumps(dict(foo="oof")))
+
+    dummy_render_class.prepared_input = dict(
+        foo="FOO",
+        bar="BAR",
+        baz="BAZ",
+    )
+
+    attach_persona("dummy@dummy.com")
+
+    test_app = make_test_app("create", create)
+    mocked_render = mocker.patch("jobbergate_cli.subapps.job_scripts.app.render_single_result")
+    mocked_fetch_application_data = mocker.patch(
+        "jobbergate_cli.subapps.job_scripts.app.fetch_application_data",
+        return_value=application_response,
+    )
+    mocked_create_job_submission = mocker.patch(
+        "jobbergate_cli.subapps.job_scripts.app.create_job_submission",
+        return_value=JobSubmissionResponse(**job_submission_data),
+    )
+    mocker.patch.object(
+        importlib.import_module("inquirer.prompt"),
+        "ConsoleRender",
+        new=dummy_render_class,
+    )
+    result = cli_runner.invoke(
+        test_app,
+        shlex.split(
+            unwrap(
+                f"""
+                create --name=dummy-name
+                       --application-id={application_response.id}
+                       --param-file={param_file_path}
+                       {sbatch_params}
+                """
+            )
+        ),
+        input="y\n",  # To confirm that the job should be submitted right away
+    )
+    assert result.exit_code == 0, f"create failed: {result.stdout}"
+    assert mocked_fetch_application_data.called_once_with(
+        dummy_context,
+        id=application_response.id,
+        identifier=None,
+    )
+    assert mocked_create_job_submission.called_once_with(
+        dummy_context,
+        job_script_id,
+        "dummy-name",
+    )
+    assert create_route.called
+    content = json.loads(create_route.calls.last.request.content)
+    assert content == dict(
+        param_dict=dict(
+            application_config=dict(
+                foo="oof",
+                bar="BAR",
+                baz="BAZ",
+            ),
+            jobbergate_config=dict(
+                default_template="test-job-script.py.j2",
+                job_script_name=None,
+                output_directory=".",
+                supporting_files=None,
+                supporting_files_output_name=None,
+                template_files=["test-job-script.py.j2"],
+            ),
+        ),
+        application_id=application_response.id,
+        job_script_name="dummy-name",
+        sbatch_params=["1", "2", "3"],
+    )
+
+    mocked_render.assert_has_calls(
+        [
+            mocker.call(
+                dummy_context,
+                JobScriptResponse(**job_script_data),
+                title="Created Job Script",
+                hidden_fields=HIDDEN_FIELDS,
+            ),
+            mocker.call(
+                dummy_context,
+                JobSubmissionResponse(**job_submission_data),
+                title="Created Job Submission (Fast Mode)",
+                hidden_fields=JOB_SUBMISSION_HIDDEN_FIELDS,
+            ),
+        ]
+    )
+
+
+def test_create__with_fast_mode_and_no_job_submission(
+    respx_mock,
+    make_test_app,
+    dummy_context,
+    dummy_application_data,
+    dummy_job_script_data,
+    dummy_domain,
+    cli_runner,
+    tmp_path,
+    attach_persona,
+    mocker,
+):
+    application_response = ApplicationResponse(**dummy_application_data[0])
+
+    job_script_data = dummy_job_script_data[0]
+
+    create_route = respx_mock.post(f"{dummy_domain}/job-scripts")
+    create_route.mock(
+        return_value=httpx.Response(
+            httpx.codes.CREATED,
+            json=job_script_data,
+        ),
+    )
+
+    sbatch_params = " ".join(f"--sbatch-params={i}" for i in (1, 2, 3))
+
+    param_file_path = tmp_path / "param_file.json"
+    param_file_path.write_text(
+        json.dumps(
+            dict(
+                foo="oof",
+                bar="rab",
+                baz="zab",
+            )
+        )
+    )
+
+    attach_persona("dummy@dummy.com")
+
+    test_app = make_test_app("create", create)
+    mocked_render = mocker.patch("jobbergate_cli.subapps.job_scripts.app.render_single_result")
+    mocked_fetch_application_data = mocker.patch(
+        "jobbergate_cli.subapps.job_scripts.app.fetch_application_data",
+        return_value=application_response,
+    )
+    result = cli_runner.invoke(
+        test_app,
+        shlex.split(
+            unwrap(
+                f"""
+                create --name=dummy-name
+                       --application-id={application_response.id}
+                       --param-file={param_file_path}
+                       --fast
+                       --no-submit
+                       {sbatch_params}
+                """
+            )
+        ),
+    )
+    assert result.exit_code == 0, f"create failed: {result.stdout}"
+    assert mocked_fetch_application_data.called_once_with(
+        dummy_context,
+        id=application_response.id,
+        identifier=None,
+    )
+    assert create_route.called
+    content = json.loads(create_route.calls.last.request.content)
+    assert content == dict(
+        param_dict=dict(
+            application_config=dict(
+                foo="oof",
+                bar="rab",
+                baz="zab",
+            ),
+            jobbergate_config=dict(
+                default_template="test-job-script.py.j2",
+                job_script_name=None,
+                output_directory=".",
+                supporting_files=None,
+                supporting_files_output_name=None,
+                template_files=["test-job-script.py.j2"],
+            ),
+        ),
+        application_id=application_response.id,
+        job_script_name="dummy-name",
+        sbatch_params=["1", "2", "3"],
+    )
+
+    mocked_render.assert_called_once_with(
+        dummy_context,
+        JobScriptResponse(**job_script_data),
+        title="Created Job Script",
+        hidden_fields=HIDDEN_FIELDS,
+    )
+
+
+def test_update__makes_request_and_renders_results(
+    respx_mock,
+    make_test_app,
+    dummy_context,
+    dummy_job_script_data,
+    dummy_domain,
+    cli_runner,
+    mocker,
+):
+    job_script_data = dummy_job_script_data[0]
+    job_script_id = job_script_data["id"]
+
+    new_job_script_data_as_string = json.dumps({"application.sh": "#!bin/bash \\n echo so dumb"})
+    new_job_script_data = {
+        **job_script_data,
+        "job_script_data_as_string": new_job_script_data_as_string,
+    }
+    respx_mock.put(f"{dummy_domain}/job-scripts/{job_script_id}").mock(
+        return_value=httpx.Response(httpx.codes.OK, json=new_job_script_data),
+    )
+    test_app = make_test_app("update", update)
+    mocked_render = mocker.patch("jobbergate_cli.subapps.job_scripts.app.render_single_result")
+    result = cli_runner.invoke(
+        test_app,
+        shlex.split(
+            unwrap(
+                f"""
+                update --id={job_script_id}
+                       --job-script='{new_job_script_data_as_string}'
+                """
+            )
+        ),
+    )
+    assert result.exit_code == 0, f"update failed: {result.stdout}"
+    mocked_render.assert_called_once_with(
+        dummy_context,
+        JobScriptResponse(**new_job_script_data),
+        title="Updated Job Script",
+        hidden_fields=HIDDEN_FIELDS,
+    )
+
+
+def test_delete__makes_request_and_sends_terminal_message(
+    respx_mock,
+    make_test_app,
+    dummy_domain,
+    cli_runner,
+):
+    job_script_id = 13
+
+    delete_route = respx_mock.delete(f"{dummy_domain}/job-scripts/{job_script_id}").mock(
+        return_value=httpx.Response(httpx.codes.NO_CONTENT),
+    )
+    test_app = make_test_app("delete", delete)
+    result = cli_runner.invoke(test_app, shlex.split(f"delete --id={job_script_id}"))
+    assert result.exit_code == 0, f"delete failed: {result.stdout}"
+    assert delete_route.called
+    assert "JOB SCRIPT DELETE SUCCEEDED"
diff --git a/jobbergate-cli/tests/subapps/job_scripts/test_tools.py b/jobbergate-cli/tests/subapps/job_scripts/test_tools.py
new file mode 100644
index 00000000..ab29a6e5
--- /dev/null
+++ b/jobbergate-cli/tests/subapps/job_scripts/test_tools.py
@@ -0,0 +1,48 @@
+import json
+import pathlib
+
+import httpx
+import pytest
+
+from jobbergate_cli.exceptions import Abort
+from jobbergate_cli.schemas import JobScriptResponse
+from jobbergate_cli.subapps.job_scripts.tools import fetch_job_script_data, validate_parameter_file
+
+
+def test_validate_parameter_file__success(tmp_path):
+    parameter_path = tmp_path / "dummy.json"
+    dummy_data = dict(
+        foo="one",
+        bar=2,
+        baz=False,
+    )
+    parameter_path.write_text(json.dumps(dummy_data))
+    assert validate_parameter_file(parameter_path) == dummy_data
+
+
+def test_validate_parameter_file__fails_if_file_does_not_exist():
+    with pytest.raises(Abort, match="does not exist"):
+        validate_parameter_file(pathlib.Path("some/fake/path"))
+
+
+def test_validate_parameter_file__fails_if_file_is_not_valid_json(tmp_path):
+    parameter_path = tmp_path / "dummy.json"
+    parameter_path.write_text("clearly not json")
+    with pytest.raises(Abort, match="is not valid JSON"):
+        validate_parameter_file(parameter_path)
+
+
+def test_fetch_job_script_data__success(
+    respx_mock,
+    dummy_context,
+    dummy_job_script_data,
+    dummy_domain,
+):
+    respx_mock.get(f"{dummy_domain}/job-scripts/1").mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=dummy_job_script_data[0],
+        ),
+    )
+    job_script = fetch_job_script_data(dummy_context, 1)
+    assert job_script == JobScriptResponse(**dummy_job_script_data[0])
diff --git a/jobbergate-cli/tests/subapps/job_submissions/__init__.py b/jobbergate-cli/tests/subapps/job_submissions/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/jobbergate-cli/tests/subapps/job_submissions/test_app.py b/jobbergate-cli/tests/subapps/job_submissions/test_app.py
new file mode 100644
index 00000000..5d549a5e
--- /dev/null
+++ b/jobbergate-cli/tests/subapps/job_submissions/test_app.py
@@ -0,0 +1,127 @@
+import shlex
+
+import httpx
+
+from jobbergate_cli.schemas import JobSubmissionResponse, ListResponseEnvelope, Pagination
+from jobbergate_cli.subapps.job_submissions.app import HIDDEN_FIELDS, create, delete, get_one, list_all, style_mapper
+from jobbergate_cli.text_tools import unwrap
+
+
+def test_create__full_run_including_non_fast_mode_and_job_submission(
+    make_test_app,
+    dummy_context,
+    dummy_job_submission_data,
+    cli_runner,
+    mocker,
+):
+    job_submission_data = JobSubmissionResponse(**dummy_job_submission_data[0])
+    job_submission_name = job_submission_data.job_submission_name
+    job_submission_description = job_submission_data.job_submission_description
+    job_script_id = job_submission_data.id
+
+    mocked_render = mocker.patch("jobbergate_cli.subapps.job_submissions.app.render_single_result")
+    patched_create_job_submission = mocker.patch("jobbergate_cli.subapps.job_submissions.app.create_job_submission")
+    patched_create_job_submission.return_value = job_submission_data
+
+    test_app = make_test_app("create", create)
+    result = cli_runner.invoke(
+        test_app,
+        shlex.split(
+            unwrap(
+                f"""
+                create --name={job_submission_name}
+                       --description='{job_submission_description}'
+                       --job-script-id={job_script_id}
+                """
+            )
+        ),
+    )
+    assert result.exit_code == 0, f"create failed: {result.stdout}"
+
+    mocked_render.assert_called_once_with(
+        dummy_context,
+        job_submission_data,
+        title="Created Job Submission",
+        hidden_fields=HIDDEN_FIELDS,
+    )
+
+
+def test_list_all__makes_request_and_renders_results(
+    respx_mock,
+    make_test_app,
+    dummy_context,
+    dummy_job_submission_data,
+    dummy_domain,
+    cli_runner,
+    mocker,
+):
+    respx_mock.get(f"{dummy_domain}/job-submissions?all=false").mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=dict(
+                results=dummy_job_submission_data,
+                pagination=dict(
+                    total=3,
+                ),
+            ),
+        ),
+    )
+    test_app = make_test_app("list-all", list_all)
+    mocked_render = mocker.patch("jobbergate_cli.subapps.job_submissions.app.render_list_results")
+    result = cli_runner.invoke(test_app, ["list-all"])
+    assert result.exit_code == 0, f"list-all failed: {result.stdout}"
+    mocked_render.assert_called_once_with(
+        dummy_context,
+        ListResponseEnvelope(
+            results=dummy_job_submission_data,
+            pagination=Pagination(total=3, start=None, limit=None),
+        ),
+        title="Job Submission List",
+        style_mapper=style_mapper,
+        hidden_fields=HIDDEN_FIELDS,
+    )
+
+
+def test_get_one__success(
+    respx_mock,
+    make_test_app,
+    dummy_context,
+    dummy_job_submission_data,
+    dummy_domain,
+    cli_runner,
+    mocker,
+):
+    respx_mock.get(f"{dummy_domain}/job-submissions/1").mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=dummy_job_submission_data[0],
+        ),
+    )
+    test_app = make_test_app("get-one", get_one)
+    mocked_render = mocker.patch("jobbergate_cli.subapps.job_submissions.app.render_single_result")
+    result = cli_runner.invoke(test_app, shlex.split("get-one --id=1"))
+    assert result.exit_code == 0, f"get-one failed: {result.stdout}"
+    mocked_render.assert_called_once_with(
+        dummy_context,
+        JobSubmissionResponse(**dummy_job_submission_data[0]),
+        title="Job Submission",
+        hidden_fields=HIDDEN_FIELDS,
+    )
+
+
+def test_delete__makes_request_and_sends_terminal_message(
+    respx_mock,
+    make_test_app,
+    dummy_domain,
+    cli_runner,
+):
+    job_submission_id = 13
+
+    delete_route = respx_mock.delete(f"{dummy_domain}/job-submissions/{job_submission_id}").mock(
+        return_value=httpx.Response(httpx.codes.NO_CONTENT),
+    )
+    test_app = make_test_app("delete", delete)
+    result = cli_runner.invoke(test_app, shlex.split(f"delete --id={job_submission_id}"))
+    assert result.exit_code == 0, f"delete failed: {result.stdout}"
+    assert delete_route.called
+    assert "JOB SUBMISSION DELETE SUCCEEDED"
diff --git a/jobbergate-cli/tests/subapps/job_submissions/test_tools.py b/jobbergate-cli/tests/subapps/job_submissions/test_tools.py
new file mode 100644
index 00000000..78c66bf8
--- /dev/null
+++ b/jobbergate-cli/tests/subapps/job_submissions/test_tools.py
@@ -0,0 +1,236 @@
+import json
+
+import httpx
+import pytest
+
+from jobbergate_cli.exceptions import Abort
+from jobbergate_cli.schemas import JobScriptResponse, JobSubmissionResponse
+from jobbergate_cli.subapps.job_submissions.tools import (
+    create_job_submission,
+    fetch_job_submission_data,
+    run_job_script,
+)
+
+
+def test_run_job_script__success(tweak_settings, tmp_path, dummy_job_script_data, mocker):
+    sbatch_path = tmp_path / "dummy-sbatch"
+    sbatch_path.write_text("whatever")
+
+    build_path = tmp_path / "dummy-build"
+    build_path.mkdir()
+
+    job_script_data = JobScriptResponse(**dummy_job_script_data[0])
+    job_script_name = job_script_data.job_script_name
+
+    with tweak_settings(SBATCH_PATH=sbatch_path):
+        patched_run = mocker.patch("jobbergate_cli.subapps.job_submissions.tools.subprocess.run")
+        patched_run.return_value = mocker.MagicMock(
+            returncode=0,
+            stdout="only the last value matters and it is 13",
+        )
+        slurm_job_id = run_job_script(job_script_data, "dummy-application-name", build_path=build_path)
+
+        assert slurm_job_id == 13
+        patched_run.assert_called_once_with(
+            [sbatch_path, build_path / f"{job_script_name}.job", "dummy-application-name"],
+            capture_output=True,
+            text=True,
+            input="sbatch output",
+        )
+        built_script_path = build_path / f"{job_script_name}.job"
+        assert built_script_path.exists()
+        assert built_script_path.read_text() == json.loads(job_script_data.job_script_data_as_string)["application.sh"]
+
+
+def test_run_job_script__raises_Abort_if_SBATCH_PATH_does_not_exist(tweak_settings, tmp_path, dummy_job_script_data):
+    sbatch_path = tmp_path / "dummy-sbatch"
+
+    build_path = tmp_path / "dummy-build"
+    build_path.mkdir()
+
+    job_script_data = JobScriptResponse(**dummy_job_script_data[0])
+
+    with tweak_settings(SBATCH_PATH=sbatch_path):
+        with pytest.raises(Abort, match="sbatch executable was not found"):
+            run_job_script(job_script_data, "dummy-application-name", build_path=build_path)
+
+
+def test_run_job_script__raises_Abort_if_build_path_does_not_exist(tweak_settings, tmp_path, dummy_job_script_data):
+    sbatch_path = tmp_path / "dummy-sbatch"
+    sbatch_path.write_text("whatever")
+
+    build_path = tmp_path / "dummy-build"
+
+    job_script_data = JobScriptResponse(**dummy_job_script_data[0])
+
+    with tweak_settings(SBATCH_PATH=sbatch_path):
+        with pytest.raises(Abort, match="build directory does not exist"):
+            run_job_script(job_script_data, "dummy-application-name", build_path=build_path)
+
+
+def test_run_job_script__uses_temporary_directory_if_build_path_is_None(
+    tweak_settings, tmp_path, dummy_job_script_data, mocker
+):
+    sbatch_path = tmp_path / "dummy-sbatch"
+    sbatch_path.write_text("whatever")
+
+    build_path = tmp_path / "dummy-build"
+    build_path.mkdir()
+
+    job_script_data = JobScriptResponse(**dummy_job_script_data[0])
+    job_script_name = job_script_data.job_script_name
+
+    with tweak_settings(SBATCH_PATH=sbatch_path):
+        patched_temp_dir = mocker.patch("jobbergate_cli.subapps.job_submissions.tools.tempfile.TemporaryDirectory")
+        magic_dir = mocker.MagicMock()
+        magic_dir.configure_mock(name=str(build_path))
+        patched_temp_dir.return_value = magic_dir
+
+        patched_run = mocker.patch("jobbergate_cli.subapps.job_submissions.tools.subprocess.run")
+        patched_run.return_value = mocker.MagicMock(
+            returncode=0,
+            stdout="only the last value matters and it is 13",
+        )
+        slurm_job_id = run_job_script(job_script_data, "dummy-application-name")
+
+        assert slurm_job_id == 13
+        patched_run.assert_called_once_with(
+            [sbatch_path, build_path / f"{job_script_name}.job", "dummy-application-name"],
+            capture_output=True,
+            text=True,
+            input="sbatch output",
+        )
+        built_script_path = build_path / f"{job_script_name}.job"
+        assert built_script_path.exists()
+        assert built_script_path.read_text() == json.loads(job_script_data.job_script_data_as_string)["application.sh"]
+
+
+def test_run_job_script__raises_abort_if_no_executable_script_was_found(
+    tweak_settings, tmp_path, dummy_job_script_data
+):
+    sbatch_path = tmp_path / "dummy-sbatch"
+    sbatch_path.write_text("whatever")
+
+    build_path = tmp_path / "dummy-build"
+    build_path.mkdir()
+
+    job_script_data = JobScriptResponse(**dummy_job_script_data[0])
+    job_script_data.job_script_data_as_string = json.dumps(
+        {k: v for (k, v) in json.loads(job_script_data.job_script_data_as_string).items() if k != "application.sh"}
+    )
+
+    with tweak_settings(SBATCH_PATH=sbatch_path):
+        with pytest.raises(Abort, match="Could not find an executable"):
+            run_job_script(job_script_data, "dummy-application-name", build_path=build_path)
+
+
+def test_run_job_script__raises_abort_if_exit_code_from_sbatch_is_not_0(
+    tweak_settings, tmp_path, dummy_job_script_data, mocker
+):
+    sbatch_path = tmp_path / "dummy-sbatch"
+    sbatch_path.write_text("whatever")
+
+    build_path = tmp_path / "dummy-build"
+    build_path.mkdir()
+
+    job_script_data = JobScriptResponse(**dummy_job_script_data[0])
+
+    with tweak_settings(SBATCH_PATH=sbatch_path):
+        patched_run = mocker.patch("jobbergate_cli.subapps.job_submissions.tools.subprocess.run")
+        patched_run.return_value = mocker.MagicMock(
+            returncode=1,
+            stdout="won't matter because return code is not 0",
+            stderr="BOOM!",
+        )
+        with pytest.raises(Abort, match="job submission with error: BOOM!"):
+            run_job_script(job_script_data, "dummy-application-name", build_path=build_path)
+
+
+def test_create_job_submission__success(
+    tweak_settings,
+    tmp_path,
+    respx_mock,
+    dummy_application_data,
+    dummy_job_script_data,
+    dummy_job_submission_data,
+    dummy_domain,
+    dummy_context,
+    attach_persona,
+    mocker,
+):
+    application_data = dummy_application_data[0]
+    application_id = application_data["id"]
+
+    job_script_data = dummy_job_script_data[0]
+    job_script_id = job_script_data["id"]
+
+    job_submission_data = dummy_job_submission_data[0]
+    job_submission_name = job_submission_data["job_submission_name"]
+    job_submission_description = job_submission_data["job_submission_description"]
+
+    fetch_application_route = respx_mock.get(f"{dummy_domain}/applications/{application_id}")
+    fetch_application_route.mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=application_data,
+        ),
+    )
+
+    fetch_job_script_route = respx_mock.get(f"{dummy_domain}/job-scripts/{job_script_id}")
+    fetch_job_script_route.mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=job_script_data,
+        ),
+    )
+
+    create_job_submission_route = respx_mock.post(f"{dummy_domain}/job-submissions")
+    create_job_submission_route.mock(
+        return_value=httpx.Response(
+            httpx.codes.CREATED,
+            json=job_submission_data,
+        ),
+    )
+
+    sbatch_path = tmp_path / "dummy-sbatch"
+    sbatch_path.write_text("whatever")
+
+    build_path = tmp_path / "dummy-build"
+    build_path.mkdir()
+
+    attach_persona("dummy@dummy.com")
+
+    with tweak_settings(SBATCH_PATH=sbatch_path):
+        patched_run = mocker.patch("jobbergate_cli.subapps.job_submissions.tools.subprocess.run")
+        patched_run.return_value = mocker.MagicMock(
+            returncode=0,
+            stdout="only the last value matters and it is 21",
+        )
+        new_job_submission = create_job_submission(
+            dummy_context,
+            job_script_id,
+            job_submission_name,
+            job_submission_description,
+        )
+        assert new_job_submission == JobSubmissionResponse(**job_submission_data)
+
+
+def test_fetch_job_submission_data__success__using_id(
+    respx_mock,
+    dummy_context,
+    dummy_job_submission_data,
+    dummy_domain,
+):
+    job_submission_data = dummy_job_submission_data[0]
+    job_submission_id = job_submission_data["id"]
+    fetch_route = respx_mock.get(f"{dummy_domain}/job-submissions/{job_submission_id}")
+    fetch_route.mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=job_submission_data,
+        ),
+    )
+
+    result = fetch_job_submission_data(dummy_context, job_submission_id)
+    assert fetch_route.called
+    assert result == JobSubmissionResponse(**job_submission_data)
diff --git a/jobbergate-cli/tests/test_auth.py b/jobbergate-cli/tests/test_auth.py
new file mode 100644
index 00000000..5b627638
--- /dev/null
+++ b/jobbergate-cli/tests/test_auth.py
@@ -0,0 +1,519 @@
+from pathlib import Path
+
+import httpx
+import pendulum
+import plummet
+import pytest
+from jose import ExpiredSignatureError, jwt
+
+from jobbergate_cli.auth import (
+    TokenSet,
+    clear_token_cache,
+    fetch_auth_tokens,
+    init_persona,
+    load_tokens_from_cache,
+    refresh_access_token,
+    save_tokens_to_cache,
+    validate_token_and_extract_identity,
+)
+from jobbergate_cli.config import settings
+from jobbergate_cli.exceptions import Abort
+from jobbergate_cli.schemas import JobbergateContext
+from jobbergate_cli.time_loop import Tick
+
+
+LOGIN_DOMAIN = "https://dummy-auth.com"
+
+
+@pytest.fixture
+def dummy_context():
+    return JobbergateContext(
+        persona=None,
+        client=httpx.Client(base_url=LOGIN_DOMAIN, headers={"content-type": "application/x-www-form-urlencoded"}),
+    )
+
+
+@pytest.fixture
+def make_token():
+    """
+    Provide a fixture that returns a helper function for creating an access_token for testing.
+    """
+
+    def _helper(identity_data: dict = None, expires=plummet.AGGREGATE_TYPE) -> str:
+        """
+        Create an access_token with a given user email, org name, and expiration moment.
+        """
+        expires_moment: pendulum.DateTime = plummet.momentize(expires)
+
+        extra_claims = dict()
+        if identity_data is not None:
+            extra_claims[settings.IDENTITY_CLAIMS_KEY] = identity_data
+
+        return jwt.encode(
+            {
+                "exp": expires_moment.int_timestamp,
+                **extra_claims,
+            },
+            "fake-secret",
+            algorithm="HS256",
+        )
+
+    return _helper
+
+
+def test_validate_token_and_extract_identity__success(make_token):
+    """
+    Validate that the ``validate_token_and_extract_identity()`` function can successfully validate a good
+    access token and extract identity_data from it.
+    """
+    access_token = make_token(
+        identity_data=dict(
+            user_email="good@email.com",
+            org_name="good_org",
+        ),
+        expires="2022-02-16 22:30:00",
+    )
+    with plummet.frozen_time("2022-02-16 21:30:00"):
+        identity_data = validate_token_and_extract_identity(TokenSet(access_token=access_token))
+    assert identity_data.user_email == "good@email.com"
+    assert identity_data.org_name == "good_org"
+
+
+def test_validate_token_and_extract_identity__re_raises_ExpiredSignatureError(make_token):
+    """
+    Validate that the ``validate_token_and_extract_identity()`` function will catch and then re-raise a
+    ``ExpiredSignatureError`` thrown by the ``jwt.decode()`` function.
+    """
+    access_token = make_token(
+        identity_data=dict(
+            user_email="good@email.com",
+            org_name="good_org",
+        ),
+        expires="2022-02-16 20:30:00",
+    )
+    with plummet.frozen_time("2022-02-16 21:30:00"):
+        with pytest.raises(ExpiredSignatureError):
+            validate_token_and_extract_identity(TokenSet(access_token=access_token))
+
+
+def test_validate_token_and_extract_identity__raises_abort_on_unknown_error(mocker):
+    """
+    Validate that the ``validate_token_and_extract_identity()`` function will raise an ``Abort`` when the
+    ``jwt.decode()`` function raises an exception type besides ``ExpiredSignatureError``.
+    """
+    test_token_set = TokenSet(access_token="BOGUS-TOKEN")
+    mocker.patch("jose.jwt.decode", side_effect=Exception("BOOM!"))
+    with pytest.raises(Abort, match="There was an unknown error while validating"):
+        validate_token_and_extract_identity(test_token_set)
+
+
+def test_validate_token_and_extract_identity__raises_abort_if_token_has_no_identity_data(make_token):
+    """
+    Validate that the ``validate_token_and_extract_identity()`` function will raise an Abort if the
+    access_token doesn't carry identity_data in it.
+    """
+    access_token = make_token(expires="2022-02-16 22:30:00")
+    with plummet.frozen_time("2022-02-16 21:30:00"):
+        with pytest.raises(Abort, match="No identity data found"):
+            validate_token_and_extract_identity(TokenSet(access_token=access_token))
+
+
+def test_validate_token_and_extract_identity__raises_abort_if_identity_data_is_malformed(make_token):
+    """
+    Validate that the ``validate_token_and_extract_identity()`` function will raise an Abort if the
+    access token's identity data does not match the ``IdentityData`` class spec.
+    """
+    access_token = make_token(
+        expires="2022-02-16 22:30:00",
+        identity_data=dict(
+            bad="data",
+        ),
+    )
+    with plummet.frozen_time("2022-02-16 21:30:00"):
+        with pytest.raises(Abort, match="malformed or incomplete"):
+            validate_token_and_extract_identity(TokenSet(access_token=access_token))
+
+
+def test_load_tokens_from_cache__success(make_token, tmp_path, mocker):
+    """
+    Validate that the ``load_tokens_from_cache()`` function can successfully load tokens from the token
+    cache on disk.
+    """
+    access_token = make_token(
+        identity_data=dict(
+            user_email="good@email.com",
+            org_name="good_org",
+        ),
+        expires="2022-02-16 22:30:00",
+    )
+    access_token_path = tmp_path / "access.jwt"
+    access_token_path.write_text(access_token)
+    refresh_token = "dummy-refresh-token"
+    refresh_token_path = tmp_path / "refresh.jwt"
+    refresh_token_path.write_text(refresh_token)
+
+    mocker.patch.object(settings, "JOBBERGATE_API_ACCESS_TOKEN_PATH", new=access_token_path)
+    mocker.patch.object(settings, "JOBBERGATE_API_REFRESH_TOKEN_PATH", new=refresh_token_path)
+    token_set = load_tokens_from_cache()
+
+    assert token_set.access_token == access_token
+    assert token_set.refresh_token == refresh_token
+
+
+def test_load_tokens_from_cache__raises_abort_if_access_token_path_does_not_exist(mocker):
+    """
+    Validate taht the ``load_tokens_from_cache()`` function raises an Abort if the token does not exist
+    at the location specified by ``settings.JOBBERGATE_API_ACCESS_TOKEN_PATH``.
+    """
+    mocker.patch.object(settings, "JOBBERGATE_API_ACCESS_TOKEN_PATH", new=Path("/some/fake/path"))
+    with pytest.raises(Abort, match="login with your auth token first"):
+        load_tokens_from_cache()
+
+
+def test_load_tokens_from_cache__omits_refresh_token_if_it_is_not_found(make_token, tmp_path, mocker):
+    """
+    Validate that the ``load_tokens_from_cache()`` function can successfully create a token set without the
+    refresh token if the location specified by ``settings.JOBBERGATE_API_REFRESH_TOKEN_PATH`` does not exist.
+    """
+    access_token = make_token(
+        identity_data=dict(
+            user_email="good@email.com",
+            org_name="good_org",
+        ),
+        expires="2022-02-16 22:30:00",
+    )
+    access_token_path = tmp_path / "access.jwt"
+    access_token_path.write_text(access_token)
+
+    mocker.patch.object(settings, "JOBBERGATE_API_ACCESS_TOKEN_PATH", new=access_token_path)
+    mocker.patch.object(settings, "JOBBERGATE_API_REFRESH_TOKEN_PATH", new=Path("/some/fake/path"))
+    token_set = load_tokens_from_cache()
+
+    assert token_set.access_token == access_token
+    assert token_set.refresh_token is None
+
+
+def test_save_tokens_to_cache__success(make_token, tmp_path, mocker):
+    """
+    Validate that the ``save_tokens_to_cache()`` function will write a access and refresh token from a
+    ``TokenSet`` instance to the locations described by ``JOBBERGATE_API_ACCESS_TOKEN_PATH`` and
+    ``JOBBERGATE_API_REFRESH_TOKEN_PATH``.
+    """
+    access_token = make_token(
+        identity_data=dict(
+            user_email="good@email.com",
+            org_name="good_org",
+        ),
+        expires="2022-02-16 22:30:00",
+    )
+    access_token_path = tmp_path / "access.jwt"
+    refresh_token = "dummy-refresh-token"
+    refresh_token_path = tmp_path / "refresh.jwt"
+    token_set = TokenSet(
+        access_token=access_token,
+        refresh_token=refresh_token,
+    )
+
+    mocker.patch.object(settings, "JOBBERGATE_API_ACCESS_TOKEN_PATH", new=access_token_path)
+    mocker.patch.object(settings, "JOBBERGATE_API_REFRESH_TOKEN_PATH", new=refresh_token_path)
+    save_tokens_to_cache(token_set)
+
+    assert access_token_path.exists()
+    assert access_token_path.read_text() == access_token
+
+    assert refresh_token_path.exists()
+    assert refresh_token_path.read_text() == refresh_token
+
+
+def test_save_tokens_to_cache__only_saves_access_token_if_refresh_token_is_not_defined(make_token, tmp_path, mocker):
+    """
+    Validate that the ``save_tokens_to_cache()`` function will only write an access token to the cache if the
+    ``TokenSet`` instance does not carry a refresh token.
+    """
+    access_token = make_token(
+        identity_data=dict(
+            user_email="good@email.com",
+            org_name="good_org",
+        ),
+        expires="2022-02-16 22:30:00",
+    )
+    access_token_path = tmp_path / "access.jwt"
+    refresh_token_path = tmp_path / "refresh.jwt"
+    token_set = TokenSet(
+        access_token=access_token,
+    )
+
+    mocker.patch.object(settings, "JOBBERGATE_API_ACCESS_TOKEN_PATH", new=access_token_path)
+    mocker.patch.object(settings, "JOBBERGATE_API_REFRESH_TOKEN_PATH", new=refresh_token_path)
+    save_tokens_to_cache(token_set)
+
+    assert access_token_path.exists()
+    assert access_token_path.read_text() == access_token
+
+    assert not refresh_token_path.exists()
+
+
+def test_clear_token_cache__success(make_token, tmp_path, mocker):
+    """
+    Validate that the ``clear_token_cache()`` function removes the access token and refresh token from the
+    cache.
+    """
+    access_token = make_token(
+        identity_data=dict(
+            user_email="good@email.com",
+            org_name="good_org",
+        ),
+        expires="2022-02-16 22:30:00",
+    )
+    access_token_path = tmp_path / "access.jwt"
+    access_token_path.write_text(access_token)
+    refresh_token = "dummy-refresh-token"
+    refresh_token_path = tmp_path / "refresh.jwt"
+    refresh_token_path.write_text(refresh_token)
+
+    assert access_token_path.exists()
+    assert refresh_token_path.exists()
+
+    mocker.patch.object(settings, "JOBBERGATE_API_ACCESS_TOKEN_PATH", new=access_token_path)
+    mocker.patch.object(settings, "JOBBERGATE_API_REFRESH_TOKEN_PATH", new=refresh_token_path)
+    clear_token_cache()
+
+    assert not access_token_path.exists()
+
+
+def test_clear_token_cache__does_not_fail_if_no_tokens_are_in_cache(tmp_path, mocker):
+    """
+    Validate that the ``clear_token_cache()`` function does not fail if there are no tokens in the cache.
+    """
+    access_token_path = tmp_path / "access.jwt"
+    refresh_token_path = tmp_path / "refresh.jwt"
+
+    assert not access_token_path.exists()
+    assert not refresh_token_path.exists()
+
+    mocker.patch.object(settings, "JOBBERGATE_API_ACCESS_TOKEN_PATH", new=access_token_path)
+    mocker.patch.object(settings, "JOBBERGATE_API_REFRESH_TOKEN_PATH", new=refresh_token_path)
+    clear_token_cache()
+
+
+def test_init_persona__success(make_token, tmp_path, dummy_context, mocker):
+    """
+    Validate that the ``init_persona()`` function will load tokens from the cache, validate them,
+    extract identity data, and return a new ``Persona`` instance with the tokens and identity data contained
+    within it.
+    """
+    access_token = make_token(
+        identity_data=dict(
+            user_email="good@email.com",
+            org_name="good_org",
+        ),
+        expires="2022-02-16 22:30:00",
+    )
+    access_token_path = tmp_path / "access.jwt"
+    access_token_path.write_text(access_token)
+    refresh_token = "dummy-refresh-token"
+    refresh_token_path = tmp_path / "refresh.jwt"
+    refresh_token_path.write_text(refresh_token)
+
+    mocker.patch.object(settings, "JOBBERGATE_API_ACCESS_TOKEN_PATH", new=access_token_path)
+    mocker.patch.object(settings, "JOBBERGATE_API_REFRESH_TOKEN_PATH", new=refresh_token_path)
+    with plummet.frozen_time("2022-02-16 21:30:00"):
+        persona = init_persona(dummy_context)
+
+    assert persona.token_set.access_token == access_token
+    assert persona.token_set.refresh_token == refresh_token
+    assert persona.identity_data.user_email == "good@email.com"
+    assert persona.identity_data.org_name == "good_org"
+
+
+def test_init_persona__uses_passed_token_set(make_token, tmp_path, dummy_context, mocker):
+    """
+    Validate that the ``init_persona()`` function will used the passed ``TokenSet`` instance instead of
+    loading it from the cache and will write the tokens to the cache after validating them.
+    """
+    access_token = make_token(
+        identity_data=dict(
+            user_email="good@email.com",
+            org_name="good_org",
+        ),
+        expires="2022-02-16 22:30:00",
+    )
+    access_token_path = tmp_path / "access.jwt"
+    refresh_token = "dummy-refresh-token"
+    refresh_token_path = tmp_path / "refresh.jwt"
+
+    token_set = TokenSet(
+        access_token=access_token,
+        refresh_token=refresh_token,
+    )
+
+    assert not access_token_path.exists()
+    assert not refresh_token_path.exists()
+
+    mocker.patch.object(settings, "JOBBERGATE_API_ACCESS_TOKEN_PATH", new=access_token_path)
+    mocker.patch.object(settings, "JOBBERGATE_API_REFRESH_TOKEN_PATH", new=refresh_token_path)
+    with plummet.frozen_time("2022-02-16 21:30:00"):
+        persona = init_persona(dummy_context, token_set)
+
+    assert persona.token_set.access_token == access_token
+    assert persona.token_set.refresh_token == refresh_token
+    assert persona.identity_data.user_email == "good@email.com"
+    assert persona.identity_data.org_name == "good_org"
+
+    assert access_token_path.exists()
+    assert access_token_path.read_text() == access_token
+    assert refresh_token_path.exists()
+
+
+def test_init_persona__refreshes_access_token_if_it_is_expired(make_token, tmp_path, respx_mock, dummy_context, mocker):
+    """
+    Validate that the ``init_persona()`` function will refresh the access token if it is expired and, after
+    validating it, save it to the cache.
+    """
+    access_token = make_token(
+        identity_data=dict(
+            user_email="good@email.com",
+            org_name="good_org",
+        ),
+        expires="2022-02-16 22:30:00",
+    )
+    access_token_path = tmp_path / "access.jwt"
+    access_token_path.write_text(access_token)
+    refresh_token = "dummy-refresh-token"
+    refresh_token_path = tmp_path / "refresh.jwt"
+    refresh_token_path.write_text(refresh_token)
+
+    refreshed_access_token = make_token(
+        identity_data=dict(
+            user_email="good@email.com",
+            org_name="good_org",
+        ),
+        expires="2022-02-17 22:30:00",
+    )
+
+    respx_mock.post(f"{LOGIN_DOMAIN}/oauth/token").mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=dict(access_token=refreshed_access_token),
+        ),
+    )
+
+    mocker.patch.object(settings, "JOBBERGATE_API_ACCESS_TOKEN_PATH", new=access_token_path)
+    mocker.patch.object(settings, "JOBBERGATE_API_REFRESH_TOKEN_PATH", new=refresh_token_path)
+    with plummet.frozen_time("2022-02-16 23:30:00"):
+        persona = init_persona(dummy_context)
+
+    assert persona.token_set.access_token == refreshed_access_token
+    assert persona.token_set.refresh_token == refresh_token
+    assert persona.identity_data.user_email == "good@email.com"
+    assert persona.identity_data.org_name == "good_org"
+
+    assert access_token_path.exists()
+    assert access_token_path.read_text() == refreshed_access_token
+    assert refresh_token_path.exists()
+    assert refresh_token_path.read_text() == refresh_token
+
+
+def test_refresh_access_token__success(make_token, respx_mock, dummy_context):
+    """
+    Validate that the ``refreshed_access_token()`` function uses a refresh token to retrieve a new access
+    token from the ``oauth/token`` endpoint of the ``settings.AUTH0_DOMAIN``.
+    """
+    access_token = "expired-access-token"
+    refresh_token = "dummy-refresh-token"
+    token_set = TokenSet(access_token=access_token, refresh_token=refresh_token)
+
+    refreshed_access_token = make_token(
+        identity_data=dict(
+            user_email="good@email.com",
+            org_name="good_org",
+        ),
+        expires="2022-02-17 22:30:00",
+    )
+
+    respx_mock.post(f"{LOGIN_DOMAIN}/oauth/token").mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=dict(access_token=refreshed_access_token),
+        ),
+    )
+
+    refresh_access_token(dummy_context, token_set)
+    assert token_set.access_token == refreshed_access_token
+
+
+def test_refresh_access_token__raises_abort_on_non_200_response(respx_mock, dummy_context):
+    """
+    Validate that the ``refreshed_access_token()`` function raises an abort if the response from the
+    ``oauth/token`` endpoint of the ``settings.AUTH0_DOMAIN`` is not a 200.
+    """
+    access_token = "expired-access-token"
+    refresh_token = "dummy-refresh-token"
+    token_set = TokenSet(access_token=access_token, refresh_token=refresh_token)
+
+    respx_mock.post(f"{LOGIN_DOMAIN}/oauth/token").mock(
+        return_value=httpx.Response(
+            httpx.codes.BAD_REQUEST,
+            json=dict(error_description="BOOM!"),
+        ),
+    )
+
+    with pytest.raises(Abort, match="auth token could not be refreshed"):
+        refresh_access_token(dummy_context, token_set)
+
+
+def test_fetch_auth_tokens__success(respx_mock, dummy_context):
+    """
+    Validate that the ``fetch_auth_tokens()`` function can successfully retrieve auth tokens from the
+    OIDC provider.
+    """
+
+    access_token = "dummy-access-token"
+    refresh_token = "dummy-refresh-token"
+
+    respx_mock.post(f"{LOGIN_DOMAIN}/oauth/device/code").mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=dict(
+                device_code="dummy-code",
+                verification_uri_complete="https://dummy-uri.com",
+                interval=1,
+            ),
+        ),
+    )
+    respx_mock.post(f"{LOGIN_DOMAIN}/oauth/token").mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=dict(
+                access_token=access_token,
+                refresh_token=refresh_token,
+            ),
+        ),
+    )
+    token_set = fetch_auth_tokens(dummy_context)
+    assert token_set.access_token == access_token
+    assert token_set.refresh_token == refresh_token
+
+
+def test_fetch_auth_tokens__raises_Abort_when_it_times_out_waiting_for_the_user(respx_mock, dummy_context, mocker):
+    """
+    Validate that the ``fetch_auth_tokens()`` function will raise an Abort if the time runs out before a user
+    completes the login process.
+    """
+    respx_mock.post(f"{LOGIN_DOMAIN}/oauth/device/code").mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=dict(
+                device_code="dummy-code",
+                verification_uri_complete="https://dummy-uri.com",
+                interval=0,
+            ),
+        ),
+    )
+    respx_mock.post(f"{LOGIN_DOMAIN}/oauth/token").mock(
+        return_value=httpx.Response(httpx.codes.BAD_REQUEST, json=dict(error="authorization_pending")),
+    )
+    one_tick = Tick(counter=1, elapsed=pendulum.Duration(seconds=1), total_elapsed=pendulum.Duration(seconds=1))
+    mocker.patch("jobbergate_cli.auth.TimeLoop", return_value=[one_tick])
+    with pytest.raises(Abort, match="not completed in time"):
+        fetch_auth_tokens(dummy_context)
diff --git a/jobbergate-cli/tests/test_compat.py b/jobbergate-cli/tests/test_compat.py
new file mode 100644
index 00000000..37414c8e
--- /dev/null
+++ b/jobbergate-cli/tests/test_compat.py
@@ -0,0 +1,77 @@
+import sys
+
+import pytest
+import typer
+
+from jobbergate_cli.compat import add_legacy_compatible_commands
+
+
+@pytest.fixture
+def cleanup_deprecated():
+    yield
+    for module in list(sys.modules.keys()):
+        if "appform" in module:
+            sys.modules.pop(module)
+        if "jobbergate_cli.application_base" in module:
+            sys.modules.pop(module)
+
+
+def test_list_all__makes_request_and_renders_results():
+    test_app = typer.Typer()
+    add_legacy_compatible_commands(test_app)
+
+    registered_command_names = [c.name for c in test_app.registered_commands]
+    assert sorted(registered_command_names) == sorted(
+        [
+            "list-applications",
+            "get-application",
+            "create-application",
+            "delete-application",
+            "update-application",
+            "list-job-scripts",
+            "get-job-script",
+            "create-job-script",
+            "update-job-script",
+            "delete-job-script",
+            "list-job-submissions",
+            "get-job-submission",
+            "create-job-submission",
+            "delete-job-submission",
+        ]
+    )
+
+
+def test_import_appform_from_jobbergate_cli_warns_and_gives_you_questions_instead_when_in_compatibility_mode(
+    tweak_settings, cleanup_deprecated
+):
+    with tweak_settings(JOBBERGATE_COMPATIBILITY_MODE=True):
+        with pytest.warns(DeprecationWarning, match="appform is deprecated"):
+            from jobbergate_cli import appform
+            from jobbergate_cli.subapps.applications import questions
+
+            assert appform is questions
+
+
+def test_import_appform_from_jobbergate_cli_raises_an_ImportError_when_not_in_compatibility_mode(cleanup_deprecated):
+    with pytest.raises(ImportError):
+        from jobbergate_cli import appform  # noqa
+
+
+def test_import_application_base_from_jobbergate_cli_warns_and_imports_from_subapp_when_in_compatibility_mode(
+    tweak_settings, cleanup_deprecated
+):
+    with tweak_settings(JOBBERGATE_COMPATIBILITY_MODE=True):
+        with pytest.warns(DeprecationWarning, match="application_base from jobbergate_cli is deprecated"):
+            from jobbergate_cli.application_base import JobbergateApplicationBase as module_from_base
+            from jobbergate_cli.subapps.applications.application_base import (
+                JobbergateApplicationBase as module_from_subapp,
+            )
+
+            assert module_from_base is module_from_subapp
+
+
+def test_import_application_base_from_jobbergate_cli_raises_import_error_when_not_in_compatibility_mode(
+    cleanup_deprecated,
+):
+    with pytest.raises(ImportError, match="ApplicationBase has been moved"):
+        from jobbergate_cli.application_base import JobbergateApplicationBase  # noqa
diff --git a/jobbergate-cli/tests/test_config.py b/jobbergate-cli/tests/test_config.py
new file mode 100644
index 00000000..c795b414
--- /dev/null
+++ b/jobbergate-cli/tests/test_config.py
@@ -0,0 +1,14 @@
+import pydantic
+import pytest
+
+from jobbergate_cli.config import Settings
+
+
+def test_SENTRY_TRACE_RATE__requires_float_in_valid_range():
+    assert Settings(SENTRY_TRACE_RATE=0.5)
+
+    with pytest.raises(pydantic.ValidationError):
+        Settings(SENTRY_TRACE_RATE=0.0)
+
+    with pytest.raises(pydantic.ValidationError):
+        Settings(SENTRY_TRACE_RATE=1.1)
diff --git a/jobbergate-cli/tests/test_exceptions.py b/jobbergate-cli/tests/test_exceptions.py
new file mode 100644
index 00000000..5262fc34
--- /dev/null
+++ b/jobbergate-cli/tests/test_exceptions.py
@@ -0,0 +1,83 @@
+import pytest
+import typer
+
+from jobbergate_cli.exceptions import Abort, handle_abort
+
+
+@pytest.fixture
+def dummy_exception():
+    return RuntimeError("Detonate!")
+
+
+@pytest.fixture
+def dummy_handled_function(dummy_exception):
+    @handle_abort
+    def _wrapped(
+        message: str = "Explosive Message",
+        subject: str = "BOOM!",
+        support: bool = True,
+        log_message: str = "volatile log message",
+        original_error: Exception = dummy_exception,
+        add_sentry_context: bool = True,
+    ):
+        sentry_kwarg = dict(sentry_context=dict(blast=True)) if add_sentry_context else dict()
+        raise Abort(
+            message,
+            subject=subject,
+            support=support,
+            log_message=log_message,
+            original_error=original_error,
+            **sentry_kwarg,
+        )
+
+    return _wrapped
+
+
+def test_handle_abort__with_all_options(capsys, caplog, mocker, dummy_handled_function, dummy_exception):
+    mocked_sentry_capture = mocker.patch("jobbergate_cli.exceptions.sentry_sdk.capture_exception")
+    with pytest.raises(typer.Exit):
+        dummy_handled_function()
+
+    captured = capsys.readouterr()
+    assert "Explosive Message" in captured.out
+    assert "BOOM!" in captured.out
+    assert "If the problem persists" in captured.out
+
+    assert "volatile log message" in caplog.text
+    assert f"Original exception: {dummy_exception}" in caplog.text
+    assert mocked_sentry_capture.called_once_with(dummy_exception)
+
+
+def test_handle_abort__without_SENTRY_DNS_does_not_push_to_sentry(mocker, tweak_settings, dummy_handled_function):
+    mocked_sentry_capture = mocker.patch("jobbergate_cli.exceptions.sentry_sdk.capture_exception")
+    with tweak_settings(SENTRY_DSN=None):
+        with pytest.raises(typer.Exit):
+            dummy_handled_function()
+
+    assert not mocked_sentry_capture.called
+
+
+def test_handle_abort__does_not_log_if_log_message_and_original_error_are_None(
+    caplog, tweak_settings, dummy_handled_function
+):
+    with tweak_settings(SENTRY_DSN=None):
+        with pytest.raises(typer.Exit):
+            dummy_handled_function(log_message=None, original_error=None)
+
+    assert caplog.text == ""
+
+
+def test_handle_abort__does_not_include_support_message_if_support_is_False(capsys, dummy_handled_function):
+    with pytest.raises(typer.Exit):
+        dummy_handled_function(support=False)
+
+    captured = capsys.readouterr()
+    assert "If the problem persists" not in captured.out
+
+
+def test_handle_abort__does_not_include_subject_message_if_subject_is_None(mocker, dummy_handled_function):
+    mocked_panel = mocker.patch("jobbergate_cli.exceptions.Panel")
+    with pytest.raises(typer.Exit):
+        dummy_handled_function(message="Bang!", subject=None)
+
+    assert mocked_panel.called_once_with("Bang!")
diff --git a/jobbergate-cli/tests/test_requests.py b/jobbergate-cli/tests/test_requests.py
new file mode 100644
index 00000000..678cf179
--- /dev/null
+++ b/jobbergate-cli/tests/test_requests.py
@@ -0,0 +1,266 @@
+import json
+from typing import Dict, Optional
+
+import httpx
+import pydantic
+import pytest
+
+from jobbergate_cli.exceptions import Abort
+from jobbergate_cli.requests import make_request
+
+
+DEFAULT_DOMAIN = "https://dummy-domain.com"
+
+
+@pytest.fixture
+def dummy_client():
+    """
+    Provide factory for a test client. Can supply custom base_url and headers.
+    """
+
+    def _helper(base_url: str = DEFAULT_DOMAIN, headers: Optional[Dict] = None) -> httpx.Client:
+        """
+        Create the dummy httpx client.
+        """
+        if headers is None:
+            headers = dict()
+
+        return httpx.Client(base_url=base_url, headers=headers)
+
+    return _helper
+
+
+class DummyResponseModel(pydantic.BaseModel):
+    """
+    Provide a dummy pydantic model for testing standard responses.
+    """
+
+    foo: int
+    bar: str
+
+
+class ErrorResponseModel(pydantic.BaseModel):
+    """
+    Provide a dummy pydantic model for testing error responses.
+    """
+
+    error: str
+
+
+def test_make_request__success(respx_mock, dummy_client):
+    """
+    Validate that the ``make_request()`` function will make a request to the supplied path for the
+    client domain, check the status code, extract the data, validate it, and return an instance of the
+    supplied response model.
+    """
+
+    client = dummy_client(headers={"content-type": "garbage"})
+    req_path = "/fake-path"
+
+    respx_mock.get(f"{DEFAULT_DOMAIN}{req_path}").mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=dict(
+                foo=1,
+                bar="one",
+            ),
+        ),
+    )
+    dummy_response_instance = make_request(
+        client,
+        req_path,
+        "GET",
+        expected_status=200,
+        response_model=DummyResponseModel,
+    )
+    assert isinstance(dummy_response_instance, DummyResponseModel)
+    assert dummy_response_instance.foo == 1
+    assert dummy_response_instance.bar == "one"
+
+
+def test_make_request__raises_Abort_if_client_request_raises_exception(respx_mock, dummy_client):
+    """
+    Validate that the ``make_request()`` function will raise an Abort if the call to ``client.send`` raises an
+    exception.
+    """
+    client = dummy_client(headers={"content-type": "garbage"})
+    req_path = "/fake-path"
+    original_error = httpx.RequestError("BOOM!")
+
+    respx_mock.get(f"{DEFAULT_DOMAIN}{req_path}").mock(side_effect=original_error)
+
+    with pytest.raises(Abort, match="There was a big problem: Communication with the API failed") as err_info:
+        make_request(
+            client, req_path, "GET", abort_message="There was a big problem", abort_subject="BIG PROBLEM", support=True
+        )
+    assert err_info.value.subject == "BIG PROBLEM"
+    assert err_info.value.support is True
+    assert err_info.value.log_message == "There was an error making the request to the API"
+    assert err_info.value.original_error == original_error
+
+
+def test_make_request__raises_Abort_when_expected_status_is_not_None_and_response_status_does_not_match_it(
+    respx_mock, dummy_client
+):
+    """
+    Validate that the ``make_request()`` function will raise an Abort if the ``expected_status`` arg is set and it
+    does not match the status code of the response.
+    """
+    client = dummy_client(headers={"content-type": "garbage"})
+    req_path = "/fake-path"
+
+    respx_mock.get(f"{DEFAULT_DOMAIN}{req_path}").mock(
+        return_value=httpx.Response(
+            httpx.codes.BAD_REQUEST,
+            text="It blowed up",
+        ),
+    )
+
+    with pytest.raises(Abort, match="There was a big problem: Received an error response") as err_info:
+        make_request(
+            client,
+            req_path,
+            "GET",
+            expected_status=200,
+            abort_message="There was a big problem",
+            abort_subject="BIG PROBLEM",
+            support=True,
+        )
+    assert err_info.value.subject == "BIG PROBLEM"
+    assert err_info.value.support is True
+    assert err_info.value.log_message == "Got an error code for request: 400: It blowed up"
+    assert err_info.value.original_error is None
+
+
+def test_make_request__does_not_raise_Abort_when_expected_status_is_None_and_response_status_is_a_fail_code(
+    respx_mock, dummy_client
+):
+    """
+    Validate that the ``make_request()`` function will not raise an Abort if the ``expected_status`` arg is not set
+    and the response is an error status code.
+    """
+    client = dummy_client(headers={"content-type": "garbage"})
+    req_path = "/fake-path"
+
+    respx_mock.get(f"{DEFAULT_DOMAIN}{req_path}").mock(
+        return_value=httpx.Response(
+            httpx.codes.BAD_REQUEST,
+            json=dict(error="It blowed up"),
+        ),
+    )
+
+    err = make_request(
+        client,
+        req_path,
+        "GET",
+        response_model=ErrorResponseModel,
+    )
+    assert err.error == "It blowed up"
+
+
+def test_make_request__returns_the_response_status_code_if_the_method_is_DELETE(respx_mock, dummy_client):
+    """
+    Validate that the ``make_request()`` function will return None if the ``method`` arg is DELETE and the request
+    was successfull.
+    """
+    client = dummy_client(headers={"content-type": "garbage"})
+    req_path = "/fake-path"
+
+    respx_mock.delete(f"{DEFAULT_DOMAIN}{req_path}").mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=dict(error="It blowed up"),
+        ),
+    )
+
+    assert make_request(client, req_path, "DELETE") == httpx.codes.OK
+
+
+def test_make_request__returns_the_response_status_code_if_expect_response_is_False(respx_mock, dummy_client):
+    """
+    Validate that the ``make_request()`` function will return None if the ``expect_response`` arg is False and the
+    request was successfull.
+    """
+    client = dummy_client(headers={"content-type": "garbage"})
+    req_path = "/fake-path"
+
+    respx_mock.post(f"{DEFAULT_DOMAIN}{req_path}").mock(
+        return_value=httpx.Response(httpx.codes.BAD_REQUEST),
+    )
+
+    assert make_request(client, req_path, "POST", expect_response=False) == httpx.codes.BAD_REQUEST
+
+
+def test_make_request__raises_an_Abort_if_the_response_cannot_be_deserialized_with_JSON(respx_mock, dummy_client):
+    """
+    Validate that the ``make_request()`` function will raise an Abort if the response is not JSON de-serializable.
+    """
+    client = dummy_client(headers={"content-type": "garbage"})
+    req_path = "/fake-path"
+
+    respx_mock.get(f"{DEFAULT_DOMAIN}{req_path}").mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            text="Not JSON, my dude",
+        ),
+    )
+
+    with pytest.raises(Abort, match="There was a big problem: Response carried no data") as err_info:
+        make_request(
+            client, req_path, "GET", abort_message="There was a big problem", abort_subject="BIG PROBLEM", support=True
+        )
+    assert err_info.value.subject == "BIG PROBLEM"
+    assert err_info.value.support is True
+    assert err_info.value.log_message == "Failed unpacking json: Not JSON, my dude"
+    assert isinstance(err_info.value.original_error, json.decoder.JSONDecodeError)
+
+
+def test_make_request__returns_a_plain_dict_if_response_model_is_None(respx_mock, dummy_client):
+    """
+    Validate that the ``make_request()`` function will return a plain dictionary containing the response data if the
+    ``response_model`` argument is not supplied.
+    """
+    client = dummy_client(headers={"content-type": "garbage"})
+    req_path = "/fake-path"
+
+    respx_mock.get(f"{DEFAULT_DOMAIN}{req_path}").mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=dict(a=1, b=2, c=3),
+        ),
+    )
+
+    assert make_request(client, req_path, "GET") == dict(a=1, b=2, c=3)
+
+
+def test_make_request__raises_an_Abort_if_the_response_data_cannot_be_serialized_into_the_response_model(
+    respx_mock, dummy_client
+):
+    """
+    Validate that the ``make_request()`` function will raise an Abort if the response data cannot be serialized as and
+    validated with the ``response_model``.
+    """
+    client = dummy_client(headers={"content-type": "garbage"})
+    req_path = "/fake-path"
+
+    respx_mock.get(f"{DEFAULT_DOMAIN}{req_path}").mock(
+        return_value=httpx.Response(
+            httpx.codes.OK,
+            json=dict(a=1, b=2, c=3),
+        ),
+    )
+
+    with pytest.raises(Abort, match="There was a big problem: Unexpected data in response") as err_info:
+        make_request(
+            client,
+            req_path,
+            "GET",
+            abort_message="There was a big problem",
+            abort_subject="BIG PROBLEM",
+            support=True,
+            response_model=DummyResponseModel,
+        )
+    assert err_info.value.subject == "BIG PROBLEM"
+    assert err_info.value.support is True
+    assert err_info.value.log_message == f"Unexpected format in response data: {dict(a=1, b=2, c=3)}"
+    assert isinstance(err_info.value.original_error, pydantic.ValidationError)