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

Issue:
Release 3.2.4-alpha.1
Automated changes by [prepare_release](https://github.com/omnivector-solutions/jobbergate/blob/main/.github/workflows/prepare_release.yaml) GitHub action.


Discussion / hints:

Fix patch:
diff --git a/jobbergate-api/CHANGELOG.rst b/jobbergate-api/CHANGELOG.rst
index e0ed1c31..3f7d24cb 100644
--- a/jobbergate-api/CHANGELOG.rst
+++ b/jobbergate-api/CHANGELOG.rst
@@ -6,6 +6,12 @@ This file keeps track of all notable changes to jobbergate-api
 
 Unreleased
 ----------
+- Add support for supporting files on job-scripts.
+
+3.2.4-alpha.1 -- 2022-08-25
+---------------------------
+- Refactor application file management in a class, making it modular and reusable.
+- Fix a bug when writing application files to S3 and add tests to cover the issue.
 
 3.2.4-alpha.0 -- 2022-08-12
 ---------------------------
diff --git a/jobbergate-api/alembic/versions/20220803_160219--d22da0741b7f_refactor_application_file_management.py b/jobbergate-api/alembic/versions/20220803_160219--d22da0741b7f_refactor_application_file_management.py
index eed99646..ff8e41f3 100644
--- a/jobbergate-api/alembic/versions/20220803_160219--d22da0741b7f_refactor_application_file_management.py
+++ b/jobbergate-api/alembic/versions/20220803_160219--d22da0741b7f_refactor_application_file_management.py
@@ -19,7 +19,7 @@
 def upgrade():
     # ### commands auto generated by Alembic - please adjust! ###
     op.drop_column("applications", "application_file")
-    op.alter_column("applications", "application_config", existing_nullable=False, nullable=True)
+    op.drop_column("applications", "application_config")
     # Set updated status to false everywhere, because application_file was dropped
     op.execute("UPDATE applications SET application_uploaded=false")
     # ### end Alembic commands ###
@@ -27,16 +27,8 @@ def upgrade():
 
 def downgrade():
     # ### commands auto generated by Alembic - please adjust! ###
-    op.add_column(
-        "applications",
-        sa.Column(
-            "application_file",
-            sa.VARCHAR(),
-            autoincrement=False,
-            nullable=False,
-        ),
-    )
-    op.alter_column("applications", "application_config", existing_nullable=True, nullable=False)
+    op.add_column("applications", sa.Column("application_file", sa.String(), nullable=False))
+    op.add_column("applications", sa.Column("application_config", sa.String(), nullable=False))
     # Set updated status to false everywhere
     op.execute("UPDATE applications SET application_uploaded=false")
     # ### end Alembic commands ###
diff --git a/jobbergate-api/jobbergate_api/apps/applications/application_files.py b/jobbergate-api/jobbergate_api/apps/applications/application_files.py
new file mode 100644
index 00000000..5fb7ab13
--- /dev/null
+++ b/jobbergate-api/jobbergate_api/apps/applications/application_files.py
@@ -0,0 +1,140 @@
+"""
+Provide a convenience class for managing application files.
+"""
+
+from pathlib import Path, PurePath
+from typing import Dict, List, Optional, cast
+
+from fastapi import UploadFile
+from file_storehouse import FileManager
+from loguru import logger
+from pydantic import BaseModel, Field
+
+from jobbergate_api.config import settings
+from jobbergate_api.file_validation import perform_all_checks_on_uploaded_files
+from jobbergate_api.s3_manager import IO_TRANSFORMATIONS, file_manager_factory, s3_client
+
+APPLICATIONS_WORK_DIR = "applications"
+APPLICATION_CONFIG_FILE_NAME = "jobbergate.yaml"
+APPLICATION_SOURCE_FILE_NAME = "jobbergate.py"
+APPLICATION_TEMPLATE_FOLDER = "templates"
+
+
+class ApplicationFiles(BaseModel):
+    """
+    Model containing application files.
+    """
+
+    config_file: Optional[str] = Field(None, alias="application_config")
+    source_file: Optional[str] = Field(None, alias="application_source_file")
+    templates: Dict[str, str] = Field(default_factory=dict, alias="application_templates")
+
+    class Config:
+        allow_population_by_field_name = True
+
+    @classmethod
+    def get_from_s3(cls, application_id: int):
+        """
+        Alternative method to initialize the model getting the objects from S3.
+        """
+        logger.debug(f"Getting application files from S3: {application_id=}")
+        file_manager = cls.file_manager_factory(application_id)
+
+        application_files = cls(
+            config_file=file_manager.get(APPLICATION_CONFIG_FILE_NAME),
+            source_file=file_manager.get(APPLICATION_SOURCE_FILE_NAME),
+        )
+
+        for path in file_manager.keys():
+            if str(path.parent) == APPLICATION_TEMPLATE_FOLDER:
+                filename = path.name
+                application_files.templates[filename] = file_manager[path]
+
+        logger.debug("Success getting application files from S3")
+
+        if not application_files.config_file:
+            logger.warning(f"Application config file was not found for {application_id=}")
+        if not application_files.source_file:
+            logger.warning(f"Application source file was not found for {application_id=}")
+        if not application_files.templates:
+            logger.warning(f"No template file was found for {application_id=}")
+
+        return application_files
+
+    @classmethod
+    def delete_from_s3(cls, application_id: int):
+        """
+        Delete the files associated with the given id.
+        """
+        logger.debug(f"Deleting from S3 the files associated to {application_id=}")
+        file_manager = cls.file_manager_factory(application_id)
+        file_manager.clear()
+        logger.debug(f"Files were deleted for {application_id=}")
+
+    def write_to_s3(self, application_id: int, *, remove_previous_files: bool = True):
+        """
+        Write to s3 the files associated with a given id.
+        """
+        logger.debug(f"Writing the application files to S3: {application_id=}")
+
+        if remove_previous_files:
+            self.delete_from_s3(application_id)
+
+        file_manager = self.file_manager_factory(application_id)
+
+        if self.config_file:
+            path = Path(APPLICATION_CONFIG_FILE_NAME)
+            file_manager[path] = self.config_file
+
+        if self.source_file:
+            path = Path(APPLICATION_SOURCE_FILE_NAME)
+            file_manager[path] = self.source_file
+
+        for name, content in self.templates.items():
+            path = Path(APPLICATION_TEMPLATE_FOLDER, name)
+            file_manager[path] = content
+
+        logger.debug(f"Files were written for {application_id=}")
+
+    @classmethod
+    def get_from_upload_files(cls, upload_files: List[UploadFile]):
+        """
+        Initialize the model getting the objects from a list of uploaded files.
+        """
+        logger.debug("Getting application files from the uploaded files")
+
+        perform_all_checks_on_uploaded_files(upload_files)
+
+        application_files = cls()
+
+        for upload in upload_files:
+            file_data = upload.file.read().decode("utf-8")
+            upload.file.seek(0)
+            if upload.filename.endswith(".py"):
+                application_files.source_file = file_data
+            elif upload.filename.endswith(".yaml"):
+                application_files.config_file = file_data
+            elif upload.filename.endswith((".j2", ".jinja2")):
+                filename = PurePath(upload.filename).name
+                application_files.templates[filename] = file_data
+
+        logger.debug("Success getting application files from the uploaded files")
+
+        return application_files
+
+    @classmethod
+    def file_manager_factory(self, application_id: int) -> FileManager:
+        """
+        Build an application file manager.
+        """
+        return cast(
+            FileManager,
+            file_manager_factory(
+                id=application_id,
+                s3_client=s3_client,
+                bucket_name=settings.S3_BUCKET_NAME,
+                work_directory=Path(APPLICATIONS_WORK_DIR),
+                manager_cls=FileManager,
+                transformations=IO_TRANSFORMATIONS,
+            ),
+        )
diff --git a/jobbergate-api/jobbergate_api/apps/applications/models.py b/jobbergate-api/jobbergate_api/apps/applications/models.py
index c282e2d5..76828283 100644
--- a/jobbergate-api/jobbergate_api/apps/applications/models.py
+++ b/jobbergate-api/jobbergate_api/apps/applications/models.py
@@ -15,7 +15,6 @@
     Column("application_identifier", String, unique=True, index=True),
     Column("application_description", String, default=""),
     Column("application_owner_email", String, nullable=False, index=True),
-    Column("application_config", String, nullable=True),
     Column("application_uploaded", Boolean, nullable=False, default=False_()),
     Column("created_at", DateTime, nullable=False, default=func.now()),
     Column("updated_at", DateTime, nullable=False, default=func.now(), onupdate=func.now()),
diff --git a/jobbergate-api/jobbergate_api/apps/applications/routers.py b/jobbergate-api/jobbergate_api/apps/applications/routers.py
index 5750cb03..6776d284 100644
--- a/jobbergate-api/jobbergate_api/apps/applications/routers.py
+++ b/jobbergate-api/jobbergate_api/apps/applications/routers.py
@@ -1,7 +1,7 @@
 """
 Router for the Application resource.
 """
-from typing import Any, Dict, List, Optional
+from typing import List, Optional
 
 from armasec import TokenPayload
 from fastapi import APIRouter, Depends, File, Header, HTTPException, Query
@@ -10,6 +10,7 @@
 from loguru import logger
 from sqlalchemy import not_
 
+from jobbergate_api.apps.applications.application_files import ApplicationFiles
 from jobbergate_api.apps.applications.models import applications_table, searchable_fields, sortable_fields
 from jobbergate_api.apps.applications.schemas import (
     ApplicationCreateRequest,
@@ -20,11 +21,6 @@
 from jobbergate_api.apps.permissions import Permissions
 from jobbergate_api.config import settings
 from jobbergate_api.pagination import Pagination, ok_response, package_response
-from jobbergate_api.s3_manager import (
-    delete_application_files_from_s3,
-    get_application_files_from_s3,
-    write_application_files_to_s3,
-)
 from jobbergate_api.security import IdentityClaims, guard
 from jobbergate_api.storage import (
     INTEGRITY_CHECK_EXCEPTIONS,
@@ -100,18 +96,12 @@ async def applications_upload(
             detail=message,
         )
 
-    write_application_files_to_s3(application_id, upload_files, remove_previous_files=True)
-
-    update_dict: Dict[str, Any] = dict(application_uploaded=True)
-
-    for upload in upload_files:
-        if upload.filename.endswith(".yaml"):
-            update_dict["application_config"] = str(upload.file.read())
-            upload.file.seek(0)
-            break
+    ApplicationFiles.get_from_upload_files(upload_files).write_to_s3(application_id)
 
     update_query = (
-        applications_table.update().where(applications_table.c.id == application_id).values(update_dict)
+        applications_table.update()
+        .where(applications_table.c.id == application_id)
+        .values(dict(application_uploaded=True))
     )
 
     logger.trace(f"update_query = {render_sql(update_query)}")
@@ -146,12 +136,12 @@ async def applications_delete_upload(
         logger.debug(f"Trying to delete an applications that was not uploaded ({application_id=})")
         return FastAPIResponse(status_code=status.HTTP_204_NO_CONTENT)
 
-    delete_application_files_from_s3(application_id)
+    ApplicationFiles.delete_from_s3(application_id)
 
     update_query = (
         applications_table.update()
         .where(applications_table.c.id == application_id)
-        .values(dict(application_uploaded=False, application_config=None))
+        .values(dict(application_uploaded=False))
     )
 
     logger.trace(f"update_query = {render_sql(update_query)}")
@@ -192,7 +182,7 @@ async def application_delete(
 
     await database.execute(delete_query)
 
-    delete_application_files_from_s3(application_id)
+    ApplicationFiles.delete_from_s3(application_id)
 
     return FastAPIResponse(status_code=status.HTTP_204_NO_CONTENT)
 
@@ -230,7 +220,7 @@ async def application_delete_by_identifier(
 
     await database.execute(delete_query)
 
-    delete_application_files_from_s3(id_)
+    ApplicationFiles.delete_from_s3(id_)
 
     return FastAPIResponse(status_code=status.HTTP_204_NO_CONTENT)
 
@@ -279,7 +269,7 @@ async def applications_get_by_id(application_id: int = Query(...)):
     """
     Return the application given it's id.
     """
-    logger.trace(f"Getting {application_id=}")
+    logger.debug(f"Getting {application_id=}")
 
     query = applications_table.select().where(applications_table.c.id == application_id)
     logger.trace(f"get_query = {render_sql(query)}")
@@ -293,14 +283,19 @@ async def applications_get_by_id(application_id: int = Query(...)):
             detail=message,
         )
 
-    logger.debug(f"Application data: {application_data=}")
-
-    response = ApplicationResponse(**application_data)
+    logger.trace(f"Application data: {dict(application_data)}")
 
-    if response.application_uploaded:
-        application_files = get_application_files_from_s3(response.id)
-        response.application_source_file = application_files.source_file
-        response.application_templates = application_files.templates
+    if application_data["application_uploaded"]:
+        response = ApplicationResponse(
+            **application_data,
+            **ApplicationFiles.get_from_s3(application_data["id"]).dict(
+                by_alias=True,
+                exclude_defaults=True,
+                exclude_unset=True,
+            ),
+        )
+    else:
+        response = ApplicationResponse(**application_data)
 
     return response
 
@@ -336,8 +331,6 @@ async def application_update(
         except INTEGRITY_CHECK_EXCEPTIONS as e:
             raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
 
-    logger.debug(f"Application data: {application_data=}")
-
     return application_data
 
 
diff --git a/jobbergate-api/jobbergate_api/apps/applications/schemas.py b/jobbergate-api/jobbergate_api/apps/applications/schemas.py
index 517e04c8..6216cef6 100644
--- a/jobbergate-api/jobbergate_api/apps/applications/schemas.py
+++ b/jobbergate-api/jobbergate_api/apps/applications/schemas.py
@@ -3,9 +3,10 @@
 """
 from datetime import datetime
 from textwrap import dedent
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, List, Optional, Union
 
-from pydantic import BaseModel
+from pydantic import BaseModel, root_validator
+from yaml import safe_load
 
 from jobbergate_api.meta_mapper import MetaField, MetaMapper
 
@@ -95,13 +96,30 @@ class JobbergateConfig(BaseModel):
     Model for Jobbergate configuration (subsection at the yaml file).
     """
 
-    default_template: str
+    default_template: Optional[str]
     supporting_files: Optional[List[str]]
     supporting_files_output_name: Optional[Dict[str, List[str]]]
     template_files: Optional[List[str]]
     job_script_name: Optional[str]
     output_directory: str = "."
 
+    @root_validator(pre=True)
+    def compute_extra_settings(cls, values):
+        """
+        Compute missing values and extra operations to enhance the user experience.
+        """
+        # Transform string to list of strings for a better user experience
+        if values.get("supporting_files_output_name"):
+            for k, v in values["supporting_files_output_name"].items():
+                if isinstance(v, str):
+                    values["supporting_files_output_name"][k] = [v]
+
+        # Get the list of supporting files automatically
+        if values.get("supporting_files_output_name") and not values.get("supporting_files"):
+            values["supporting_files"] = list(values.get("supporting_files_output_name"))
+
+        return values
+
     class Config:
         extra = "allow"
 
@@ -114,6 +132,20 @@ class ApplicationConfig(BaseModel):
     application_config: Dict[str, Any]
     jobbergate_config: JobbergateConfig
 
+    @classmethod
+    def get_from_yaml_file(
+        cls, yaml_file: Union[bytes, str], user_supplied_parameters: Dict[str, Any] = None
+    ):
+        """
+        Construct this model from the application config file (jobbergate.yaml).
+
+        User supplied parameters can be supplied to override the defaults at the file.
+        """
+        param_dict = safe_load(yaml_file)
+        if user_supplied_parameters:
+            param_dict.update(**user_supplied_parameters)
+        return cls(**param_dict)
+
 
 class ApplicationCreateRequest(BaseModel):
     """
diff --git a/jobbergate-api/jobbergate_api/apps/job_scripts/job_script_files.py b/jobbergate-api/jobbergate_api/apps/job_scripts/job_script_files.py
new file mode 100644
index 00000000..accebe5f
--- /dev/null
+++ b/jobbergate-api/jobbergate_api/apps/job_scripts/job_script_files.py
@@ -0,0 +1,312 @@
+"""
+Provide a convenience class for managing job-script files.
+"""
+
+from pathlib import Path
+from typing import Any, Dict, List, cast
+
+from buzz import Buzz, require_condition
+from file_storehouse import FileManager
+from jinja2 import Template
+from loguru import logger
+from pydantic import BaseModel, root_validator
+
+from jobbergate_api.apps.applications.application_files import ApplicationFiles
+from jobbergate_api.apps.applications.schemas import ApplicationConfig
+from jobbergate_api.config import settings
+from jobbergate_api.s3_manager import IO_TRANSFORMATIONS, file_manager_factory, s3_client
+
+JOBSCRIPTS_WORK_DIR = "job-scripts"
+JOBSCRIPTS_MAIN_FILE_FOLDER = "main-file"
+JOBSCRIPTS_SUPPORTING_FILES_FOLDER = "supporting-files"
+
+
+class JobScriptCreationError(Buzz):
+    """Raise exception when facing any error creating a job-script from application files."""
+
+
+def flatten_param_dict(param_dict: Dict[str, Any]) -> Dict[str, Any]:
+    """
+    Flatten an input dictionary to support the rendering process.
+
+    See the example:
+
+    >>> param_dict = {
+    ...     "application_config": {"job_name": "rats", "partitions": [...]},
+    ...     "jobbergate_config": {
+    ...         "default_template": "test_job_script.sh",
+    ...         "supporting_files": [...],
+    ...         "supporting_files_output_name": {...},
+    ...         "template_files": [...],
+    ...         "job_script_name": None,
+    ...         "output_directory": ".",
+    ...         "partition": "debug",
+    ...         "job_name": "rats",
+    ...     },
+    ... }
+    >>> flat_param_dict = flatten_param_dict(param_dict)
+    >>> print(flat_param_dict)
+    {
+        "job_name": "rats",
+        "partitions": ["debug", "partition1"],
+        "default_template": "test_job_script.sh",
+        "supporting_files": ["test_job_script.sh"],
+        "supporting_files_output_name": {"test_job_script.sh": [...]},
+        "template_files": ["templates/test_job_script.sh"],
+        "job_script_name": None,
+        "output_directory": ".",
+        "partition": "debug",
+    }
+    """
+    param_dict_flat = {}
+    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
+    return param_dict_flat
+
+
+def inject_sbatch_params(job_script_data_as_string: str, sbatch_params: List[str]) -> str:
+    """
+    Inject sbatch params into job script.
+
+    Given the job script as job_script_data_as_string, inject the sbatch params in the correct location.
+    """
+    logger.debug("Preparing to inject sbatch params into job script")
+
+    if not sbatch_params:
+        logger.warning("Sbatch param list is empty")
+        return job_script_data_as_string
+
+    first_sbatch_index = job_script_data_as_string.find("#SBATCH")
+    string_slice = job_script_data_as_string[first_sbatch_index:]
+    line_end = string_slice.find("\n") + first_sbatch_index + 1
+
+    inner_string = ""
+    for parameter in sbatch_params:
+        inner_string += "#SBATCH " + parameter + "\n"
+
+    new_job_script_data_as_string = (
+        job_script_data_as_string[:line_end] + inner_string + job_script_data_as_string[line_end:]
+    )
+
+    logger.debug("Done injecting sbatch params into job script")
+    return new_job_script_data_as_string
+
+
+class JobScriptFiles(BaseModel):
+    """
+    Model containing job-script files.
+
+    The folder structure aims to mimic the one specified by the application code.
+    In this way, the ``output_directory`` and file names for the main file
+    and supporting files are application specific.
+
+    To differentiate the files, they are stored in two folders, ``main-file`` and
+    ``supporting-files`` and the paths used in this model are relative the those
+    folders. See the example:
+
+        job-scripts
+        └───<id>
+            └───main-file
+            │   └───<output-directory>
+            │       └───<main-file-name>
+            │
+            └───supporting-files
+                └───<output-directory>
+                    │   <supporting-file-name-1>
+                    │   <supporting-file-name-2>
+                    │   <supporting-file-name-3>
+                    └───...
+
+    In this way, services that consume this information can maintain the
+    structure as well (like the user interface and the agents).
+    """
+
+    main_file_path: Path
+    files: Dict[Path, str]
+
+    @root_validator(pre=False, skip_on_failure=True)
+    def check_main_file_path_is_in_files_keys(cls, values):
+        """
+        Validate the model.
+
+        main_file_path should be found among the files.
+        """
+        if values["main_file_path"] not in values["files"].keys():
+            raise ValueError("main_file_path is not a valid key on the dict files")
+        return values
+
+    @property
+    def main_file(self):
+        """
+        Obtain the main file with this helper property.
+        """
+        return self.files.get(self.main_file_path)
+
+    @classmethod
+    def get_from_s3(cls, job_script_id: int):
+        """
+        Alternative method to initialize the model getting the objects from S3.
+        """
+        logger.debug(f"Getting job-script files from S3: {job_script_id=}")
+        file_manager = cls.file_manager_factory(job_script_id)
+
+        files = {}
+        main_file_path = None
+        main_file_counter = 0
+
+        for s3_path in file_manager.keys():
+            foldername = s3_path.parts[0]
+            dict_path = s3_path.relative_to(foldername)
+            if foldername == JOBSCRIPTS_MAIN_FILE_FOLDER:
+                files[dict_path] = file_manager.get(s3_path)
+                main_file_counter += 1
+                main_file_path = dict_path
+            elif foldername == JOBSCRIPTS_SUPPORTING_FILES_FOLDER:
+                files[dict_path] = file_manager.get(s3_path)
+
+        require_condition(
+            main_file_counter == 1,
+            f"One and only one main file is expected for a job-script, found {main_file_counter}",
+            ValueError,
+        )
+
+        jobscript_files = cls(main_file_path=main_file_path, files=files)
+
+        logger.debug(f"Done getting job-script files from S3: {job_script_id=}")
+
+        return jobscript_files
+
+    @classmethod
+    def delete_from_s3(cls, job_script_id: int):
+        """
+        Delete the files associated with the given id.
+        """
+        logger.debug(f"Deleting from S3 the files associated to {job_script_id=}")
+        file_manager = cls.file_manager_factory(job_script_id)
+        file_manager.clear()
+        logger.debug(f"Files were deleted for {job_script_id=}")
+
+    def write_to_s3(self, job_script_id: int, *, remove_previous_files: bool = True):
+        """
+        Write to s3 the files associated with a given id.
+        """
+        logger.debug(f"Writing job-script files to S3: {job_script_id=}")
+
+        if remove_previous_files:
+            self.delete_from_s3(job_script_id)
+
+        file_manager = self.file_manager_factory(job_script_id)
+
+        for dict_path, content in self.files.items():
+            if dict_path == self.main_file_path:
+                s3_path = JOBSCRIPTS_MAIN_FILE_FOLDER / dict_path
+            else:
+                s3_path = JOBSCRIPTS_SUPPORTING_FILES_FOLDER / dict_path
+            file_manager[s3_path] = content
+
+        logger.debug(f"Done writing job-script files to S3 for {job_script_id=}")
+
+    @classmethod
+    def render_from_application(
+        cls,
+        application_files: ApplicationFiles,
+        user_supplied_parameters: Dict[str, Any] = None,
+        sbatch_params: List[str] = None,
+    ):
+        """
+        Render JobScriptFiles from ApplicationFiles.
+        """
+        logger.debug("Rendering job-script files from an application")
+
+        with JobScriptCreationError.check_expressions(
+            main_message="One or more application files are missing",
+        ) as check:
+            check(application_files.config_file, "Application config file was not found")
+            check(application_files.source_file, "Application source file was not found")
+            check(application_files.templates, "No template file was found")
+
+        with JobScriptCreationError.handle_errors(
+            "Error while parsing the config-file and/or job-script params",
+        ):
+            app_config = ApplicationConfig.get_from_yaml_file(
+                application_files.config_file, user_supplied_parameters  # type: ignore
+            )
+
+        with JobScriptCreationError.check_expressions(
+            main_message=(
+                "One or more template files are missing "
+                "the available options are: "
+                ", ".join(application_files.templates.keys()),
+            ),
+        ) as check:
+            default_template_name = app_config.jobbergate_config.default_template
+            check(
+                default_template_name in application_files.templates,
+                f"{default_template_name=} was not found",
+            )
+            if app_config.jobbergate_config.supporting_files_output_name:
+                for supporting_file in app_config.jobbergate_config.supporting_files_output_name.keys():
+                    check(supporting_file in application_files.templates, f"{supporting_file=} was not found")
+
+        with JobScriptCreationError.handle_errors("Error rendering the main file"):
+
+            param_dict_flat = flatten_param_dict(app_config.dict())
+
+            main_file_path = Path(
+                app_config.jobbergate_config.output_directory,
+                default_template_name.rstrip(".j2").rstrip(".jinja2"),
+            )
+
+            main_file_content = Template(
+                application_files.templates[default_template_name],
+            ).render(data=param_dict_flat)
+
+        with JobScriptCreationError.handle_errors("Error while injecting the sbatch params"):
+            if sbatch_params:
+                main_file_content = inject_sbatch_params(main_file_content, sbatch_params)
+
+        with JobScriptCreationError.handle_errors("Error while creating JobScriptFiles object"):
+            jobscript_files = cls(
+                main_file_path=main_file_path,
+                files={main_file_path: main_file_content},
+            )
+
+        with JobScriptCreationError.handle_errors("Error while rendering the supporting files"):
+            output_name_mapping = app_config.jobbergate_config.supporting_files_output_name
+            if output_name_mapping:
+                for template_name, supporting_filename_list in output_name_mapping.items():
+
+                    for supporting_filename in supporting_filename_list:
+                        path = Path(
+                            app_config.jobbergate_config.output_directory,
+                            supporting_filename,
+                        )
+
+                        jobscript_files.files[path] = Template(
+                            application_files.templates[template_name],
+                        ).render(data=param_dict_flat)
+
+        logger.debug("Done rendering job-script files from an application")
+
+        return jobscript_files
+
+    @classmethod
+    def file_manager_factory(self, job_script_id: int) -> FileManager:
+        """
+        Build an application file manager.
+        """
+        return cast(
+            FileManager,
+            file_manager_factory(
+                id=job_script_id,
+                s3_client=s3_client,
+                bucket_name=settings.S3_BUCKET_NAME,
+                work_directory=Path(JOBSCRIPTS_WORK_DIR),
+                manager_cls=FileManager,
+                transformations=IO_TRANSFORMATIONS,
+            ),
+        )
diff --git a/jobbergate-api/jobbergate_api/apps/job_scripts/routers.py b/jobbergate-api/jobbergate_api/apps/job_scripts/routers.py
index 93605f93..2ec32a87 100644
--- a/jobbergate-api/jobbergate_api/apps/job_scripts/routers.py
+++ b/jobbergate-api/jobbergate_api/apps/job_scripts/routers.py
@@ -1,18 +1,16 @@
 """
 Router for the JobScript resource.
 """
-import json
-from pathlib import PurePath
-from typing import List, Optional
+from typing import Optional
 
 from armasec import TokenPayload
 from fastapi import APIRouter, Depends, HTTPException, Query, status
-from jinja2 import Template
 from loguru import logger
-from yaml import safe_load
 
+from jobbergate_api.apps.applications.application_files import ApplicationFiles
 from jobbergate_api.apps.applications.models import applications_table
-from jobbergate_api.apps.applications.schemas import ApplicationConfig, ApplicationResponse
+from jobbergate_api.apps.applications.schemas import ApplicationResponse
+from jobbergate_api.apps.job_scripts.job_script_files import JobScriptCreationError, JobScriptFiles
 from jobbergate_api.apps.job_scripts.models import job_scripts_table, searchable_fields, sortable_fields
 from jobbergate_api.apps.job_scripts.schemas import (
     JobScriptCreateRequest,
@@ -21,9 +19,7 @@
     JobScriptUpdateRequest,
 )
 from jobbergate_api.apps.permissions import Permissions
-from jobbergate_api.file_validation import UploadedFilesValidationError
 from jobbergate_api.pagination import Pagination, ok_response, package_response
-from jobbergate_api.s3_manager import get_application_files_from_s3, s3man_jobscripts
 from jobbergate_api.security import IdentityClaims, guard
 from jobbergate_api.storage import (
     INTEGRITY_CHECK_EXCEPTIONS,
@@ -36,90 +32,6 @@
 router = APIRouter()
 
 
-def inject_sbatch_params(job_script_data_as_string: str, sbatch_params: List[str]) -> str:
-    """
-    Inject sbatch params into job script.
-
-    Given the job script as job_script_data_as_string, inject the sbatch params in the correct location.
-    """
-    logger.debug("Preparing to inject sbatch params into job script")
-
-    if not sbatch_params:
-        logger.warning("Sbatch param list is empty")
-        return job_script_data_as_string
-
-    first_sbatch_index = job_script_data_as_string.find("#SBATCH")
-    string_slice = job_script_data_as_string[first_sbatch_index:]
-    line_end = string_slice.find("\n") + first_sbatch_index + 1
-
-    inner_string = ""
-    for parameter in sbatch_params:
-        inner_string += "#SBATCH " + parameter + "\\n"
-
-    new_job_script_data_as_string = (
-        job_script_data_as_string[:line_end] + inner_string + job_script_data_as_string[line_end:]
-    )
-
-    logger.debug("Done injecting sbatch params into job script")
-    return new_job_script_data_as_string
-
-
-def render_template(template_files, param_dict_flat):
-    """
-    Render the templates as strings using jinja2.
-    """
-    logger.debug("Rendering the templates as strings using jinja2")
-    for key, value in template_files.items():
-        template = Template(value)
-        rendered_js = template.render(data=param_dict_flat)
-        template_files[key] = rendered_js
-    job_script_data_as_string = json.dumps(template_files)
-    return job_script_data_as_string
-
-
-def build_job_script_data_as_string(application_id: int, application_config: ApplicationConfig) -> str:
-    """
-    Return the job_script_data_as_string from the S3 application and the templates.
-    """
-    logger.debug("Building the job script file from the S3 application and the templates")
-
-    application_files = get_application_files_from_s3(application_id)
-
-    default_template_path = PurePath(application_config.jobbergate_config.default_template)
-    default_template_name = default_template_path.name
-
-    with UploadedFilesValidationError.check_expressions("One or more application files are missing") as check:
-        check(
-            application_files.source_file,
-            f"Application file was not found for {application_id=}",
-        )
-        check(
-            application_files.templates,
-            f"No template was found for {application_id=}",
-        )
-        check(
-            default_template_name in application_files.templates,
-            f"{default_template_name=} was found for {application_id=}",
-        )
-
-    # rename default template
-    default_template_file = application_files.templates.pop(default_template_name)
-    application_files.templates["application.sh"] = default_template_file
-
-    param_dict_flat = {}
-    for (key, value) in application_config.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
-
-    job_script_data_as_string = render_template(application_files.templates, param_dict_flat)
-
-    logger.debug("Done building the job script file")
-    return job_script_data_as_string
-
-
 @router.post(
     "/job-scripts",
     status_code=status.HTTP_201_CREATED,
@@ -162,24 +74,16 @@ async def job_script_create(
         job_script_owner_email=identity_claims.email,
     )
 
-    # Use application_config from the application as a baseline of defaults
-    logger.debug(f"APP CONFIG: {application.application_config}")
-    param_dict = safe_load(application.application_config)
-
-    # User supplied param dict is optional and may override defaults
-    param_dict.update(**job_script.param_dict)
-
-    application_config = ApplicationConfig(**param_dict)
-
     try:
-        job_script_data_as_string = build_job_script_data_as_string(application.id, application_config)
-    except UploadedFilesValidationError as e:
+        jobscript_files = JobScriptFiles.render_from_application(
+            application_files=ApplicationFiles.get_from_s3(application.id),
+            user_supplied_parameters=job_script.param_dict,
+            sbatch_params=create_dict.pop("sbatch_params", []),
+        )
+    except JobScriptCreationError as e:
         message = str(e)
         logger.error(message)
-        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=message)
-
-    sbatch_params = create_dict.pop("sbatch_params", [])
-    job_script_data_as_string = inject_sbatch_params(job_script_data_as_string, sbatch_params)
+        raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=message)
 
     logger.debug("Inserting job_script")
 
@@ -198,7 +102,7 @@ async def job_script_create(
                     detail=message,
                 )
 
-            s3man_jobscripts[job_script_data["id"]] = job_script_data_as_string
+            jobscript_files.write_to_s3(job_script_data["id"])
 
         except INTEGRITY_CHECK_EXCEPTIONS as e:
             logger.error(f"Reverting database transaction: {str(e)}")
@@ -208,12 +112,13 @@ async def job_script_create(
             logger.error(f"Reverting database transaction: {str(e)}")
             raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e))
 
-    logger.debug(f"Job-script created: {job_script_data}")
-    job_script_response = dict(
+    logger.debug(f"Job-script created: {dict(job_script_data)}")
+
+    response = JobScriptResponse(
         **job_script_data,
-        job_script_data_as_string=job_script_data_as_string,
+        job_script_files=jobscript_files,
     )
-    return job_script_response
+    return response
 
 
 @router.get(
@@ -240,11 +145,12 @@ async def job_script_get(job_script_id: int = Query(...)):
             detail=message,
         )
 
-    job_script_response = dict(job_script)
-
     try:
-        job_script_response["job_script_data_as_string"] = s3man_jobscripts[job_script_id]
-    except KeyError:
+        response = JobScriptResponse(
+            **job_script,
+            job_script_files=JobScriptFiles.get_from_s3(job_script_id),
+        )
+    except (KeyError, ValueError):
         message = f"JobScript file not found for id={job_script_id}."
         logger.warning(message)
         raise HTTPException(
@@ -252,9 +158,9 @@ async def job_script_get(job_script_id: int = Query(...)):
             detail=message,
         )
 
-    logger.debug(f"Job-script data: {job_script_response=}")
+    logger.debug(f"Job-script data: {response}")
 
-    return job_script_response
+    return response
 
 
 @router.get(
@@ -325,7 +231,7 @@ async def job_script_delete(job_script_id: int = Query(..., description="id of t
     await database.execute(delete_query)
 
     try:
-        del s3man_jobscripts[job_script_id]
+        JobScriptFiles.delete_from_s3(job_script_id)
     except KeyError:
         # There is no need to raise an error if we try to delete a file that does not exist
         logger.warning(f"Tried to delete {job_script_id=}, but it was not found on S3.")
@@ -348,7 +254,7 @@ async def job_script_update(job_script_id: int, job_script: JobScriptUpdateReque
         job_scripts_table.update()
         .where(job_scripts_table.c.id == job_script_id)
         .values(
-            job_script.dict(exclude_unset=True, exclude={"job_script_data_as_string"}),
+            job_script.dict(exclude_unset=True, exclude={"job_script_files"}),
         )
         .returning(job_scripts_table)
     )
@@ -373,12 +279,12 @@ async def job_script_update(job_script_id: int, job_script: JobScriptUpdateReque
         job_script_response = dict(result)
 
         try:
-            if job_script.job_script_data_as_string:
-                s3man_jobscripts[job_script_id] = job_script.job_script_data_as_string
-                job_script_response["job_script_data_as_string"] = job_script.job_script_data_as_string
+            if job_script.job_script_files:
+                job_script.job_script_files.write_to_s3(job_script_id)
+                job_script_response["job_script_files"] = job_script.job_script_files
             else:
-                job_script_response["job_script_data_as_string"] = s3man_jobscripts[job_script_id]
-        except KeyError as e:
+                job_script_response["job_script_files"] = JobScriptFiles.get_from_s3(job_script_id)
+        except (KeyError, ValueError) as e:
             logger.error(f"Reverting database transaction: {str(e)}")
             raise HTTPException(
                 status_code=status.HTTP_404_NOT_FOUND,
diff --git a/jobbergate-api/jobbergate_api/apps/job_scripts/schemas.py b/jobbergate-api/jobbergate_api/apps/job_scripts/schemas.py
index 70773779..dbf55cc5 100644
--- a/jobbergate-api/jobbergate_api/apps/job_scripts/schemas.py
+++ b/jobbergate-api/jobbergate_api/apps/job_scripts/schemas.py
@@ -6,6 +6,7 @@
 
 from pydantic import BaseModel
 
+from jobbergate_api.apps.job_scripts.job_script_files import JobScriptFiles
 from jobbergate_api.meta_mapper import MetaField, MetaMapper
 
 job_script_meta_mapper = MetaMapper(
@@ -81,7 +82,7 @@ class JobScriptUpdateRequest(BaseModel):
 
     job_script_name: Optional[str]
     job_script_description: Optional[str]
-    job_script_data_as_string: Optional[str]
+    job_script_files: Optional[JobScriptFiles]
     sbatch_params: Optional[List[str]]
     param_dict: Optional[str]
 
@@ -93,7 +94,7 @@ class JobScriptPartialResponse(BaseModel):
     """
     Complete model to match database for the JobScript resource.
 
-    Notice this model does not include job_script_data_as_string from S3.
+    Notice this model does not include job_script_files from S3.
     """
 
     id: Optional[int] = None
@@ -113,10 +114,10 @@ class JobScriptResponse(JobScriptPartialResponse):
     """
     Complete model to match database for the JobScript resource.
 
-    In addition to the field job_script_data_as_string from S3, for the JobScript resource.
+    In addition to the field job_script_files from S3, for the JobScript resource.
     """
 
-    job_script_data_as_string: str
+    job_script_files: JobScriptFiles
 
     class Config:
         orm_mode = True
diff --git a/jobbergate-api/jobbergate_api/apps/job_submissions/routers.py b/jobbergate-api/jobbergate_api/apps/job_submissions/routers.py
index 24e51e73..a95a3c4d 100644
--- a/jobbergate-api/jobbergate_api/apps/job_submissions/routers.py
+++ b/jobbergate-api/jobbergate_api/apps/job_submissions/routers.py
@@ -9,6 +9,7 @@
 from sqlalchemy import select
 
 from jobbergate_api.apps.applications.models import applications_table
+from jobbergate_api.apps.job_scripts.job_script_files import JobScriptFiles
 from jobbergate_api.apps.job_scripts.models import job_scripts_table
 from jobbergate_api.apps.job_submissions.constants import JobSubmissionStatus
 from jobbergate_api.apps.job_submissions.models import (
@@ -26,7 +27,6 @@
 from jobbergate_api.apps.permissions import Permissions
 from jobbergate_api.email_notification import notify_submission_rejected
 from jobbergate_api.pagination import Pagination, ok_response, package_response
-from jobbergate_api.s3_manager import s3man_jobscripts
 from jobbergate_api.security import IdentityClaims, guard
 from jobbergate_api.storage import (
     INTEGRITY_CHECK_EXCEPTIONS,
@@ -316,18 +316,21 @@ async def job_submissions_agent_pending(
     logger.trace(f"query = {render_sql(query)}")
     rows = await database.fetch_all(query)
 
-    try:
-        response = [
-            PendingJobSubmission(
-                **row,
-                job_script_data_as_string=s3man_jobscripts[str(row.get("job_script_id"))],
-            )
-            for row in rows
-        ]
-    except KeyError:
-        list_ids = (str(row.get("job_script_id")) for row in rows)
-        missing_ids = {id for id in list_ids if id not in s3man_jobscripts}
-        message = f"JobScript file(s) not found, the missing ids are: {', '.join(missing_ids)}"
+    response = []
+    missing_ids = set()
+
+    for row in rows:
+        try:
+            jobscript_id = int(row["job_script_id"])
+            job_script_files = JobScriptFiles.get_from_s3(jobscript_id)
+        except (KeyError, ValueError) as e:
+            logger.error(f"Error getting files for {jobscript_id=}: {str(e)}")
+            missing_ids.add(jobscript_id)
+        else:
+            response.append(PendingJobSubmission(**row, job_script_files=job_script_files))
+
+    if missing_ids:
+        message = f"JobScript file(s) not found, the missing ids are: {', '.join(map(str, missing_ids))}"
         logger.error(message)
         raise HTTPException(
             status_code=status.HTTP_404_NOT_FOUND,
diff --git a/jobbergate-api/jobbergate_api/apps/job_submissions/schemas.py b/jobbergate-api/jobbergate_api/apps/job_submissions/schemas.py
index cb09c1c6..3e6cde8b 100644
--- a/jobbergate-api/jobbergate_api/apps/job_submissions/schemas.py
+++ b/jobbergate-api/jobbergate_api/apps/job_submissions/schemas.py
@@ -7,6 +7,7 @@
 
 from pydantic import BaseModel, Extra, Field
 
+from jobbergate_api.apps.job_scripts.job_script_files import JobScriptFiles
 from jobbergate_api.apps.job_submissions.constants import JobSubmissionStatus
 from jobbergate_api.meta_mapper import MetaField, MetaMapper
 
@@ -126,8 +127,8 @@ class PendingJobSubmission(BaseModel, extra=Extra.ignore):
     job_submission_owner_email: str
     execution_directory: Optional[Path]
     job_script_name: str
-    job_script_data_as_string: str
     application_name: str
+    job_script_files: JobScriptFiles
 
 
 class ActiveJobSubmission(BaseModel, extra=Extra.ignore):
diff --git a/jobbergate-api/jobbergate_api/file_validation.py b/jobbergate-api/jobbergate_api/file_validation.py
index c10f7af5..43850cf7 100644
--- a/jobbergate-api/jobbergate_api/file_validation.py
+++ b/jobbergate-api/jobbergate_api/file_validation.py
@@ -193,9 +193,8 @@ def check_uploaded_files_yaml_is_parsable(file_list: List[UploadFile]):
     ):
         for f in file_list:
             if f.filename.endswith(".yaml"):
-                config = yaml_safe_load(f.file.read())
+                ApplicationConfig.get_from_yaml_file(f.file.read())
                 f.file.seek(0)
-                ApplicationConfig(**config)
 
 
 SyntaxValidationEquation = Callable[[Union[str, bytes]], bool]
diff --git a/jobbergate-api/jobbergate_api/s3_manager.py b/jobbergate-api/jobbergate_api/s3_manager.py
index a06fc8d9..d3706164 100644
--- a/jobbergate-api/jobbergate_api/s3_manager.py
+++ b/jobbergate-api/jobbergate_api/s3_manager.py
@@ -1,138 +1,73 @@
 """
 Provide a convenience class for managing calls to S3.
 """
-import typing
-from dataclasses import dataclass
 from functools import lru_cache
-from pathlib import PurePath
-
-from fastapi import UploadFile
-from file_storehouse import FileManager, FileManagerReadOnly, client
-from file_storehouse.engine import EngineS3
-from file_storehouse.key_mapping import KeyMappingNumeratedFolder, KeyMappingRaw
-from file_storehouse.transformation import TransformationCodecs
-from loguru import logger
+from pathlib import Path
+from typing import Tuple, Type, Union
+
+from boto3 import client
+from botocore.client import BaseClient
+from file_storehouse import (
+    EngineS3,
+    FileManager,
+    FileManagerReadOnly,
+    TransformationABC,
+    TransformationCodecs,
+)
 
 from jobbergate_api.config import settings
-from jobbergate_api.file_validation import perform_all_checks_on_uploaded_files
-
-
-@lru_cache
-def application_template_manager_factory(
-    application_id: int, is_read_only: bool = True
-) -> typing.Union[FileManager, FileManagerReadOnly]:
-    """
-    Build a manager object for application template files.
-
-    :param int application_id: Application's id
-    :param bool is_read_only: If the manager is read only or not, defaults to True
-    :return typing.Union[FileManager, FileManagerReadOnly]: File manager
-    """
-    Manager = FileManagerReadOnly if is_read_only else FileManager
-    return Manager(
-        engine=EngineS3(
-            s3_client,
-            settings.S3_BUCKET_NAME,
-            f"applications/{application_id}/templates/",
-        ),
-        transformation_list=[TransformationCodecs("utf-8")],
-        key_mapping=KeyMappingRaw(),
-    )
-
-
-def write_application_files_to_s3(
-    application_id: int,
-    upload_files: typing.List[UploadFile],
-    *,
-    remove_previous_files: bool = False,
-):
-    """
-    Write the list of uploaded application files to S3, fist checking them for consistency.
-
-    :param int application_id: Application identification number
-    :param typing.List[UploadFile] upload_files: Uploaded files
-    :param bool remove_previous_files: Delete old files before writing the new ones
-    """
-    logger.debug(f"Writing the list of uploaded files to S3: {application_id=}")
-
-    perform_all_checks_on_uploaded_files(upload_files)
-
-    if remove_previous_files:
-        delete_application_files_from_s3(application_id)
-
-    templates_manager = typing.cast(
-        FileManager,
-        application_template_manager_factory(application_id, is_read_only=False),
-    )
 
-    for upload in upload_files:
-        if upload.filename.endswith(".py"):
-            s3man_applications_source_files[application_id] = upload.file
-        elif upload.filename.endswith((".j2", ".jinja2")):
-            filename = PurePath(upload.filename).name
-            templates_manager[filename] = upload.file
+IO_TRANSFORMATIONS = (TransformationCodecs("utf-8"),)
+"""
+Transformations to be performed when writing/reading S3 objects.
 
+With this, all files are encoded/decoded with utf-8.
 
-@dataclass
-class ApplicationFiles:
-    """
-    Dataclass containing application files.
-    """
-
-    templates: typing.Dict[str, str]
-    source_file: str
+This constant can be shared between file managers.
+"""
 
 
-def get_application_files_from_s3(application_id: int) -> ApplicationFiles:
+def engine_factory(*, s3_client: BaseClient, bucket_name: str, work_directory: Path) -> EngineS3:
     """
-    Read the application files from S3.
+    Build an engine to manipulate objects from an s3 bucket.
 
-    :param int application_id: Application identification number
-    :return ApplicationFiles: Application files
+    :param BaseClient s3_client: S3 client from boto3
+    :param str bucket_name: The name of the s3 bucket
+    :param Path work_directory: Work directory (referred as prefix at boto3 documentation)
+    :return EngineS3: And engine to manipulate files from s3
     """
-    logger.debug(f"Getting application files from S3: {application_id=}")
+    return EngineS3(s3_client=s3_client, bucket_name=bucket_name, prefix=str(work_directory))
 
-    templates_manager = application_template_manager_factory(application_id, True)
 
-    return ApplicationFiles(
-        templates=dict(templates_manager), source_file=s3man_applications_source_files.get(application_id, "")
-    )
-
-
-def delete_application_files_from_s3(application_id: int):
+@lru_cache(maxsize=128)
+def file_manager_factory(
+    id: int,
+    *,
+    s3_client: BaseClient,
+    bucket_name: str,
+    work_directory: Path,
+    manager_cls: Union[Type[FileManagerReadOnly], Type[FileManager]],
+    transformations: Tuple[TransformationABC, ...],
+) -> Union[FileManagerReadOnly, FileManager]:
     """
-    Delete the files associated to a given id from S3.
-
-    :param int application_id: Application identification number
+    Build a file managers on demand.
+
+    :param int id: identification number
+    :param BaseClient s3_client: S3 client from boto3
+    :param str bucket_name: The name of the s3 bucket
+    :param Path work_directory: Work directory (referred as prefix at boto3 documentation)
+    :param Union[FileManager, FileManagerReadOnly] manager_cls: Manager class (i/o access or just read only)
+    :param Tuple[TransformationABC] transformations: I/o transformations
+    :return Union[FileManager, FileManagerReadOnly]: Manager object
     """
-    logger.debug(f"Deleting from S3 the files associated to {application_id=}")
-
-    templates_manager = typing.cast(
-        FileManager, application_template_manager_factory(application_id, is_read_only=False)
+    return manager_cls(
+        engine=engine_factory(
+            s3_client=s3_client,
+            bucket_name=bucket_name,
+            work_directory=work_directory / str(id),
+        ),
+        io_transformations=transformations,
     )
 
-    templates_manager.clear()
-    try:
-        del s3man_applications_source_files[application_id]
-    except KeyError:
-        logger.warning(
-            f"Tried to delete the source code for {application_id=}, but it was not found on S3",
-        )
 
-
-s3_client = client(
-    "s3",
-    endpoint_url=settings.S3_ENDPOINT_URL,
-)
-
-s3man_applications_source_files = FileManager(
-    engine=EngineS3(s3_client, settings.S3_BUCKET_NAME, "applications"),
-    transformation_list=[TransformationCodecs("utf-8")],
-    key_mapping=KeyMappingNumeratedFolder("jobbergate.py"),
-)
-
-s3man_jobscripts = FileManager(
-    engine=EngineS3(s3_client, settings.S3_BUCKET_NAME, "job-scripts"),
-    transformation_list=[TransformationCodecs("utf-8")],
-    key_mapping=KeyMappingNumeratedFolder("jobbergate.txt"),
-)
+s3_client = client("s3", endpoint_url=settings.S3_ENDPOINT_URL)
diff --git a/jobbergate-api/poetry.lock b/jobbergate-api/poetry.lock
index 78049c4b..0b592037 100644
--- a/jobbergate-api/poetry.lock
+++ b/jobbergate-api/poetry.lock
@@ -414,7 +414,7 @@ test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "pytest-async
 
 [[package]]
 name = "file-storehouse"
-version = "0.3.4"
+version = "0.5.0"
 description = "Manage files in bulk quantity using a friendly dict-like interface"
 category = "main"
 optional = false
@@ -1179,11 +1179,11 @@ six = ">=1.5"
 
 [[package]]
 name = "python-dotenv"
-version = "0.16.0"
+version = "0.20.0"
 description = "Read key-value pairs from a .env file and set them as environment variables"
 category = "main"
 optional = false
-python-versions = "*"
+python-versions = ">=3.5"
 
 [package.extras]
 cli = ["click (>=5.0)"]
@@ -1657,7 +1657,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.8"
-content-hash = "1e13db2dd9d18a8d82ce264bd9b687a2ac29f8cc09eeeefb71ad3c5a7b578fa0"
+content-hash = "058dbfe428a434f5022454cf6b15e388beed9c823958ca56627f376f2208ea85"
 
 [metadata.files]
 alembic = [
@@ -1926,8 +1926,8 @@ fastapi = [
     {file = "fastapi-0.68.2.tar.gz", hash = "sha256:38526fc46bda73f7ec92033952677323c16061e70a91d15c95f18b11895da494"},
 ]
 file-storehouse = [
-    {file = "file-storehouse-0.3.4.tar.gz", hash = "sha256:94a9b843b5334392da8d9a9ad312a9e1218afce962b84880413cecccb3e17fb9"},
-    {file = "file_storehouse-0.3.4-py3-none-any.whl", hash = "sha256:6436c2c132d4f93dfe85900ffb0d437650d8835f2d5d11ca866ea0c5c4f6503a"},
+    {file = "file-storehouse-0.5.0.tar.gz", hash = "sha256:78e535862d8b852412e94d5014a31a18289bbcd1f34b570a3a53307b70d69ac8"},
+    {file = "file_storehouse-0.5.0-py3-none-any.whl", hash = "sha256:e5e395aefa4553f29bed4d30dcc7051bf92faa742005bb6320340874b3526083"},
 ]
 filelock = [
     {file = "filelock-3.6.0-py3-none-any.whl", hash = "sha256:f8314284bfffbdcfa0ff3d7992b023d4c628ced6feb957351d4c48d059f56bc0"},
@@ -2420,10 +2420,7 @@ python-dateutil = [
     {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
     {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
 ]
-python-dotenv = [
-    {file = "python-dotenv-0.16.0.tar.gz", hash = "sha256:9fa413c37d4652d3fa02fea0ff465c384f5db75eab259c4fc5d0c5b8bf20edd4"},
-    {file = "python_dotenv-0.16.0-py2.py3-none-any.whl", hash = "sha256:31d752f5b748f4e292448c9a0cac6a08ed5e6f4cefab85044462dcad56905cec"},
-]
+python-dotenv = []
 python-http-client = [
     {file = "python_http_client-3.3.7-py3-none-any.whl", hash = "sha256:ad371d2bbedc6ea15c26179c6222a78bc9308d272435ddf1d5c84f068f249a36"},
     {file = "python_http_client-3.3.7.tar.gz", hash = "sha256:bf841ee45262747e00dec7ee9971dfb8c7d83083f5713596488d67739170cea0"},
diff --git a/jobbergate-api/pyproject.toml b/jobbergate-api/pyproject.toml
index c3317b6f..05710aa7 100644
--- a/jobbergate-api/pyproject.toml
+++ b/jobbergate-api/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "jobbergate-api"
-version = "3.2.4-alpha.0"
+version = "3.2.4-alpha.3"
 description = "Jobbergate API"
 authors = ["Omnivector Solutions <info@omnivector.solutions>"]
 license = "MIT"
@@ -13,7 +13,7 @@ fastapi = "^0.68.0"
 email_validator = "^1.1.0"
 passlib = "^1.7.2"
 python-multipart = "^0.0.5"
-python-dotenv = "^0.16.0"
+python-dotenv = "^0.20.0"
 boto3 = "^1.17.51"
 Jinja2 = "^3.0.3"
 asyncpg = "^0.22.0"
@@ -27,7 +27,7 @@ typer = "^0.4.0"
 databases = {extras = ["postgresql"], version = "^0.5.5"}
 PyYAML = "^6.0"
 sendgrid = "^6.9.7"
-file-storehouse = "0.3.4"
+file-storehouse = "0.5.0"
 
 [tool.poetry.dev-dependencies]
 black = "^22"
diff --git a/jobbergate-cli/CHANGELOG.rst b/jobbergate-cli/CHANGELOG.rst
index 62550af5..f143ea58 100644
--- a/jobbergate-cli/CHANGELOG.rst
+++ b/jobbergate-cli/CHANGELOG.rst
@@ -7,9 +7,15 @@ This file keeps track of all notable changes to jobbergate-cli
 Unreleased
 ----------
 
+3.2.4-alpha.1 -- 2022-08-25
+---------------------------
+- Refactor the logic to upload files to the API.
+- Remove file validation from the CLI.
+- Remove compression of the upload files into tarballs.
+
 3.2.4-alpha.0 -- 2022-08-12
 ---------------------------
-Keep versions synced between the agent and the API
+- Keep versions synced between the agent and the API
 
 3.2.3 -- 2022-08-01
 -------------------
diff --git a/jobbergate-cli/jobbergate_cli/constants.py b/jobbergate-cli/jobbergate_cli/constants.py
index 7e7f6261..090096da 100644
--- a/jobbergate-cli/jobbergate_cli/constants.py
+++ b/jobbergate-cli/jobbergate_cli/constants.py
@@ -9,8 +9,6 @@
 JOBBERGATE_APPLICATION_CONFIG = {
     "application_name": "",
     "application_description": "",
-    "application_file": "",
-    "application_config": "",
 }
 
 JOBBERGATE_JOB_SCRIPT_CONFIG = {
@@ -28,6 +26,7 @@
 }
 
 JOBBERGATE_DEFAULT_DOTENV_PATH = Path("/etc/default/jobbergate-cli")
+JOBBERGATE_APPLICATION_SUPPORTED_FILES = {".py", ".yaml", ".j2", ".jinja2"}
 JOBBERGATE_APPLICATION_MODULE_FILE_NAME = "jobbergate.py"
 JOBBERGATE_APPLICATION_CONFIG_FILE_NAME = "jobbergate.yaml"
 TAR_NAME = "jobbergate.tar.gz"
diff --git a/jobbergate-cli/jobbergate_cli/schemas.py b/jobbergate-cli/jobbergate_cli/schemas.py
index 4e769f8a..d80b990e 100644
--- a/jobbergate-cli/jobbergate_cli/schemas.py
+++ b/jobbergate-cli/jobbergate_cli/schemas.py
@@ -61,11 +61,11 @@ class JobbergateContext(pydantic.BaseModel, arbitrary_types_allowed=True):
 
 class JobbergateConfig(pydantic.BaseModel):
     """
-    A data object desribing the config values needed in the "jobbergate_config" section of the
+    A data object describing the config values needed in the "jobbergate_config" section of the
     JobbergateApplicationConfig model.
     """
 
-    template_files: List[Path]
+    template_files: Optional[List[Path]]
     default_template: Optional[str] = None
     output_directory: Optional[Path] = None
     supporting_files_output_name: Optional[Dict[str, Any]] = None
@@ -79,7 +79,7 @@ class JobbergateConfig(pydantic.BaseModel):
 
 class JobbergateApplicationConfig(pydantic.BaseModel):
     """
-    A data object describing the config data needed to instantiate a JobbergateAppliation class.
+    A data object describing the config data needed to instantiate a JobbergateApplication class.
     """
 
     jobbergate_config: JobbergateConfig
@@ -96,11 +96,21 @@ class ApplicationResponse(pydantic.BaseModel, extra=pydantic.Extra.ignore):
     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
+    application_config: Optional[str] = None
+    application_source_file: Optional[str] = None
+    application_templates: Optional[Dict[str, str]] = None
+
+
+class JobScriptFiles(pydantic.BaseModel, extra=pydantic.Extra.ignore):
+    """
+    Model containing job-script files.
+    """
+
+    main_file_path: Path
+    files: Dict[Path, str]
 
 
 class JobScriptResponse(pydantic.BaseModel, extra=pydantic.Extra.ignore):
@@ -112,7 +122,7 @@ class JobScriptResponse(pydantic.BaseModel, extra=pydantic.Extra.ignore):
     application_id: int
     job_script_name: str
     job_script_description: Optional[str] = None
-    job_script_data_as_string: str
+    job_script_files: JobScriptFiles
     job_script_owner_email: str
     created_at: Optional[datetime] = None
     updated_at: Optional[datetime] = None
@@ -143,6 +153,7 @@ class JobScriptCreateRequestData(pydantic.BaseModel):
 
     application_id: int
     job_script_name: str
+    job_script_description: Optional[str]
     param_dict: Optional[JobbergateApplicationConfig] = None
     sbatch_params: Optional[List[Any]] = None
 
diff --git a/jobbergate-cli/jobbergate_cli/subapps/applications/app.py b/jobbergate-cli/jobbergate_cli/subapps/applications/app.py
index 367ae545..48b11206 100644
--- a/jobbergate-cli/jobbergate_cli/subapps/applications/app.py
+++ b/jobbergate-cli/jobbergate_cli/subapps/applications/app.py
@@ -3,32 +3,23 @@
 """
 
 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,
-)
+from jobbergate_cli.subapps.applications.tools import fetch_application_data, load_default_config, upload_application
 
 
 # TODO: move hidden field logic to the API
 HIDDEN_FIELDS = [
     "application_config",
-    "application_file",
+    "application_source_file",
+    "application_templates",
     "created_at",
     "updated_at",
 ]
@@ -140,37 +131,6 @@ def get_one(
     )
 
 
-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"/jobbergate/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(
@@ -203,11 +163,6 @@ def create(
     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
@@ -231,7 +186,7 @@ def create(
     if not successful_upload:
         terminal_message(
             f"""
-            The zipped application files could not be uploaded.
+            The application files could not be uploaded.
 
             Try running the `update` command including the application path to re-upload.
 
@@ -284,11 +239,6 @@ def update(
     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
@@ -308,11 +258,11 @@ def update(
     )
 
     if application_path is not None:
-        successful_upload = _upload_application(jg_ctx, application_path, id)
+        successful_upload = upload_application(jg_ctx, application_path, id)
         if not successful_upload:
             terminal_message(
                 f"""
-                The zipped application files could not be uploaded.
+                The application files could not be uploaded.
 
                 [yellow]If the problem persists, please contact [bold]{OV_CONTACT}[/bold]
                 for support and trouble-shooting[/yellow]
diff --git a/jobbergate-cli/jobbergate_cli/subapps/applications/tools.py b/jobbergate-cli/jobbergate_cli/subapps/applications/tools.py
index b078cd5f..0ead6179 100644
--- a/jobbergate-cli/jobbergate_cli/subapps/applications/tools.py
+++ b/jobbergate-cli/jobbergate_cli/subapps/applications/tools.py
@@ -2,84 +2,20 @@
 Provide tool functions for working with Application data.
 """
 
-import ast
+import contextlib
 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.constants import JOBBERGATE_APPLICATION_CONFIG, JOBBERGATE_APPLICATION_SUPPORTED_FILES
 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]:
@@ -89,50 +25,6 @@ def load_default_config() -> Dict[str, Any]:
     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,
@@ -196,6 +88,14 @@ def load_application_data(
     :param: app_data: A dictionary containing the application data
     :returns: A tuple containing the application config and the application module
     """
+    if not app_data.application_config:
+
+        raise Abort(
+            f"Fail to retrieve the application config file for id={app_data.id}",
+            subject="Application config is missing",
+            log_message="Application config is missing",
+        )
+
     try:
         app_config = load_application_config_from_source(app_data.application_config)
     except Exception as err:
@@ -208,12 +108,20 @@ def load_application_data(
             original_error=err,
         )
 
+    if not app_data.application_source_file:
+
+        raise Abort(
+            f"Fail to retrieve the application source file for id={app_data.id}",
+            subject="Application source file is missing",
+            log_message="Application source file is missing",
+        )
+
     try:
-        app_module = load_application_from_source(app_data.application_file, app_config)
+        app_module = load_application_from_source(app_data.application_source_file, app_config)
     except Exception as err:
         raise Abort(
             "The application source fetched from the API is not valid",
-            subject="Invalid application config",
+            subject="Invalid application module",
             support=True,
             log_message="Invalid application module",
             original_error=err,
@@ -222,6 +130,27 @@ def load_application_data(
     return (app_config, app_module)
 
 
+@contextlib.contextmanager
+def get_upload_files(application_path: pathlib.Path):
+    """
+    Context manager to build the ``files`` parameter.
+
+    Open the supplied file(s) and build a ``files`` param appropriate for using
+    multi-part file uploads with the client.
+    """
+    Abort.require_condition(application_path.is_dir(), f"Application directory {application_path} does not exist")
+
+    with contextlib.ExitStack() as stack:
+        yield [
+            (
+                "upload_files",
+                (path.name, stack.enter_context(open(path)), "text/plain"),
+            )
+            for path in application_path.rglob("*")
+            if path.is_file() and path.suffix in JOBBERGATE_APPLICATION_SUPPORTED_FILES
+        ]
+
+
 def upload_application(
     jg_ctx: JobbergateContext,
     application_path: pathlib.Path,
@@ -239,10 +168,10 @@ def upload_application(
     # 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)
+    with get_upload_files(pathlib.Path(application_path)) as upload_files:
+        logger.debug(
+            f"Preparing to upload {len(upload_files)} application files from {application_path}",
+        )
         response_code = cast(
             int,
             make_request(
@@ -252,7 +181,7 @@ def upload_application(
                 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")),
+                files=upload_files,
             ),
         )
         return response_code == 201
@@ -274,7 +203,7 @@ def load_application_from_source(app_source: str, app_config: JobbergateApplicat
     """
     Load the JobbergateApplication class from a text string containing the source file.
 
-    Creates the module in a temporary file and importins it with importlib.
+    Creates the module in a temporary file and imports it with importlib.
 
     Adapted from: https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly
 
diff --git a/jobbergate-cli/jobbergate_cli/subapps/job_scripts/app.py b/jobbergate-cli/jobbergate_cli/subapps/job_scripts/app.py
index 5b4bc174..8ee8611f 100644
--- a/jobbergate-cli/jobbergate_cli/subapps/job_scripts/app.py
+++ b/jobbergate-cli/jobbergate_cli/subapps/job_scripts/app.py
@@ -124,6 +124,10 @@ def create(
         None,
         help="The identifier of the application from which to create the job script.",
     ),
+    description: Optional[str] = typer.Option(
+        None,
+        help="Optional text describing the job script.",
+    ),
     sbatch_params: Optional[List[str]] = typer.Option(
         None,
         help="Optional parameter to submit raw sbatch parameters.",
@@ -162,6 +166,7 @@ def create(
         job_script_name=name,
         sbatch_params=sbatch_params,
         param_dict=app_config,
+        job_script_description=description,
     )
 
     supplied_params = validate_parameter_file(param_file) if param_file else dict()
@@ -231,15 +236,13 @@ def update(
         ...,
         help="The id of the job script to update",
     ),
-    job_script: str = typer.Option(
+    name: 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"}'
-        """,
+        help="New name of the job script to create.",
+    ),
+    description: Optional[str] = typer.Option(
+        None,
+        help="Optional new text describing the job script.",
     ),
 ):
     """
@@ -259,7 +262,10 @@ def update(
             expected_status=200,
             abort_message="Couldn't update job script",
             support=True,
-            json=dict(job_script_data_as_string=job_script),
+            json=dict(
+                job_script_name=name,
+                job_script_description=description,
+            ),
             response_model_cls=JobScriptResponse,
         ),
     )
diff --git a/jobbergate-cli/pyproject.toml b/jobbergate-cli/pyproject.toml
index 4ba234ce..3d847220 100644
--- a/jobbergate-cli/pyproject.toml
+++ b/jobbergate-cli/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "jobbergate-cli"
-version = "3.2.4-alpha.0"
+version = "3.2.4-alpha.3"
 description = "Jobbergate CLI Client"
 authors = ["Omnivector Solutions <info@omnivector.solutions>"]
 license = "MIT"
diff --git a/jobbergate-slurp/poetry.lock b/jobbergate-slurp/poetry.lock
index 98868151..9bc334a2 100644
--- a/jobbergate-slurp/poetry.lock
+++ b/jobbergate-slurp/poetry.lock
@@ -1,3 +1,20 @@
+[[package]]
+name = "alembic"
+version = "1.8.1"
+description = "A database migration tool for SQLAlchemy."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+importlib-metadata = {version = "*", markers = "python_version < \"3.9\""}
+importlib-resources = {version = "*", markers = "python_version < \"3.9\""}
+Mako = "*"
+SQLAlchemy = ">=1.3.0"
+
+[package.extras]
+tz = ["python-dateutil"]
+
 [[package]]
 name = "anyio"
 version = "3.3.4"
@@ -15,6 +32,78 @@ doc = ["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", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"]
 trio = ["trio (>=0.16)"]
 
+[[package]]
+name = "armasec"
+version = "0.11.0"
+description = "Injectable FastAPI auth via OIDC"
+category = "main"
+optional = false
+python-versions = ">=3.6.2,<4.0.0"
+
+[package.dependencies]
+auto-name-enum = ">=2,<3"
+fastapi = ">=0.68.0,<0.69.0"
+httpx = ">=0,<1"
+py-buzz = ">=3.1.0,<4.0.0"
+pytest = ">=6.2.5,<7.0.0"
+python-jose = {version = ">=3.2.0,<4.0.0", extras = ["cryptography"]}
+respx = ">=0.17.1,<0.18.0"
+snick = ">=1.3.0,<2.0.0"
+
+[[package]]
+name = "asgiref"
+version = "3.5.2"
+description = "ASGI specs, helper code, and adapters"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.extras]
+tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"]
+
+[[package]]
+name = "asyncpg"
+version = "0.22.0"
+description = "An asyncio PostgreSQL driver"
+category = "main"
+optional = false
+python-versions = ">=3.5.0"
+
+[package.extras]
+dev = ["Cython (>=0.29.20,<0.30.0)", "pytest (>=3.6.0)", "Sphinx (>=1.7.3,<1.8.0)", "sphinxcontrib-asyncio (>=0.2.0,<0.3.0)", "sphinx-rtd-theme (>=0.2.4,<0.3.0)", "pycodestyle (>=2.5.0,<2.6.0)", "flake8 (>=3.7.9,<3.8.0)", "uvloop (>=0.14.0,<0.15.0)"]
+docs = ["Sphinx (>=1.7.3,<1.8.0)", "sphinxcontrib-asyncio (>=0.2.0,<0.3.0)", "sphinx-rtd-theme (>=0.2.4,<0.3.0)"]
+test = ["pycodestyle (>=2.5.0,<2.6.0)", "flake8 (>=3.7.9,<3.8.0)", "uvloop (>=0.14.0,<0.15.0)"]
+
+[[package]]
+name = "atomicwrites"
+version = "1.4.1"
+description = "Atomic file writes."
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "attrs"
+version = "22.1.0"
+description = "Classes Without Boilerplate"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[package.extras]
+tests_no_zope = ["cloudpickle", "pytest-mypy-plugins", "mypy (>=0.900,!=0.940)", "pytest (>=4.3.0)", "pympler", "hypothesis", "coverage[toml] (>=5.0.2)"]
+tests = ["cloudpickle", "zope.interface", "pytest-mypy-plugins", "mypy (>=0.900,!=0.940)", "pytest (>=4.3.0)", "pympler", "hypothesis", "coverage[toml] (>=5.0.2)"]
+docs = ["sphinx-notfound-page", "zope.interface", "sphinx", "furo"]
+dev = ["cloudpickle", "pre-commit", "sphinx-notfound-page", "sphinx", "furo", "zope.interface", "pytest-mypy-plugins", "mypy (>=0.900,!=0.940)", "pytest (>=4.3.0)", "pympler", "hypothesis", "coverage[toml] (>=5.0.2)"]
+
+[[package]]
+name = "auto-name-enum"
+version = "2.0.0"
+description = "String-based Enum class that automatically assigns values"
+category = "main"
+optional = false
+python-versions = ">=3.6.2,<4.0.0"
+
 [[package]]
 name = "backports.zoneinfo"
 version = "0.2.1"
@@ -50,35 +139,35 @@ uvloop = ["uvloop (>=0.15.2)"]
 
 [[package]]
 name = "boto3"
-version = "1.20.8"
+version = "1.24.50"
 description = "The AWS SDK for Python"
 category = "main"
 optional = false
-python-versions = ">= 3.6"
+python-versions = ">= 3.7"
 
 [package.dependencies]
-botocore = ">=1.23.8,<1.24.0"
-jmespath = ">=0.7.1,<1.0.0"
-s3transfer = ">=0.5.0,<0.6.0"
+botocore = ">=1.27.50,<1.28.0"
+jmespath = ">=0.7.1,<2.0.0"
+s3transfer = ">=0.6.0,<0.7.0"
 
 [package.extras]
 crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
 
 [[package]]
 name = "botocore"
-version = "1.23.8"
+version = "1.27.50"
 description = "Low-level, data-driven core of boto 3."
 category = "main"
 optional = false
-python-versions = ">= 3.6"
+python-versions = ">= 3.7"
 
 [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.8)"]
 
 [[package]]
 name = "certifi"
@@ -88,6 +177,17 @@ category = "main"
 optional = false
 python-versions = "*"
 
+[[package]]
+name = "cffi"
+version = "1.15.1"
+description = "Foreign Function Interface for Python calling C code."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+pycparser = "*"
+
 [[package]]
 name = "cli-helpers"
 version = "2.2.1"
@@ -135,18 +235,114 @@ python-versions = "*"
 six = "*"
 
 [[package]]
-name = "deprecated"
-version = "1.2.13"
-description = "Python @deprecated decorator to deprecate old python classes, functions or methods."
+name = "cryptography"
+version = "37.0.4"
+description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
 category = "main"
 optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=3.6"
+
+[package.dependencies]
+cffi = ">=1.12"
+
+[package.extras]
+docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"]
+docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"]
+pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"]
+sdist = ["setuptools_rust (>=0.11.4)"]
+ssh = ["bcrypt (>=3.1.5)"]
+test = ["pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"]
+
+[[package]]
+name = "databases"
+version = "0.5.5"
+description = "Async database support for Python."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+asyncpg = {version = "*", optional = true, markers = "extra == \"postgresql\""}
+sqlalchemy = ">=1.4,<1.5"
+
+[package.extras]
+mysql = ["aiomysql"]
+mysql_asyncmy = ["asyncmy"]
+postgresql = ["asyncpg"]
+postgresql_aiopg = ["aiopg"]
+sqlite = ["aiosqlite"]
+
+[[package]]
+name = "dnspython"
+version = "2.2.1"
+description = "DNS toolkit"
+category = "main"
+optional = false
+python-versions = ">=3.6,<4.0"
+
+[package.extras]
+dnssec = ["cryptography (>=2.6,<37.0)"]
+curio = ["curio (>=1.2,<2.0)", "sniffio (>=1.1,<2.0)"]
+doh = ["h2 (>=4.1.0)", "httpx (>=0.21.1)", "requests (>=2.23.0,<3.0.0)", "requests-toolbelt (>=0.9.1,<0.10.0)"]
+idna = ["idna (>=2.1,<4.0)"]
+trio = ["trio (>=0.14,<0.20)"]
+wmi = ["wmi (>=1.5.1,<2.0.0)"]
+
+[[package]]
+name = "ecdsa"
+version = "0.18.0"
+description = "ECDSA cryptographic signature library (pure python)"
+category = "main"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
 
 [package.dependencies]
-wrapt = ">=1.10,<2"
+six = ">=1.9.0"
 
 [package.extras]
-dev = ["tox", "bump2version (<1)", "sphinx (<2)", "importlib-metadata (<3)", "importlib-resources (<4)", "configparser (<5)", "sphinxcontrib-websupport (<2)", "zipp (<2)", "PyTest (<5)", "PyTest-Cov (<2.6)", "pytest", "pytest-cov"]
+gmpy = ["gmpy"]
+gmpy2 = ["gmpy2"]
+
+[[package]]
+name = "email-validator"
+version = "1.2.1"
+description = "A robust email syntax and deliverability validation library."
+category = "main"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+
+[package.dependencies]
+dnspython = ">=1.15.0"
+idna = ">=2.0.0"
+
+[[package]]
+name = "fastapi"
+version = "0.68.2"
+description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
+category = "main"
+optional = false
+python-versions = ">=3.6.1"
+
+[package.dependencies]
+pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0"
+starlette = "0.14.2"
+
+[package.extras]
+all = ["requests (>=2.24.0,<3.0.0)", "aiofiles (>=0.5.0,<0.8.0)", "jinja2 (>=2.11.2,<3.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<2.0.0)", "pyyaml (>=5.3.1,<6.0.0)", "graphene (>=2.1.8,<3.0.0)", "ujson (>=4.0.1,<5.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)", "async_exit_stack (>=1.0.1,<2.0.0)", "async_generator (>=1.10,<2.0.0)"]
+dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)", "graphene (>=2.1.8,<3.0.0)"]
+doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=7.1.9,<8.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer-cli (>=0.0.12,<0.0.13)", "pyyaml (>=5.3.1,<6.0.0)"]
+test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "pytest-asyncio (>=0.14.0,<0.16.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==21.9b0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,<5.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "aiofiles (>=0.5.0,<0.8.0)", "flask (>=1.1.2,<2.0.0)", "async_exit_stack (>=1.0.1,<2.0.0)", "async_generator (>=1.10,<2.0.0)", "types-ujson (==0.1.1)", "types-orjson (==3.6.0)", "types-dataclasses (==0.1.7)"]
+
+[[package]]
+name = "file-storehouse"
+version = "0.3.4"
+description = "Manage files in bulk quantity using a friendly dict-like interface"
+category = "main"
+optional = false
+python-versions = ">=3.8,<4.0"
+
+[package.dependencies]
+boto3 = ">=1.24.36,<2.0.0"
 
 [[package]]
 name = "flake8"
@@ -161,6 +357,17 @@ mccabe = ">=0.6.0,<0.7.0"
 pycodestyle = ">=2.8.0,<2.9.0"
 pyflakes = ">=2.4.0,<2.5.0"
 
+[[package]]
+name = "greenlet"
+version = "1.1.2"
+description = "Lightweight in-process concurrent programming"
+category = "main"
+optional = false
+python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*"
+
+[package.extras]
+docs = ["sphinx"]
+
 [[package]]
 name = "h11"
 version = "0.12.0"
@@ -216,12 +423,57 @@ optional = false
 python-versions = ">=3.5"
 
 [[package]]
-name = "inflection"
-version = "0.5.1"
-description = "A port of Ruby on Rails inflector to Python"
+name = "importlib-metadata"
+version = "4.12.0"
+description = "Read metadata from Python packages"
 category = "main"
 optional = false
-python-versions = ">=3.5"
+python-versions = ">=3.7"
+
+[package.dependencies]
+zipp = ">=0.5"
+
+[package.extras]
+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.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"]
+
+[[package]]
+name = "importlib-resources"
+version = "5.9.0"
+description = "Read resources from Python packages"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""}
+
+[package.extras]
+docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"]
+testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
+
+[[package]]
+name = "iniconfig"
+version = "1.1.1"
+description = "iniconfig: brain-dead simple config-ini parsing"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "jinja2"
+version = "3.1.2"
+description = "A very fast and expressive template engine."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+MarkupSafe = ">=2.0"
+
+[package.extras]
+i18n = ["Babel (>=2.7)"]
 
 [[package]]
 name = "jmespath"
@@ -231,6 +483,40 @@ category = "main"
 optional = false
 python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
 
+[[package]]
+name = "jobbergate-api"
+version = "3.2.3"
+description = "Jobbergate API"
+category = "main"
+optional = false
+python-versions = "^3.8"
+develop = false
+
+[package.dependencies]
+alembic = "^1.7.5"
+armasec = "^0.11.0"
+asyncpg = "^0.22.0"
+boto3 = "^1.17.51"
+databases = {version = "^0.5.5", extras = ["postgresql"]}
+email_validator = "^1.1.0"
+fastapi = "^0.68.0"
+file-storehouse = "0.3.4"
+Jinja2 = "^3.0.3"
+loguru = "^0.6.0"
+passlib = "^1.7.2"
+python-dotenv = "^0.20.0"
+python-multipart = "^0.0.5"
+PyYAML = "^6.0"
+sendgrid = "^6.9.7"
+sentry-sdk = "^1.5.0"
+typer = "^0.4.0"
+uvicorn = "^0.15.0"
+yarl = "^1.7.2"
+
+[package.source]
+type = "directory"
+url = "../jobbergate-api"
+
 [[package]]
 name = "loguru"
 version = "0.6.0"
@@ -246,6 +532,30 @@ win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
 [package.extras]
 dev = ["colorama (>=0.3.4)", "docutils (==0.16)", "flake8 (>=3.7.7)", "tox (>=3.9.0)", "pytest (>=4.6.2)", "pytest-cov (>=2.7.1)", "black (>=19.10b0)", "isort (>=5.1.1)", "Sphinx (>=4.1.1)", "sphinx-autobuild (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)"]
 
+[[package]]
+name = "mako"
+version = "1.2.1"
+description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+MarkupSafe = ">=0.9.2"
+
+[package.extras]
+babel = ["babel"]
+lingua = ["lingua"]
+testing = ["pytest"]
+
+[[package]]
+name = "markupsafe"
+version = "2.1.1"
+description = "Safely add untrusted strings to HTML/XML markup."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
 [[package]]
 name = "mccabe"
 version = "0.6.1"
@@ -254,6 +564,14 @@ category = "dev"
 optional = false
 python-versions = "*"
 
+[[package]]
+name = "multidict"
+version = "6.0.2"
+description = "multidict implementation"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
 [[package]]
 name = "mypy-extensions"
 version = "0.4.3"
@@ -262,6 +580,31 @@ category = "dev"
 optional = false
 python-versions = "*"
 
+[[package]]
+name = "packaging"
+version = "21.3"
+description = "Core utilities for Python packages"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
+
+[[package]]
+name = "passlib"
+version = "1.7.4"
+description = "comprehensive password hashing framework supporting over 30 schemes"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.extras]
+argon2 = ["argon2-cffi (>=18.2.0)"]
+bcrypt = ["bcrypt (>=3.1.0)"]
+build_docs = ["sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)", "cloud-sptheme (>=1.10.1)"]
+totp = ["cryptography"]
+
 [[package]]
 name = "pathspec"
 version = "0.9.0"
@@ -330,6 +673,18 @@ python-versions = ">=3.7"
 docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"]
 test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"]
 
+[[package]]
+name = "pluggy"
+version = "1.0.0"
+description = "plugin and hook calling mechanisms for python"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
 [[package]]
 name = "pprintpp"
 version = "0.4.0"
@@ -376,17 +731,29 @@ category = "main"
 optional = false
 python-versions = ">=3.6"
 
+[[package]]
+name = "py"
+version = "1.11.0"
+description = "library with cross-python path, ini-parsing, io, code, log facilities"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
 [[package]]
 name = "py-buzz"
-version = "2.1.3"
-description = "\"That's not flying, it's falling with style: Exceptions with extras\""
+version = "3.2.1"
+description = "\"That's not flying, it's falling with style\": Exceptions with extras"
 category = "main"
 optional = false
-python-versions = ">=3.6,<4.0"
+python-versions = ">=3.6.2,<4.0.0"
 
-[package.dependencies]
-deprecated = ">=1.2,<2.0"
-inflection = ">=0.5.1,<0.6.0"
+[[package]]
+name = "pyasn1"
+version = "0.4.8"
+description = "ASN.1 types and codecs"
+category = "main"
+optional = false
+python-versions = "*"
 
 [[package]]
 name = "pycodestyle"
@@ -396,6 +763,14 @@ category = "dev"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
+[[package]]
+name = "pycparser"
+version = "2.21"
+description = "C parser in Python"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
 [[package]]
 name = "pydantic"
 version = "1.8.2"
@@ -427,6 +802,17 @@ category = "main"
 optional = false
 python-versions = ">=3.5"
 
+[[package]]
+name = "pyparsing"
+version = "3.0.9"
+description = "pyparsing module - Classes and methods to define and execute parsing grammars"
+category = "main"
+optional = false
+python-versions = ">=3.6.8"
+
+[package.extras]
+diagrams = ["railroad-diagrams", "jinja2"]
+
 [[package]]
 name = "pyproject-flake8"
 version = "0.0.1a4"
@@ -439,6 +825,27 @@ python-versions = "*"
 flake8 = "*"
 tomli = {version = "*", markers = "python_version < \"3.11\""}
 
+[[package]]
+name = "pytest"
+version = "6.2.5"
+description = "pytest: simple powerful testing with Python"
+category = "main"
+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"
+toml = "*"
+
+[package.extras]
+testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
+
 [[package]]
 name = "python-dateutil"
 version = "2.8.2"
@@ -452,7 +859,7 @@ six = ">=1.5"
 
 [[package]]
 name = "python-dotenv"
-version = "0.19.1"
+version = "0.20.0"
 description = "Read key-value pairs from a .env file and set them as environment variables"
 category = "main"
 optional = false
@@ -461,6 +868,44 @@ python-versions = ">=3.5"
 [package.extras]
 cli = ["click (>=5.0)"]
 
+[[package]]
+name = "python-http-client"
+version = "3.3.7"
+description = "HTTP REST client, simplified for Python"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "python-jose"
+version = "3.3.0"
+description = "JOSE implementation in Python"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"cryptography\""}
+ecdsa = "!=0.15"
+pyasn1 = "*"
+rsa = "*"
+
+[package.extras]
+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 = "python-multipart"
+version = "0.0.5"
+description = "A streaming multipart parser for Python"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+six = ">=1.4.0"
+
 [[package]]
 name = "pytzdata"
 version = "2020.1"
@@ -469,6 +914,25 @@ category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
+[[package]]
+name = "pyyaml"
+version = "6.0"
+description = "YAML parser and emitter for Python"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "respx"
+version = "0.17.1"
+description = "A utility for mocking out the Python HTTPX and HTTP Core libraries."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+httpx = ">=0.18.0"
+
 [[package]]
 name = "rfc3986"
 version = "1.5.0"
@@ -483,13 +947,24 @@ idna = {version = "*", optional = true, markers = "extra == \"idna2008\""}
 [package.extras]
 idna2008 = ["idna"]
 
+[[package]]
+name = "rsa"
+version = "4.9"
+description = "Pure-Python RSA implementation"
+category = "main"
+optional = false
+python-versions = ">=3.6,<4"
+
+[package.dependencies]
+pyasn1 = ">=0.1.3"
+
 [[package]]
 name = "s3transfer"
-version = "0.5.0"
+version = "0.6.0"
 description = "An Amazon S3 Transfer Manager"
 category = "main"
 optional = false
-python-versions = ">= 3.6"
+python-versions = ">= 3.7"
 
 [package.dependencies]
 botocore = ">=1.12.36,<2.0a.0"
@@ -497,6 +972,50 @@ botocore = ">=1.12.36,<2.0a.0"
 [package.extras]
 crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"]
 
+[[package]]
+name = "sendgrid"
+version = "6.9.7"
+description = "Twilio SendGrid library for Python"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[package.dependencies]
+python-http-client = ">=3.2.1"
+starkbank-ecdsa = ">=2.0.1"
+
+[[package]]
+name = "sentry-sdk"
+version = "1.9.0"
+description = "Python client for Sentry (https://sentry.io)"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+certifi = "*"
+urllib3 = ">=1.10.0"
+
+[package.extras]
+aiohttp = ["aiohttp (>=3.5)"]
+beam = ["apache-beam (>=2.12)"]
+bottle = ["bottle (>=0.12.13)"]
+celery = ["celery (>=3)"]
+chalice = ["chalice (>=1.16.0)"]
+django = ["django (>=1.8)"]
+falcon = ["falcon (>=1.4)"]
+fastapi = ["fastapi (>=0.79.0)"]
+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)"]
+starlette = ["starlette (>=0.19.1)"]
+tornado = ["tornado (>=5)"]
+
 [[package]]
 name = "setproctitle"
 version = "1.2.2"
@@ -518,11 +1037,11 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
 
 [[package]]
 name = "snick"
-version = "1.0.0"
+version = "1.3.0"
 description = ""
 category = "main"
 optional = false
-python-versions = ">=3.7,<4.0"
+python-versions = ">=3.6.2,<4.0.0"
 
 [package.dependencies]
 pprintpp = ">=0.4.0,<0.5.0"
@@ -535,6 +1054,38 @@ category = "main"
 optional = false
 python-versions = ">=3.5"
 
+[[package]]
+name = "sqlalchemy"
+version = "1.4.40"
+description = "Database Abstraction Library"
+category = "main"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
+
+[package.dependencies]
+greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"}
+
+[package.extras]
+aiomysql = ["greenlet (!=0.4.17)", "aiomysql"]
+aiosqlite = ["typing_extensions (!=3.10.0.1)", "greenlet (!=0.4.17)", "aiosqlite"]
+asyncio = ["greenlet (!=0.4.17)"]
+asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3,!=0.2.4)"]
+mariadb_connector = ["mariadb (>=1.0.1,!=1.1.2)"]
+mssql = ["pyodbc"]
+mssql_pymssql = ["pymssql"]
+mssql_pyodbc = ["pyodbc"]
+mypy = ["sqlalchemy2-stubs", "mypy (>=0.910)"]
+mysql = ["mysqlclient (>=1.4.0,<2)", "mysqlclient (>=1.4.0)"]
+mysql_connector = ["mysql-connector-python"]
+oracle = ["cx_oracle (>=7,<8)", "cx_oracle (>=7)"]
+postgresql = ["psycopg2 (>=2.7)"]
+postgresql_asyncpg = ["greenlet (!=0.4.17)", "asyncpg"]
+postgresql_pg8000 = ["pg8000 (>=1.16.6,!=1.29.0)"]
+postgresql_psycopg2binary = ["psycopg2-binary"]
+postgresql_psycopg2cffi = ["psycopg2cffi"]
+pymysql = ["pymysql (<1)", "pymysql"]
+sqlcipher = ["sqlcipher3-binary"]
+
 [[package]]
 name = "sqlparse"
 version = "0.4.2"
@@ -543,6 +1094,25 @@ category = "main"
 optional = false
 python-versions = ">=3.5"
 
+[[package]]
+name = "starkbank-ecdsa"
+version = "2.0.3"
+description = "A lightweight and fast pure python ECDSA library"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "starlette"
+version = "0.14.2"
+description = "The little ASGI library that shines."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+full = ["aiofiles", "graphene", "itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests"]
+
 [[package]]
 name = "tabulate"
 version = "0.8.10"
@@ -557,6 +1127,14 @@ wcwidth = {version = "*", optional = true, markers = "extra == \"widechars\""}
 [package.extras]
 widechars = ["wcwidth"]
 
+[[package]]
+name = "toml"
+version = "0.10.2"
+description = "Python Library for Tom's Obvious, Minimal Language"
+category = "main"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+
 [[package]]
 name = "tomli"
 version = "2.0.1"
@@ -603,6 +1181,22 @@ brotli = ["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)"]
 
+[[package]]
+name = "uvicorn"
+version = "0.15.0"
+description = "The lightning-fast ASGI server."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+asgiref = ">=3.4.0"
+click = ">=7.0"
+h11 = ">=0.8"
+
+[package.extras]
+standard = ["websockets (>=9.1)", "httptools (>=0.2.0,<0.3.0)", "watchgod (>=0.6)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"]
+
 [[package]]
 name = "wcwidth"
 version = "0.2.5"
@@ -623,23 +1217,71 @@ python-versions = ">=3.5"
 dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"]
 
 [[package]]
-name = "wrapt"
-version = "1.13.3"
-description = "Module for decorators, wrappers and monkey patching."
+name = "yarl"
+version = "1.8.1"
+description = "Yet another URL library"
 category = "main"
 optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+python-versions = ">=3.7"
+
+[package.dependencies]
+idna = ">=2.0"
+multidict = ">=4.0"
+
+[[package]]
+name = "zipp"
+version = "3.8.1"
+description = "Backport of pathlib-compatible object wrapper for zip files"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.extras]
+docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"]
+testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
 
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.8"
-content-hash = "e8d60f7790b4f61ad8b7f9eaf5ad59b6901b988686f1064cf635ebaaf401eb6e"
+content-hash = "23b294333a456159ab814ef934ff080df6f9ba4be13a286c9ce90b86fe79b213"
 
 [metadata.files]
+alembic = [
+    {file = "alembic-1.8.1-py3-none-any.whl", hash = "sha256:0a024d7f2de88d738d7395ff866997314c837be6104e90c5724350313dee4da4"},
+    {file = "alembic-1.8.1.tar.gz", hash = "sha256:cd0b5e45b14b706426b833f06369b9a6d5ee03f826ec3238723ce8caaf6e5ffa"},
+]
 anyio = [
     {file = "anyio-3.3.4-py3-none-any.whl", hash = "sha256:4fd09a25ab7fa01d34512b7249e366cd10358cdafc95022c7ff8c8f8a5026d66"},
     {file = "anyio-3.3.4.tar.gz", hash = "sha256:67da67b5b21f96b9d3d65daa6ea99f5d5282cb09f50eb4456f8fb51dffefc3ff"},
 ]
+armasec = []
+asgiref = [
+    {file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"},
+    {file = "asgiref-3.5.2.tar.gz", hash = "sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"},
+]
+asyncpg = [
+    {file = "asyncpg-0.22.0-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:ccd75cfb4710c7e8debc19516e2e1d4c9863cce3f7a45a3822980d04b16f4fdd"},
+    {file = "asyncpg-0.22.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:3af9a8511569983481b5cf94db17b7cbecd06b5398aac9c82e4acb69bb1f4090"},
+    {file = "asyncpg-0.22.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:d1cb6e5b58a4e017335f2a1886e153a32bd213ffa9f7129ee5aced2a7210fa3c"},
+    {file = "asyncpg-0.22.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0f4604a88386d68c46bf7b50c201a9718515b0d2df6d5e9ce024d78ed0f7189c"},
+    {file = "asyncpg-0.22.0-cp36-cp36m-win_amd64.whl", hash = "sha256:b37efafbbec505287bd1499a88f4b59ff2b470709a1d8f7e4db198d3e2c5a2c4"},
+    {file = "asyncpg-0.22.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:1d3efdec14f3fbcc665b77619f8b420564f98b89632a21694be2101dafa6bcf2"},
+    {file = "asyncpg-0.22.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f1df7cfd12ef484210717e7827cc2d4d550b16a1b4dd4566c93914c7a2259352"},
+    {file = "asyncpg-0.22.0-cp37-cp37m-win_amd64.whl", hash = "sha256:1f514b13bc54bde65db6cd1d0832ae27f21093e3cb66f741e078fab77768971c"},
+    {file = "asyncpg-0.22.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:82e23ba5b37c0c7ee96f290a95cbf9815b2d29b302e8b9c4af1de9b7759fd27b"},
+    {file = "asyncpg-0.22.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:062e4ff80e68fe56066c44a8c51989a98785904bf86f49058a242a5887be6ce3"},
+    {file = "asyncpg-0.22.0-cp38-cp38-win_amd64.whl", hash = "sha256:e7a67fb0244e4a5b3baaa40092d0efd642da032b5e891d75947dab993b47d925"},
+    {file = "asyncpg-0.22.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:1bbe5e829de506c743cbd5240b3722e487c53669a5f1e159abcc3b92a64a985e"},
+    {file = "asyncpg-0.22.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:2cb730241dfe650b9626eae00490cca4cfeb00871ed8b8f389f3a4507b328683"},
+    {file = "asyncpg-0.22.0-cp39-cp39-win_amd64.whl", hash = "sha256:2e3875c82ae609b21e562e6befdc35e52c4290e49d03e7529275d59a0595ca97"},
+    {file = "asyncpg-0.22.0.tar.gz", hash = "sha256:348ad471d9bdd77f0609a00c860142f47c81c9123f4064d13d65c8569415d802"},
+]
+atomicwrites = []
+attrs = [
+    {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"},
+    {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"},
+]
+auto-name-enum = []
 "backports.zoneinfo" = [
     {file = "backports.zoneinfo-0.2.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc"},
     {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722"},
@@ -684,17 +1326,18 @@ black = [
     {file = "black-22.3.0.tar.gz", hash = "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79"},
 ]
 boto3 = [
-    {file = "boto3-1.20.8-py3-none-any.whl", hash = "sha256:c0ac23cc36dc484edd1edd28903b5712cb07507af1ae19b2e8d6db176416d9e2"},
-    {file = "boto3-1.20.8.tar.gz", hash = "sha256:81ebdcabc534a52e2b7a2bfcbe1a1d7f1e34f028f7fe1cb16ccd80e34cea867a"},
+    {file = "boto3-1.24.50-py3-none-any.whl", hash = "sha256:c312c6ffb0c4eb94c6be2ab4471ca18ebcadc4a3abe048d1c9b437b7c6f1af10"},
+    {file = "boto3-1.24.50.tar.gz", hash = "sha256:28fefb77fb85b521a87ffd932621b14aabf4bb65ec4f9d9ce7399d093b2ad8bd"},
 ]
 botocore = [
-    {file = "botocore-1.23.8-py3-none-any.whl", hash = "sha256:a0c7cfea155a0202ab197a016736dd4e6a26f9e416bdd9cdd2c9a3fb88ffa5a8"},
-    {file = "botocore-1.23.8.tar.gz", hash = "sha256:ae4ed9666199020a9e53c3d3efc0a7d417315cd2313b70cb013282afe70ac358"},
+    {file = "botocore-1.27.50-py3-none-any.whl", hash = "sha256:697b76a58cfafa8ccbadcbf003efc693cf52170e562a8e2cfda9162d7d5aed20"},
+    {file = "botocore-1.27.50.tar.gz", hash = "sha256:0a3e2eef7a60c20e46be47eaf45cf5ea7a946aecdb68d829886d4f2506ac657c"},
 ]
 certifi = [
     {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"},
     {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"},
 ]
+cffi = []
 cli-helpers = [
     {file = "cli_helpers-2.2.1-py3-none-any.whl", hash = "sha256:e1a67c6e2cd95857fe50f7640549c65af8df5c4d7666af4e6d8c8ee7dbb694b4"},
     {file = "cli_helpers-2.2.1.tar.gz", hash = "sha256:0ccc1cfcda1ac64dc7ed83d7013055cf19e5979d29e56c21f3b692de01555aae"},
@@ -710,14 +1353,92 @@ colorama = [
 configobj = [
     {file = "configobj-5.0.6.tar.gz", hash = "sha256:a2f5650770e1c87fb335af19a9b7eb73fc05ccf22144eb68db7d00cd2bcb0902"},
 ]
-deprecated = [
-    {file = "Deprecated-1.2.13-py2.py3-none-any.whl", hash = "sha256:64756e3e14c8c5eea9795d93c524551432a0be75629f8f29e67ab8caf076c76d"},
-    {file = "Deprecated-1.2.13.tar.gz", hash = "sha256:43ac5335da90c31c24ba028af536a91d41d53f9e6901ddb021bcc572ce44e38d"},
+cryptography = []
+databases = [
+    {file = "databases-0.5.5-py3-none-any.whl", hash = "sha256:97d9b9647216d1ab53ca61c059412b5c7b6e1f0bf8ce985477982ebcc7f278f3"},
+    {file = "databases-0.5.5.tar.gz", hash = "sha256:02c6b016c1c951c21cca281dc8e2e002c60dc44026c0084aabbd8c37514aeb37"},
+]
+dnspython = [
+    {file = "dnspython-2.2.1-py3-none-any.whl", hash = "sha256:a851e51367fb93e9e1361732c1d60dab63eff98712e503ea7d92e6eccb109b4f"},
+    {file = "dnspython-2.2.1.tar.gz", hash = "sha256:0f7569a4a6ff151958b64304071d370daa3243d15941a7beedf0c9fe5105603e"},
+]
+ecdsa = [
+    {file = "ecdsa-0.18.0-py2.py3-none-any.whl", hash = "sha256:80600258e7ed2f16b9aa1d7c295bd70194109ad5a30fdee0eaeefef1d4c559dd"},
+    {file = "ecdsa-0.18.0.tar.gz", hash = "sha256:190348041559e21b22a1d65cee485282ca11a6f81d503fddb84d5017e9ed1e49"},
+]
+email-validator = [
+    {file = "email_validator-1.2.1-py2.py3-none-any.whl", hash = "sha256:c8589e691cf73eb99eed8d10ce0e9cbb05a0886ba920c8bcb7c82873f4c5789c"},
+    {file = "email_validator-1.2.1.tar.gz", hash = "sha256:6757aea012d40516357c0ac2b1a4c31219ab2f899d26831334c5d069e8b6c3d8"},
+]
+fastapi = [
+    {file = "fastapi-0.68.2-py3-none-any.whl", hash = "sha256:36bcdd3dbea87c586061005e4a40b9bd0145afd766655b4e0ec1d8870b32555c"},
+    {file = "fastapi-0.68.2.tar.gz", hash = "sha256:38526fc46bda73f7ec92033952677323c16061e70a91d15c95f18b11895da494"},
+]
+file-storehouse = [
+    {file = "file-storehouse-0.3.4.tar.gz", hash = "sha256:94a9b843b5334392da8d9a9ad312a9e1218afce962b84880413cecccb3e17fb9"},
+    {file = "file_storehouse-0.3.4-py3-none-any.whl", hash = "sha256:6436c2c132d4f93dfe85900ffb0d437650d8835f2d5d11ca866ea0c5c4f6503a"},
 ]
 flake8 = [
     {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"},
     {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"},
 ]
+greenlet = [
+    {file = "greenlet-1.1.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6"},
+    {file = "greenlet-1.1.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:aec52725173bd3a7b56fe91bc56eccb26fbdff1386ef123abb63c84c5b43b63a"},
+    {file = "greenlet-1.1.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:833e1551925ed51e6b44c800e71e77dacd7e49181fdc9ac9a0bf3714d515785d"},
+    {file = "greenlet-1.1.2-cp27-cp27m-win32.whl", hash = "sha256:aa5b467f15e78b82257319aebc78dd2915e4c1436c3c0d1ad6f53e47ba6e2713"},
+    {file = "greenlet-1.1.2-cp27-cp27m-win_amd64.whl", hash = "sha256:40b951f601af999a8bf2ce8c71e8aaa4e8c6f78ff8afae7b808aae2dc50d4c40"},
+    {file = "greenlet-1.1.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:95e69877983ea39b7303570fa6760f81a3eec23d0e3ab2021b7144b94d06202d"},
+    {file = "greenlet-1.1.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:356b3576ad078c89a6107caa9c50cc14e98e3a6c4874a37c3e0273e4baf33de8"},
+    {file = "greenlet-1.1.2-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8639cadfda96737427330a094476d4c7a56ac03de7265622fcf4cfe57c8ae18d"},
+    {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e5306482182170ade15c4b0d8386ded995a07d7cc2ca8f27958d34d6736497"},
+    {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6a36bb9474218c7a5b27ae476035497a6990e21d04c279884eb10d9b290f1b1"},
+    {file = "greenlet-1.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abb7a75ed8b968f3061327c433a0fbd17b729947b400747c334a9c29a9af6c58"},
+    {file = "greenlet-1.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b336501a05e13b616ef81ce329c0e09ac5ed8c732d9ba7e3e983fcc1a9e86965"},
+    {file = "greenlet-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:14d4f3cd4e8b524ae9b8aa567858beed70c392fdec26dbdb0a8a418392e71708"},
+    {file = "greenlet-1.1.2-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:17ff94e7a83aa8671a25bf5b59326ec26da379ace2ebc4411d690d80a7fbcf23"},
+    {file = "greenlet-1.1.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9f3cba480d3deb69f6ee2c1825060177a22c7826431458c697df88e6aeb3caee"},
+    {file = "greenlet-1.1.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c"},
+    {file = "greenlet-1.1.2-cp35-cp35m-win32.whl", hash = "sha256:7cbd7574ce8e138bda9df4efc6bf2ab8572c9aff640d8ecfece1b006b68da963"},
+    {file = "greenlet-1.1.2-cp35-cp35m-win_amd64.whl", hash = "sha256:903bbd302a2378f984aef528f76d4c9b1748f318fe1294961c072bdc7f2ffa3e"},
+    {file = "greenlet-1.1.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:049fe7579230e44daef03a259faa24511d10ebfa44f69411d99e6a184fe68073"},
+    {file = "greenlet-1.1.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:dd0b1e9e891f69e7675ba5c92e28b90eaa045f6ab134ffe70b52e948aa175b3c"},
+    {file = "greenlet-1.1.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7418b6bfc7fe3331541b84bb2141c9baf1ec7132a7ecd9f375912eca810e714e"},
+    {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9d29ca8a77117315101425ec7ec2a47a22ccf59f5593378fc4077ac5b754fce"},
+    {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21915eb821a6b3d9d8eefdaf57d6c345b970ad722f856cd71739493ce003ad08"},
+    {file = "greenlet-1.1.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eff9d20417ff9dcb0d25e2defc2574d10b491bf2e693b4e491914738b7908168"},
+    {file = "greenlet-1.1.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b8c008de9d0daba7b6666aa5bbfdc23dcd78cafc33997c9b7741ff6353bafb7f"},
+    {file = "greenlet-1.1.2-cp36-cp36m-win32.whl", hash = "sha256:32ca72bbc673adbcfecb935bb3fb1b74e663d10a4b241aaa2f5a75fe1d1f90aa"},
+    {file = "greenlet-1.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:f0214eb2a23b85528310dad848ad2ac58e735612929c8072f6093f3585fd342d"},
+    {file = "greenlet-1.1.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:b92e29e58bef6d9cfd340c72b04d74c4b4e9f70c9fa7c78b674d1fec18896dc4"},
+    {file = "greenlet-1.1.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b"},
+    {file = "greenlet-1.1.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:93f81b134a165cc17123626ab8da2e30c0455441d4ab5576eed73a64c025b25c"},
+    {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e12bdc622676ce47ae9abbf455c189e442afdde8818d9da983085df6312e7a1"},
+    {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c790abda465726cfb8bb08bd4ca9a5d0a7bd77c7ac1ca1b839ad823b948ea28"},
+    {file = "greenlet-1.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f276df9830dba7a333544bd41070e8175762a7ac20350786b322b714b0e654f5"},
+    {file = "greenlet-1.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c5d5b35f789a030ebb95bff352f1d27a93d81069f2adb3182d99882e095cefe"},
+    {file = "greenlet-1.1.2-cp37-cp37m-win32.whl", hash = "sha256:64e6175c2e53195278d7388c454e0b30997573f3f4bd63697f88d855f7a6a1fc"},
+    {file = "greenlet-1.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b11548073a2213d950c3f671aa88e6f83cda6e2fb97a8b6317b1b5b33d850e06"},
+    {file = "greenlet-1.1.2-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:9633b3034d3d901f0a46b7939f8c4d64427dfba6bbc5a36b1a67364cf148a1b0"},
+    {file = "greenlet-1.1.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:eb6ea6da4c787111adf40f697b4e58732ee0942b5d3bd8f435277643329ba627"},
+    {file = "greenlet-1.1.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:f3acda1924472472ddd60c29e5b9db0cec629fbe3c5c5accb74d6d6d14773478"},
+    {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e859fcb4cbe93504ea18008d1df98dee4f7766db66c435e4882ab35cf70cac43"},
+    {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00e44c8afdbe5467e4f7b5851be223be68adb4272f44696ee71fe46b7036a711"},
+    {file = "greenlet-1.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec8c433b3ab0419100bd45b47c9c8551248a5aee30ca5e9d399a0b57ac04651b"},
+    {file = "greenlet-1.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2bde6792f313f4e918caabc46532aa64aa27a0db05d75b20edfc5c6f46479de2"},
+    {file = "greenlet-1.1.2-cp38-cp38-win32.whl", hash = "sha256:288c6a76705dc54fba69fbcb59904ae4ad768b4c768839b8ca5fdadec6dd8cfd"},
+    {file = "greenlet-1.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:8d2f1fb53a421b410751887eb4ff21386d119ef9cde3797bf5e7ed49fb51a3b3"},
+    {file = "greenlet-1.1.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:166eac03e48784a6a6e0e5f041cfebb1ab400b394db188c48b3a84737f505b67"},
+    {file = "greenlet-1.1.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:572e1787d1460da79590bf44304abbc0a2da944ea64ec549188fa84d89bba7ab"},
+    {file = "greenlet-1.1.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:be5f425ff1f5f4b3c1e33ad64ab994eed12fc284a6ea71c5243fd564502ecbe5"},
+    {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1692f7d6bc45e3200844be0dba153612103db241691088626a33ff1f24a0d88"},
+    {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7227b47e73dedaa513cdebb98469705ef0d66eb5a1250144468e9c3097d6b59b"},
+    {file = "greenlet-1.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ff61ff178250f9bb3cd89752df0f1dd0e27316a8bd1465351652b1b4a4cdfd3"},
+    {file = "greenlet-1.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0051c6f1f27cb756ffc0ffbac7d2cd48cb0362ac1736871399a739b2885134d3"},
+    {file = "greenlet-1.1.2-cp39-cp39-win32.whl", hash = "sha256:f70a9e237bb792c7cc7e44c531fd48f5897961701cdaa06cf22fc14965c496cf"},
+    {file = "greenlet-1.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd"},
+    {file = "greenlet-1.1.2.tar.gz", hash = "sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a"},
+]
 h11 = [
     {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
     {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"},
@@ -734,26 +1455,148 @@ idna = [
     {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
     {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
 ]
-inflection = [
-    {file = "inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"},
-    {file = "inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417"},
+importlib-metadata = []
+importlib-resources = []
+iniconfig = [
+    {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
+    {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
+]
+jinja2 = [
+    {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"},
+    {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"},
 ]
 jmespath = [
     {file = "jmespath-0.10.0-py2.py3-none-any.whl", hash = "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f"},
     {file = "jmespath-0.10.0.tar.gz", hash = "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9"},
 ]
+jobbergate-api = []
 loguru = [
     {file = "loguru-0.6.0-py3-none-any.whl", hash = "sha256:4e2414d534a2ab57573365b3e6d0234dfb1d84b68b7f3b948e6fb743860a77c3"},
     {file = "loguru-0.6.0.tar.gz", hash = "sha256:066bd06758d0a513e9836fd9c6b5a75bfb3fd36841f4b996bc60b547a309d41c"},
 ]
+mako = [
+    {file = "Mako-1.2.1-py3-none-any.whl", hash = "sha256:df3921c3081b013c8a2d5ff03c18375651684921ae83fd12e64800b7da923257"},
+    {file = "Mako-1.2.1.tar.gz", hash = "sha256:f054a5ff4743492f1aa9ecc47172cb33b42b9d993cffcc146c9de17e717b0307"},
+]
+markupsafe = [
+    {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"},
+    {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"},
+    {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"},
+    {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"},
+    {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"},
+    {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"},
+    {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"},
+    {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"},
+    {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"},
+    {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"},
+    {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"},
+    {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"},
+    {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"},
+    {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"},
+    {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"},
+    {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"},
+    {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"},
+    {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"},
+    {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"},
+    {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"},
+    {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"},
+    {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"},
+    {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"},
+    {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"},
+    {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"},
+    {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"},
+    {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"},
+    {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"},
+    {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"},
+    {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"},
+    {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"},
+    {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"},
+    {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"},
+    {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"},
+    {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"},
+    {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"},
+    {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"},
+    {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"},
+    {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"},
+    {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"},
+]
 mccabe = [
     {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
     {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
 ]
+multidict = [
+    {file = "multidict-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b9e95a740109c6047602f4db4da9949e6c5945cefbad34a1299775ddc9a62e2"},
+    {file = "multidict-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac0e27844758d7177989ce406acc6a83c16ed4524ebc363c1f748cba184d89d3"},
+    {file = "multidict-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:041b81a5f6b38244b34dc18c7b6aba91f9cdaf854d9a39e5ff0b58e2b5773b9c"},
+    {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fdda29a3c7e76a064f2477c9aab1ba96fd94e02e386f1e665bca1807fc5386f"},
+    {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3368bf2398b0e0fcbf46d85795adc4c259299fec50c1416d0f77c0a843a3eed9"},
+    {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4f052ee022928d34fe1f4d2bc743f32609fb79ed9c49a1710a5ad6b2198db20"},
+    {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:225383a6603c086e6cef0f2f05564acb4f4d5f019a4e3e983f572b8530f70c88"},
+    {file = "multidict-6.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50bd442726e288e884f7be9071016c15a8742eb689a593a0cac49ea093eef0a7"},
+    {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:47e6a7e923e9cada7c139531feac59448f1f47727a79076c0b1ee80274cd8eee"},
+    {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0556a1d4ea2d949efe5fd76a09b4a82e3a4a30700553a6725535098d8d9fb672"},
+    {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:626fe10ac87851f4cffecee161fc6f8f9853f0f6f1035b59337a51d29ff3b4f9"},
+    {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:8064b7c6f0af936a741ea1efd18690bacfbae4078c0c385d7c3f611d11f0cf87"},
+    {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2d36e929d7f6a16d4eb11b250719c39560dd70545356365b494249e2186bc389"},
+    {file = "multidict-6.0.2-cp310-cp310-win32.whl", hash = "sha256:fcb91630817aa8b9bc4a74023e4198480587269c272c58b3279875ed7235c293"},
+    {file = "multidict-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:8cbf0132f3de7cc6c6ce00147cc78e6439ea736cee6bca4f068bcf892b0fd658"},
+    {file = "multidict-6.0.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:05f6949d6169878a03e607a21e3b862eaf8e356590e8bdae4227eedadacf6e51"},
+    {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2c2e459f7050aeb7c1b1276763364884595d47000c1cddb51764c0d8976e608"},
+    {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0509e469d48940147e1235d994cd849a8f8195e0bca65f8f5439c56e17872a3"},
+    {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:514fe2b8d750d6cdb4712346a2c5084a80220821a3e91f3f71eec11cf8d28fd4"},
+    {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19adcfc2a7197cdc3987044e3f415168fc5dc1f720c932eb1ef4f71a2067e08b"},
+    {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9d153e7f1f9ba0b23ad1568b3b9e17301e23b042c23870f9ee0522dc5cc79e8"},
+    {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:aef9cc3d9c7d63d924adac329c33835e0243b5052a6dfcbf7732a921c6e918ba"},
+    {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4571f1beddff25f3e925eea34268422622963cd8dc395bb8778eb28418248e43"},
+    {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:d48b8ee1d4068561ce8033d2c344cf5232cb29ee1a0206a7b828c79cbc5982b8"},
+    {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:45183c96ddf61bf96d2684d9fbaf6f3564d86b34cb125761f9a0ef9e36c1d55b"},
+    {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:75bdf08716edde767b09e76829db8c1e5ca9d8bb0a8d4bd94ae1eafe3dac5e15"},
+    {file = "multidict-6.0.2-cp37-cp37m-win32.whl", hash = "sha256:a45e1135cb07086833ce969555df39149680e5471c04dfd6a915abd2fc3f6dbc"},
+    {file = "multidict-6.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6f3cdef8a247d1eafa649085812f8a310e728bdf3900ff6c434eafb2d443b23a"},
+    {file = "multidict-6.0.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0327292e745a880459ef71be14e709aaea2f783f3537588fb4ed09b6c01bca60"},
+    {file = "multidict-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e875b6086e325bab7e680e4316d667fc0e5e174bb5611eb16b3ea121c8951b86"},
+    {file = "multidict-6.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:feea820722e69451743a3d56ad74948b68bf456984d63c1a92e8347b7b88452d"},
+    {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc57c68cb9139c7cd6fc39f211b02198e69fb90ce4bc4a094cf5fe0d20fd8b0"},
+    {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:497988d6b6ec6ed6f87030ec03280b696ca47dbf0648045e4e1d28b80346560d"},
+    {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89171b2c769e03a953d5969b2f272efa931426355b6c0cb508022976a17fd376"},
+    {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:684133b1e1fe91eda8fa7447f137c9490a064c6b7f392aa857bba83a28cfb693"},
+    {file = "multidict-6.0.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd9fc9c4849a07f3635ccffa895d57abce554b467d611a5009ba4f39b78a8849"},
+    {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e07c8e79d6e6fd37b42f3250dba122053fddb319e84b55dd3a8d6446e1a7ee49"},
+    {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4070613ea2227da2bfb2c35a6041e4371b0af6b0be57f424fe2318b42a748516"},
+    {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:47fbeedbf94bed6547d3aa632075d804867a352d86688c04e606971595460227"},
+    {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:5774d9218d77befa7b70d836004a768fb9aa4fdb53c97498f4d8d3f67bb9cfa9"},
+    {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2957489cba47c2539a8eb7ab32ff49101439ccf78eab724c828c1a54ff3ff98d"},
+    {file = "multidict-6.0.2-cp38-cp38-win32.whl", hash = "sha256:e5b20e9599ba74391ca0cfbd7b328fcc20976823ba19bc573983a25b32e92b57"},
+    {file = "multidict-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:8004dca28e15b86d1b1372515f32eb6f814bdf6f00952699bdeb541691091f96"},
+    {file = "multidict-6.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2e4a0785b84fb59e43c18a015ffc575ba93f7d1dbd272b4cdad9f5134b8a006c"},
+    {file = "multidict-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6701bf8a5d03a43375909ac91b6980aea74b0f5402fbe9428fc3f6edf5d9677e"},
+    {file = "multidict-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a007b1638e148c3cfb6bf0bdc4f82776cef0ac487191d093cdc316905e504071"},
+    {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07a017cfa00c9890011628eab2503bee5872f27144936a52eaab449be5eaf032"},
+    {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c207fff63adcdf5a485969131dc70e4b194327666b7e8a87a97fbc4fd80a53b2"},
+    {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:373ba9d1d061c76462d74e7de1c0c8e267e9791ee8cfefcf6b0b2495762c370c"},
+    {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfba7c6d5d7c9099ba21f84662b037a0ffd4a5e6b26ac07d19e423e6fdf965a9"},
+    {file = "multidict-6.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19d9bad105dfb34eb539c97b132057a4e709919ec4dd883ece5838bcbf262b80"},
+    {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:de989b195c3d636ba000ee4281cd03bb1234635b124bf4cd89eeee9ca8fcb09d"},
+    {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7c40b7bbece294ae3a87c1bc2abff0ff9beef41d14188cda94ada7bcea99b0fb"},
+    {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:d16cce709ebfadc91278a1c005e3c17dd5f71f5098bfae1035149785ea6e9c68"},
+    {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:a2c34a93e1d2aa35fbf1485e5010337c72c6791407d03aa5f4eed920343dd360"},
+    {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:feba80698173761cddd814fa22e88b0661e98cb810f9f986c54aa34d281e4937"},
+    {file = "multidict-6.0.2-cp39-cp39-win32.whl", hash = "sha256:23b616fdc3c74c9fe01d76ce0d1ce872d2d396d8fa8e4899398ad64fb5aa214a"},
+    {file = "multidict-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:4bae31803d708f6f15fd98be6a6ac0b6958fcf68fda3c77a048a4f9073704aae"},
+    {file = "multidict-6.0.2.tar.gz", hash = "sha256:5ff3bd75f38e4c43f1f470f2df7a4d430b821c4ce22be384e1459cb57d6bb013"},
+]
 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"},
+]
+passlib = [
+    {file = "passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1"},
+    {file = "passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04"},
+]
 pathspec = [
     {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"},
     {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"},
@@ -793,6 +1636,10 @@ platformdirs = [
     {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"},
     {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"},
 ]
+pluggy = [
+    {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
+    {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
+]
 pprintpp = [
     {file = "pprintpp-0.4.0-py2.py3-none-any.whl", hash = "sha256:b6b4dcdd0c0c0d75e4d7b2f21a9e933e5b2ce62b26e1a54537f9651ae5a5c01d"},
     {file = "pprintpp-0.4.0.tar.gz", hash = "sha256:ea826108e2c7f49dc6d66c752973c3fc9749142a798d6b254e1e301cfdbc6403"},
@@ -818,14 +1665,37 @@ psycopg2 = [
     {file = "psycopg2-2.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:bf35a25f1aaa8a3781195595577fcbb59934856ee46b4f252f56ad12b8043bcf"},
     {file = "psycopg2-2.9.1.tar.gz", hash = "sha256:de5303a6f1d0a7a34b9d40e4d3bef684ccc44a49bbe3eb85e3c0bffb4a131b7c"},
 ]
+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-2.1.3.tar.gz", hash = "sha256:4981e2d78fb1023b1b4b2b990e821b83336ba8dd558360306b291b5e4ff159bd"},
-    {file = "py_buzz-2.1.3-py3-none-any.whl", hash = "sha256:ccd89fb05901697d6ecf68693310040180c229b8c9f42c0876400265dd8976aa"},
+    {file = "py-buzz-3.2.1.tar.gz", hash = "sha256:f4fc6a119d9d74bc60822cbdac65876423cd56d9911833968db1c9ee8e2ed069"},
+    {file = "py_buzz-3.2.1-py3-none-any.whl", hash = "sha256:2dfc7dc9c7e19d49e0e75def2efad84510fcbf06dad538c8960faa50d39d34bf"},
+]
+pyasn1 = [
+    {file = "pyasn1-0.4.8-py2.4.egg", hash = "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"},
+    {file = "pyasn1-0.4.8-py2.5.egg", hash = "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf"},
+    {file = "pyasn1-0.4.8-py2.6.egg", hash = "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00"},
+    {file = "pyasn1-0.4.8-py2.7.egg", hash = "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8"},
+    {file = "pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"},
+    {file = "pyasn1-0.4.8-py3.1.egg", hash = "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86"},
+    {file = "pyasn1-0.4.8-py3.2.egg", hash = "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7"},
+    {file = "pyasn1-0.4.8-py3.3.egg", hash = "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576"},
+    {file = "pyasn1-0.4.8-py3.4.egg", hash = "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12"},
+    {file = "pyasn1-0.4.8-py3.5.egg", hash = "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2"},
+    {file = "pyasn1-0.4.8-py3.6.egg", hash = "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359"},
+    {file = "pyasn1-0.4.8-py3.7.egg", hash = "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776"},
+    {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"},
 ]
 pycodestyle = [
     {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"},
     {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"},
 ]
+pycparser = [
+    {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
+    {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
+]
 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"},
@@ -858,29 +1728,96 @@ pygments = [
     {file = "Pygments-2.10.0-py3-none-any.whl", hash = "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380"},
     {file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"},
 ]
+pyparsing = [
+    {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
+    {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
+]
 pyproject-flake8 = [
     {file = "pyproject-flake8-0.0.1a4.tar.gz", hash = "sha256:8ed9453f1d984cfe94c998f9840275359e29e7f435b8ddd188ae084e2dc1270c"},
     {file = "pyproject_flake8-0.0.1a4-py2.py3-none-any.whl", hash = "sha256:1a8f94e18d08677ee780625049d9d00a9ee823661c6606caab8a383351037a75"},
 ]
+pytest = [
+    {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"},
+    {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"},
+]
 python-dateutil = [
     {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
     {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"},
+python-dotenv = []
+python-http-client = [
+    {file = "python_http_client-3.3.7-py3-none-any.whl", hash = "sha256:ad371d2bbedc6ea15c26179c6222a78bc9308d272435ddf1d5c84f068f249a36"},
+    {file = "python_http_client-3.3.7.tar.gz", hash = "sha256:bf841ee45262747e00dec7ee9971dfb8c7d83083f5713596488d67739170cea0"},
+]
+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"},
+]
+python-multipart = [
+    {file = "python-multipart-0.0.5.tar.gz", hash = "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"},
 ]
 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-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"},
+    {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"},
+    {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"},
+    {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"},
+    {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"},
+    {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"},
+    {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"},
+    {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"},
+    {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"},
+    {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"},
+    {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"},
+    {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"},
+    {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"},
+    {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"},
+    {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"},
+    {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"},
+    {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"},
+    {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"},
+    {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"},
+    {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"},
+    {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"},
+    {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"},
+    {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"},
+    {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"},
+    {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"},
+    {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"},
+    {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"},
+    {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"},
+    {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"},
+    {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"},
+    {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"},
+    {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
+    {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
+]
+respx = [
+    {file = "respx-0.17.1-py2.py3-none-any.whl", hash = "sha256:34b28dacaa8e0c1bced38d9d183d7633df1f7c06db9802b9157bafa68a11755b"},
+    {file = "respx-0.17.1.tar.gz", hash = "sha256:7bde9b6f311ba51f4651618ccd4c5034df628fe44bc28102b98235c429df68fb"},
+]
 rfc3986 = [
     {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"},
     {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"},
 ]
+rsa = [
+    {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"},
+    {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"},
+]
 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.6.0-py3-none-any.whl", hash = "sha256:06176b74f3a15f61f1b4f25a1fc29a4429040b7647133a463da8fa5bd28d5ecd"},
+    {file = "s3transfer-0.6.0.tar.gz", hash = "sha256:2ed07d3866f523cc561bf4a00fc5535827981b117dd7876f036b0c1aca42c947"},
+]
+sendgrid = [
+    {file = "sendgrid-6.9.7-py3-none-any.whl", hash = "sha256:ba8d3d39e1f392b9434365d53983b2fc6a458ae0496d2d9e103c15e1743ab66b"},
+    {file = "sendgrid-6.9.7.tar.gz", hash = "sha256:fa30411c627690fecd0ef6b1d4e1783f2d0272aa14b5fffb133ebd1e31114f16"},
+]
+sentry-sdk = [
+    {file = "sentry-sdk-1.9.0.tar.gz", hash = "sha256:f185c53496d79b280fe5d9d21e6572aee1ab802d3354eb12314d216cfbaa8d30"},
+    {file = "sentry_sdk-1.9.0-py2.py3-none-any.whl", hash = "sha256:60b13757d6344a94bf0ccb3c0a006c4de77daab09871b30fbbd05d5ec24e54fb"},
 ]
 setproctitle = [
     {file = "setproctitle-1.2.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:9106bcbacae534b6f82955b176723f1b2ca6514518aab44dffec05a583f8dca8"},
@@ -910,22 +1847,71 @@ six = [
     {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
 ]
 snick = [
-    {file = "snick-1.0.0-py3-none-any.whl", hash = "sha256:49d0c9cbf2c1464fc5159914a5af8157f4db572a8241268f02ea97108c1a26ad"},
-    {file = "snick-1.0.0.tar.gz", hash = "sha256:272b5c6cb6676d762d8ba7c9961539edaf0d2b16aba57132857b224bf07fbc1a"},
+    {file = "snick-1.3.0-py3-none-any.whl", hash = "sha256:2b15bddc68b71e3bd396d8be38dff40130d0a7c79eddf3ea0ec7485befb0de08"},
+    {file = "snick-1.3.0.tar.gz", hash = "sha256:f2aadf7e3135878ca592f1f70e9ecfa6f8d7f590751500ac152614bff262e109"},
 ]
 sniffio = [
     {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"},
     {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"},
 ]
+sqlalchemy = [
+    {file = "SQLAlchemy-1.4.40-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:b07fc38e6392a65935dc8b486229679142b2ea33c94059366b4d8b56f1e35a97"},
+    {file = "SQLAlchemy-1.4.40-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:fb4edb6c354eac0fcc07cb91797e142f702532dbb16c1d62839d6eec35f814cf"},
+    {file = "SQLAlchemy-1.4.40-cp27-cp27m-win32.whl", hash = "sha256:2026632051a93997cf8f6fda14360f99230be1725b7ab2ef15be205a4b8a5430"},
+    {file = "SQLAlchemy-1.4.40-cp27-cp27m-win_amd64.whl", hash = "sha256:f2aa85aebc0ef6b342d5d3542f969caa8c6a63c8d36cf5098769158a9fa2123c"},
+    {file = "SQLAlchemy-1.4.40-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a0b9e3d81f86ba04007f0349e373a5b8c81ec2047aadb8d669caf8c54a092461"},
+    {file = "SQLAlchemy-1.4.40-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:1ab08141d93de83559f6a7d9a962830f918623a885b3759ec2b9d1a531ff28fe"},
+    {file = "SQLAlchemy-1.4.40-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00dd998b43b282c71de46b061627b5edb9332510eb1edfc5017b9e4356ed44ea"},
+    {file = "SQLAlchemy-1.4.40-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bb342c0e25cc8f78a0e7c692da3b984f072666b316fbbec2a0e371cb4dfef5f0"},
+    {file = "SQLAlchemy-1.4.40-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23b693876ac7963b6bc7b1a5f3a2642f38d2624af834faad5933913928089d1b"},
+    {file = "SQLAlchemy-1.4.40-cp310-cp310-win32.whl", hash = "sha256:2cf50611ef4221ad587fb7a1708e61ff72966f84330c6317642e08d6db4138fd"},
+    {file = "SQLAlchemy-1.4.40-cp310-cp310-win_amd64.whl", hash = "sha256:26ee4dbac5dd7abf18bf3cd8f04e51f72c339caf702f68172d308888cd26c6c9"},
+    {file = "SQLAlchemy-1.4.40-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:b41b87b929118838bafc4bb18cf3c5cd1b3be4b61cd9042e75174df79e8ac7a2"},
+    {file = "SQLAlchemy-1.4.40-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:885e11638946472b4a0a7db8e6df604b2cf64d23dc40eedc3806d869fcb18fae"},
+    {file = "SQLAlchemy-1.4.40-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b7ff0a8bf0aec1908b92b8dfa1246128bf4f94adbdd3da6730e9c542e112542d"},
+    {file = "SQLAlchemy-1.4.40-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cfa8ab4ba0c97ab6bcae1f0948497d14c11b6c6ecd1b32b8a79546a0823d8211"},
+    {file = "SQLAlchemy-1.4.40-cp36-cp36m-win32.whl", hash = "sha256:d259fa08e4b3ed952c01711268bcf6cd2442b0c54866d64aece122f83da77c6d"},
+    {file = "SQLAlchemy-1.4.40-cp36-cp36m-win_amd64.whl", hash = "sha256:c8d974c991eef0cd29418a5957ae544559dc326685a6f26b3a914c87759bf2f4"},
+    {file = "SQLAlchemy-1.4.40-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:28b1791a30d62fc104070965f1a2866699c45bbf5adc0be0cf5f22935edcac58"},
+    {file = "SQLAlchemy-1.4.40-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7ccdca6cd167611f4a62a8c2c0c4285c2535640d77108f782ce3f3cccb70f3a"},
+    {file = "SQLAlchemy-1.4.40-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:69deec3a94de10062080d91e1ba69595efeafeafe68b996426dec9720031fb25"},
+    {file = "SQLAlchemy-1.4.40-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63ad778f4e80913fb171247e4fa82123d0068615ae1d51a9791fc4284cb81748"},
+    {file = "SQLAlchemy-1.4.40-cp37-cp37m-win32.whl", hash = "sha256:9ced2450c9fd016f9232d976661623e54c450679eeefc7aa48a3d29924a63189"},
+    {file = "SQLAlchemy-1.4.40-cp37-cp37m-win_amd64.whl", hash = "sha256:cdee4d475e35684d210dc6b430ff8ca2ed0636378ac19b457e2f6f350d1f5acc"},
+    {file = "SQLAlchemy-1.4.40-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:08b47c971327e733ffd6bae2d4f50a7b761793efe69d41067fcba86282819eea"},
+    {file = "SQLAlchemy-1.4.40-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cf03d37819dc17a388d313919daf32058d19ba1e592efdf14ce8cbd997e6023"},
+    {file = "SQLAlchemy-1.4.40-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a62c0ecbb9976550f26f7bf75569f425e661e7249349487f1483115e5fc893a6"},
+    {file = "SQLAlchemy-1.4.40-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ec440990ab00650d0c7ea2c75bc225087afdd7ddcb248e3d934def4dff62762"},
+    {file = "SQLAlchemy-1.4.40-cp38-cp38-win32.whl", hash = "sha256:2b64955850a14b9d481c17becf0d3f62fb1bb31ac2c45c2caf5ad06d9e811187"},
+    {file = "SQLAlchemy-1.4.40-cp38-cp38-win_amd64.whl", hash = "sha256:959bf4390766a8696aa01285016c766b4eb676f712878aac5fce956dd49695d9"},
+    {file = "SQLAlchemy-1.4.40-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:0992f3cc640ec0f88f721e426da884c34ff0a60eb73d3d64172e23dfadfc8a0b"},
+    {file = "SQLAlchemy-1.4.40-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fa9e0d7832b7511b3b3fd0e67fac85ff11fd752834c143ca2364c9b778c0485a"},
+    {file = "SQLAlchemy-1.4.40-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c9d0f1a9538cc5e75f2ea0cb6c3d70155a1b7f18092c052e0d84105622a41b63"},
+    {file = "SQLAlchemy-1.4.40-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c956a5d1adb49a35d78ef0fae26717afc48a36262359bb5b0cbd7a3a247c26f"},
+    {file = "SQLAlchemy-1.4.40-cp39-cp39-win32.whl", hash = "sha256:6b70d02bbe1adbbf715d2249cacf9ac17c6f8d22dfcb3f1a4fbc5bf64364da8a"},
+    {file = "SQLAlchemy-1.4.40-cp39-cp39-win_amd64.whl", hash = "sha256:bf073c619b5a7f7cd731507d0fdc7329bee14b247a63b0419929e4acd24afea8"},
+    {file = "SQLAlchemy-1.4.40.tar.gz", hash = "sha256:44a660506080cc975e1dfa5776fe5f6315ddc626a77b50bf0eee18b0389ea265"},
+]
 sqlparse = [
     {file = "sqlparse-0.4.2-py3-none-any.whl", hash = "sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d"},
     {file = "sqlparse-0.4.2.tar.gz", hash = "sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae"},
 ]
+starkbank-ecdsa = [
+    {file = "starkbank-ecdsa-2.0.3.tar.gz", hash = "sha256:73b62b1b3de54bbaa05dedb1a2d951c033432bb074de899e19d4a96a36b21df6"},
+]
+starlette = [
+    {file = "starlette-0.14.2-py3-none-any.whl", hash = "sha256:3c8e48e52736b3161e34c9f0e8153b4f32ec5d8995a3ee1d59410d92f75162ed"},
+    {file = "starlette-0.14.2.tar.gz", hash = "sha256:7d49f4a27f8742262ef1470608c59ddbc66baf37c148e938c7038e6bc7a998aa"},
+]
 tabulate = [
     {file = "tabulate-0.8.10-py3-none-any.whl", hash = "sha256:0ba055423dbaa164b9e456abe7920c5e8ed33fcc16f6d1b2f2d152c8e1e8b4fc"},
     {file = "tabulate-0.8.10-py3.8.egg", hash = "sha256:436f1c768b424654fce8597290d2764def1eea6a77cfa5c33be00b1bc0f4f63d"},
     {file = "tabulate-0.8.10.tar.gz", hash = "sha256:6c57f3f3dd7ac2782770155f3adb2db0b1a269637e42f27599925e64b114f519"},
 ]
+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"},
@@ -943,6 +1929,10 @@ urllib3 = [
     {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"},
     {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"},
 ]
+uvicorn = [
+    {file = "uvicorn-0.15.0-py3-none-any.whl", hash = "sha256:17f898c64c71a2640514d4089da2689e5db1ce5d4086c2d53699bf99513421c1"},
+    {file = "uvicorn-0.15.0.tar.gz", hash = "sha256:d9a3c0dd1ca86728d3e235182683b4cf94cd53a867c288eaeca80ee781b2caff"},
+]
 wcwidth = [
     {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"},
     {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"},
@@ -951,56 +1941,65 @@ 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"},
 ]
-wrapt = [
-    {file = "wrapt-1.13.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:e05e60ff3b2b0342153be4d1b597bbcfd8330890056b9619f4ad6b8d5c96a81a"},
-    {file = "wrapt-1.13.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:85148f4225287b6a0665eef08a178c15097366d46b210574a658c1ff5b377489"},
-    {file = "wrapt-1.13.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:2dded5496e8f1592ec27079b28b6ad2a1ef0b9296d270f77b8e4a3a796cf6909"},
-    {file = "wrapt-1.13.3-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:e94b7d9deaa4cc7bac9198a58a7240aaf87fe56c6277ee25fa5b3aa1edebd229"},
-    {file = "wrapt-1.13.3-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:498e6217523111d07cd67e87a791f5e9ee769f9241fcf8a379696e25806965af"},
-    {file = "wrapt-1.13.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:ec7e20258ecc5174029a0f391e1b948bf2906cd64c198a9b8b281b811cbc04de"},
-    {file = "wrapt-1.13.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:87883690cae293541e08ba2da22cacaae0a092e0ed56bbba8d018cc486fbafbb"},
-    {file = "wrapt-1.13.3-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:f99c0489258086308aad4ae57da9e8ecf9e1f3f30fa35d5e170b4d4896554d80"},
-    {file = "wrapt-1.13.3-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:6a03d9917aee887690aa3f1747ce634e610f6db6f6b332b35c2dd89412912bca"},
-    {file = "wrapt-1.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:936503cb0a6ed28dbfa87e8fcd0a56458822144e9d11a49ccee6d9a8adb2ac44"},
-    {file = "wrapt-1.13.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f9c51d9af9abb899bd34ace878fbec8bf357b3194a10c4e8e0a25512826ef056"},
-    {file = "wrapt-1.13.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:220a869982ea9023e163ba915077816ca439489de6d2c09089b219f4e11b6785"},
-    {file = "wrapt-1.13.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0877fe981fd76b183711d767500e6b3111378ed2043c145e21816ee589d91096"},
-    {file = "wrapt-1.13.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:43e69ffe47e3609a6aec0fe723001c60c65305784d964f5007d5b4fb1bc6bf33"},
-    {file = "wrapt-1.13.3-cp310-cp310-win32.whl", hash = "sha256:78dea98c81915bbf510eb6a3c9c24915e4660302937b9ae05a0947164248020f"},
-    {file = "wrapt-1.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:ea3e746e29d4000cd98d572f3ee2a6050a4f784bb536f4ac1f035987fc1ed83e"},
-    {file = "wrapt-1.13.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:8c73c1a2ec7c98d7eaded149f6d225a692caa1bd7b2401a14125446e9e90410d"},
-    {file = "wrapt-1.13.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:086218a72ec7d986a3eddb7707c8c4526d677c7b35e355875a0fe2918b059179"},
-    {file = "wrapt-1.13.3-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:e92d0d4fa68ea0c02d39f1e2f9cb5bc4b4a71e8c442207433d8db47ee79d7aa3"},
-    {file = "wrapt-1.13.3-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:d4a5f6146cfa5c7ba0134249665acd322a70d1ea61732723c7d3e8cc0fa80755"},
-    {file = "wrapt-1.13.3-cp35-cp35m-win32.whl", hash = "sha256:8aab36778fa9bba1a8f06a4919556f9f8c7b33102bd71b3ab307bb3fecb21851"},
-    {file = "wrapt-1.13.3-cp35-cp35m-win_amd64.whl", hash = "sha256:944b180f61f5e36c0634d3202ba8509b986b5fbaf57db3e94df11abee244ba13"},
-    {file = "wrapt-1.13.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:2ebdde19cd3c8cdf8df3fc165bc7827334bc4e353465048b36f7deeae8ee0918"},
-    {file = "wrapt-1.13.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:610f5f83dd1e0ad40254c306f4764fcdc846641f120c3cf424ff57a19d5f7ade"},
-    {file = "wrapt-1.13.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5601f44a0f38fed36cc07db004f0eedeaadbdcec90e4e90509480e7e6060a5bc"},
-    {file = "wrapt-1.13.3-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:e6906d6f48437dfd80464f7d7af1740eadc572b9f7a4301e7dd3d65db285cacf"},
-    {file = "wrapt-1.13.3-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:766b32c762e07e26f50d8a3468e3b4228b3736c805018e4b0ec8cc01ecd88125"},
-    {file = "wrapt-1.13.3-cp36-cp36m-win32.whl", hash = "sha256:5f223101f21cfd41deec8ce3889dc59f88a59b409db028c469c9b20cfeefbe36"},
-    {file = "wrapt-1.13.3-cp36-cp36m-win_amd64.whl", hash = "sha256:f122ccd12fdc69628786d0c947bdd9cb2733be8f800d88b5a37c57f1f1d73c10"},
-    {file = "wrapt-1.13.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:46f7f3af321a573fc0c3586612db4decb7eb37172af1bc6173d81f5b66c2e068"},
-    {file = "wrapt-1.13.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:778fd096ee96890c10ce96187c76b3e99b2da44e08c9e24d5652f356873f6709"},
-    {file = "wrapt-1.13.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0cb23d36ed03bf46b894cfec777eec754146d68429c30431c99ef28482b5c1df"},
-    {file = "wrapt-1.13.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:96b81ae75591a795d8c90edc0bfaab44d3d41ffc1aae4d994c5aa21d9b8e19a2"},
-    {file = "wrapt-1.13.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7dd215e4e8514004c8d810a73e342c536547038fb130205ec4bba9f5de35d45b"},
-    {file = "wrapt-1.13.3-cp37-cp37m-win32.whl", hash = "sha256:47f0a183743e7f71f29e4e21574ad3fa95676136f45b91afcf83f6a050914829"},
-    {file = "wrapt-1.13.3-cp37-cp37m-win_amd64.whl", hash = "sha256:fd76c47f20984b43d93de9a82011bb6e5f8325df6c9ed4d8310029a55fa361ea"},
-    {file = "wrapt-1.13.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b73d4b78807bd299b38e4598b8e7bd34ed55d480160d2e7fdaabd9931afa65f9"},
-    {file = "wrapt-1.13.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ec9465dd69d5657b5d2fa6133b3e1e989ae27d29471a672416fd729b429eb554"},
-    {file = "wrapt-1.13.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:dd91006848eb55af2159375134d724032a2d1d13bcc6f81cd8d3ed9f2b8e846c"},
-    {file = "wrapt-1.13.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ae9de71eb60940e58207f8e71fe113c639da42adb02fb2bcbcaccc1ccecd092b"},
-    {file = "wrapt-1.13.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:51799ca950cfee9396a87f4a1240622ac38973b6df5ef7a41e7f0b98797099ce"},
-    {file = "wrapt-1.13.3-cp38-cp38-win32.whl", hash = "sha256:4b9c458732450ec42578b5642ac53e312092acf8c0bfce140ada5ca1ac556f79"},
-    {file = "wrapt-1.13.3-cp38-cp38-win_amd64.whl", hash = "sha256:7dde79d007cd6dfa65afe404766057c2409316135cb892be4b1c768e3f3a11cb"},
-    {file = "wrapt-1.13.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:981da26722bebb9247a0601e2922cedf8bb7a600e89c852d063313102de6f2cb"},
-    {file = "wrapt-1.13.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:705e2af1f7be4707e49ced9153f8d72131090e52be9278b5dbb1498c749a1e32"},
-    {file = "wrapt-1.13.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:25b1b1d5df495d82be1c9d2fad408f7ce5ca8a38085e2da41bb63c914baadff7"},
-    {file = "wrapt-1.13.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:77416e6b17926d953b5c666a3cb718d5945df63ecf922af0ee576206d7033b5e"},
-    {file = "wrapt-1.13.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:865c0b50003616f05858b22174c40ffc27a38e67359fa1495605f96125f76640"},
-    {file = "wrapt-1.13.3-cp39-cp39-win32.whl", hash = "sha256:0a017a667d1f7411816e4bf214646d0ad5b1da2c1ea13dec6c162736ff25a374"},
-    {file = "wrapt-1.13.3-cp39-cp39-win_amd64.whl", hash = "sha256:81bd7c90d28a4b2e1df135bfbd7c23aee3050078ca6441bead44c42483f9ebfb"},
-    {file = "wrapt-1.13.3.tar.gz", hash = "sha256:1fea9cd438686e6682271d36f3481a9f3636195578bab9ca3382e2f5f01fc185"},
+yarl = [
+    {file = "yarl-1.8.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:abc06b97407868ef38f3d172762f4069323de52f2b70d133d096a48d72215d28"},
+    {file = "yarl-1.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:07b21e274de4c637f3e3b7104694e53260b5fc10d51fb3ec5fed1da8e0f754e3"},
+    {file = "yarl-1.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9de955d98e02fab288c7718662afb33aab64212ecb368c5dc866d9a57bf48880"},
+    {file = "yarl-1.8.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ec362167e2c9fd178f82f252b6d97669d7245695dc057ee182118042026da40"},
+    {file = "yarl-1.8.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20df6ff4089bc86e4a66e3b1380460f864df3dd9dccaf88d6b3385d24405893b"},
+    {file = "yarl-1.8.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5999c4662631cb798496535afbd837a102859568adc67d75d2045e31ec3ac497"},
+    {file = "yarl-1.8.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed19b74e81b10b592084a5ad1e70f845f0aacb57577018d31de064e71ffa267a"},
+    {file = "yarl-1.8.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e4808f996ca39a6463f45182e2af2fae55e2560be586d447ce8016f389f626f"},
+    {file = "yarl-1.8.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2d800b9c2eaf0684c08be5f50e52bfa2aa920e7163c2ea43f4f431e829b4f0fd"},
+    {file = "yarl-1.8.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6628d750041550c5d9da50bb40b5cf28a2e63b9388bac10fedd4f19236ef4957"},
+    {file = "yarl-1.8.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f5af52738e225fcc526ae64071b7e5342abe03f42e0e8918227b38c9aa711e28"},
+    {file = "yarl-1.8.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:76577f13333b4fe345c3704811ac7509b31499132ff0181f25ee26619de2c843"},
+    {file = "yarl-1.8.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0c03f456522d1ec815893d85fccb5def01ffaa74c1b16ff30f8aaa03eb21e453"},
+    {file = "yarl-1.8.1-cp310-cp310-win32.whl", hash = "sha256:ea30a42dc94d42f2ba4d0f7c0ffb4f4f9baa1b23045910c0c32df9c9902cb272"},
+    {file = "yarl-1.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:9130ddf1ae9978abe63808b6b60a897e41fccb834408cde79522feb37fb72fb0"},
+    {file = "yarl-1.8.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0ab5a138211c1c366404d912824bdcf5545ccba5b3ff52c42c4af4cbdc2c5035"},
+    {file = "yarl-1.8.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0fb2cb4204ddb456a8e32381f9a90000429489a25f64e817e6ff94879d432fc"},
+    {file = "yarl-1.8.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:85cba594433915d5c9a0d14b24cfba0339f57a2fff203a5d4fd070e593307d0b"},
+    {file = "yarl-1.8.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ca7e596c55bd675432b11320b4eacc62310c2145d6801a1f8e9ad160685a231"},
+    {file = "yarl-1.8.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0f77539733e0ec2475ddcd4e26777d08996f8cd55d2aef82ec4d3896687abda"},
+    {file = "yarl-1.8.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29e256649f42771829974e742061c3501cc50cf16e63f91ed8d1bf98242e5507"},
+    {file = "yarl-1.8.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7fce6cbc6c170ede0221cc8c91b285f7f3c8b9fe28283b51885ff621bbe0f8ee"},
+    {file = "yarl-1.8.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:59ddd85a1214862ce7c7c66457f05543b6a275b70a65de366030d56159a979f0"},
+    {file = "yarl-1.8.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:12768232751689c1a89b0376a96a32bc7633c08da45ad985d0c49ede691f5c0d"},
+    {file = "yarl-1.8.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:b19255dde4b4f4c32e012038f2c169bb72e7f081552bea4641cab4d88bc409dd"},
+    {file = "yarl-1.8.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6c8148e0b52bf9535c40c48faebb00cb294ee577ca069d21bd5c48d302a83780"},
+    {file = "yarl-1.8.1-cp37-cp37m-win32.whl", hash = "sha256:de839c3a1826a909fdbfe05f6fe2167c4ab033f1133757b5936efe2f84904c07"},
+    {file = "yarl-1.8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:dd032e8422a52e5a4860e062eb84ac94ea08861d334a4bcaf142a63ce8ad4802"},
+    {file = "yarl-1.8.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:19cd801d6f983918a3f3a39f3a45b553c015c5aac92ccd1fac619bd74beece4a"},
+    {file = "yarl-1.8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6347f1a58e658b97b0a0d1ff7658a03cb79bdbda0331603bed24dd7054a6dea1"},
+    {file = "yarl-1.8.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c0da7e44d0c9108d8b98469338705e07f4bb7dab96dbd8fa4e91b337db42548"},
+    {file = "yarl-1.8.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5587bba41399854703212b87071c6d8638fa6e61656385875f8c6dff92b2e461"},
+    {file = "yarl-1.8.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31a9a04ecccd6b03e2b0e12e82131f1488dea5555a13a4d32f064e22a6003cfe"},
+    {file = "yarl-1.8.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:205904cffd69ae972a1707a1bd3ea7cded594b1d773a0ce66714edf17833cdae"},
+    {file = "yarl-1.8.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea513a25976d21733bff523e0ca836ef1679630ef4ad22d46987d04b372d57fc"},
+    {file = "yarl-1.8.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0b51530877d3ad7a8d47b2fff0c8df3b8f3b8deddf057379ba50b13df2a5eae"},
+    {file = "yarl-1.8.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d2b8f245dad9e331540c350285910b20dd913dc86d4ee410c11d48523c4fd546"},
+    {file = "yarl-1.8.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ab2a60d57ca88e1d4ca34a10e9fb4ab2ac5ad315543351de3a612bbb0560bead"},
+    {file = "yarl-1.8.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:449c957ffc6bc2309e1fbe67ab7d2c1efca89d3f4912baeb8ead207bb3cc1cd4"},
+    {file = "yarl-1.8.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a165442348c211b5dea67c0206fc61366212d7082ba8118c8c5c1c853ea4d82e"},
+    {file = "yarl-1.8.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b3ded839a5c5608eec8b6f9ae9a62cb22cd037ea97c627f38ae0841a48f09eae"},
+    {file = "yarl-1.8.1-cp38-cp38-win32.whl", hash = "sha256:c1445a0c562ed561d06d8cbc5c8916c6008a31c60bc3655cdd2de1d3bf5174a0"},
+    {file = "yarl-1.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:56c11efb0a89700987d05597b08a1efcd78d74c52febe530126785e1b1a285f4"},
+    {file = "yarl-1.8.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e80ed5a9939ceb6fda42811542f31c8602be336b1fb977bccb012e83da7e4936"},
+    {file = "yarl-1.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6afb336e23a793cd3b6476c30f030a0d4c7539cd81649683b5e0c1b0ab0bf350"},
+    {file = "yarl-1.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4c322cbaa4ed78a8aac89b2174a6df398faf50e5fc12c4c191c40c59d5e28357"},
+    {file = "yarl-1.8.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fae37373155f5ef9b403ab48af5136ae9851151f7aacd9926251ab26b953118b"},
+    {file = "yarl-1.8.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5395da939ffa959974577eff2cbfc24b004a2fb6c346918f39966a5786874e54"},
+    {file = "yarl-1.8.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:076eede537ab978b605f41db79a56cad2e7efeea2aa6e0fa8f05a26c24a034fb"},
+    {file = "yarl-1.8.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d1a50e461615747dd93c099f297c1994d472b0f4d2db8a64e55b1edf704ec1c"},
+    {file = "yarl-1.8.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7de89c8456525650ffa2bb56a3eee6af891e98f498babd43ae307bd42dca98f6"},
+    {file = "yarl-1.8.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4a88510731cd8d4befaba5fbd734a7dd914de5ab8132a5b3dde0bbd6c9476c64"},
+    {file = "yarl-1.8.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:2d93a049d29df172f48bcb09acf9226318e712ce67374f893b460b42cc1380ae"},
+    {file = "yarl-1.8.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:21ac44b763e0eec15746a3d440f5e09ad2ecc8b5f6dcd3ea8cb4773d6d4703e3"},
+    {file = "yarl-1.8.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:d0272228fabe78ce00a3365ffffd6f643f57a91043e119c289aaba202f4095b0"},
+    {file = "yarl-1.8.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:99449cd5366fe4608e7226c6cae80873296dfa0cde45d9b498fefa1de315a09e"},
+    {file = "yarl-1.8.1-cp39-cp39-win32.whl", hash = "sha256:8b0af1cf36b93cee99a31a545fe91d08223e64390c5ecc5e94c39511832a4bb6"},
+    {file = "yarl-1.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:de49d77e968de6626ba7ef4472323f9d2e5a56c1d85b7c0e2a190b2173d3b9be"},
+    {file = "yarl-1.8.1.tar.gz", hash = "sha256:af887845b8c2e060eb5605ff72b6f2dd2aab7a761379373fd89d314f4752abbf"},
 ]
+zipp = []
diff --git a/jobbergate-slurp/pyproject.toml b/jobbergate-slurp/pyproject.toml
index 0edddb08..d382fc32 100644
--- a/jobbergate-slurp/pyproject.toml
+++ b/jobbergate-slurp/pyproject.toml
@@ -8,7 +8,7 @@ packages = [ { include = "slurp" } ]
 
 [tool.poetry.dependencies]
 python = "^3.8"
-python-dotenv = "^0.19.1"
+python-dotenv = "^0.20.0"
 psycopg = "^3.0.3"
 pydantic = "^1.8.2"
 loguru = "^0.6.0"
@@ -16,8 +16,10 @@ typer = "^0.4.0"
 pgcli = "^3.2.0"
 snick = "^1.0.0"
 httpx = "^0.23.0"
-py-buzz = "^2.1.3"
+py-buzz = "^3.1.0"
 boto3 = "^1.20.8"
+file-storehouse = "^0.3.4"
+jobbergate-api = {path = "../jobbergate-api"}
 
 [tool.poetry.dev-dependencies]
 black = "^22.3.0"
diff --git a/jobbergate-slurp/slurp/main.py b/jobbergate-slurp/slurp/main.py
index 8621fb43..2795660d 100644
--- a/jobbergate-slurp/slurp/main.py
+++ b/jobbergate-slurp/slurp/main.py
@@ -32,7 +32,6 @@ def clear_nextgen_db():
     Clears out the tables of the nextgen database.
     """
     logger.info("Clearing out nextgen database")
-    s3man = build_managers()
     with db(is_legacy=False) as nextgen_db:
         logger.info("Truncating job_submissions")
         nextgen_db.execute("truncate job_submissions cascade")
@@ -43,9 +42,7 @@ def clear_nextgen_db():
         logger.info("Truncating applications")
         nextgen_db.execute("truncate applications cascade")
 
-        logger.info("Clearing S3 objects")
-        for s in s3man:
-            s.nextgen.clear()
+        logger.info("Remember to clean S3 objects manually, if necessary")
     logger.success("Finished clearing!")
 
 
diff --git a/jobbergate-slurp/slurp/migrators/applications.py b/jobbergate-slurp/slurp/migrators/applications.py
index b8d706ec..3c74c80c 100644
--- a/jobbergate-slurp/slurp/migrators/applications.py
+++ b/jobbergate-slurp/slurp/migrators/applications.py
@@ -25,7 +25,6 @@ def migrate_applications(nextgen_db, legacy_applications, user_map):
                 application_identifier,
                 application_description,
                 application_owner_email,
-                application_file,
                 application_config,
                 application_uploaded,
                 created_at,
@@ -36,7 +35,6 @@ def migrate_applications(nextgen_db, legacy_applications, user_map):
                 %(identifier)s,
                 %(description)s,
                 %(owner_email)s,
-                %(file)s,
                 %(config)s,
                 false,
                 %(created)s,
@@ -49,7 +47,6 @@ def migrate_applications(nextgen_db, legacy_applications, user_map):
                 identifier=application["application_identifier"],
                 description=application["application_description"],
                 owner_email=owner_email,
-                file=application["application_file"],
                 config=application["application_config"],
                 created=application["created_at"],
                 updated=application["updated_at"],
@@ -76,7 +73,7 @@ def mark_uploaded(nextgen_db, ids):
         logger.debug(f"Marking application {batch_start} through {batch_end} of {id_count}")
         batch = ids[batch_start:batch_end]
         nextgen_db.execute(
-            f"""
+            """
             update applications
             set application_uploaded = true
             where id = any(%s)
diff --git a/jobbergate-slurp/slurp/migrators/job_scripts.py b/jobbergate-slurp/slurp/migrators/job_scripts.py
index 15dadd40..336d7b42 100644
--- a/jobbergate-slurp/slurp/migrators/job_scripts.py
+++ b/jobbergate-slurp/slurp/migrators/job_scripts.py
@@ -17,7 +17,7 @@ def migrate_job_scripts(nextgen_db, legacy_job_scripts, user_map, application_ma
     job_scripts_map = {}
     logger.info("Migrating job_scripts to nextgen database")
 
-    s3man.nextgen.ensure_bucket()
+    s3man.nextgen.engine.ensure_bucket()
 
     for job_script in legacy_job_scripts:
         owner_email = user_map[job_script["job_script_owner_id"]]["email"]
@@ -56,8 +56,7 @@ def migrate_job_scripts(nextgen_db, legacy_job_scripts, user_map, application_ma
         nextgen_jobscript_id = result.fetchone()["id"]
         job_scripts_map[job_script["id"]] = nextgen_jobscript_id
 
-        nextgen_jobscript_key = s3man.nextgen.get_key_from_id(nextgen_jobscript_id)
-        s3man.nextgen[nextgen_jobscript_key] = job_script["job_script_data_as_string"]
+        s3man.nextgen[nextgen_jobscript_id] = job_script["job_script_data_as_string"]
 
     logger.success("Finished migrating job_scripts")
     return job_scripts_map
diff --git a/jobbergate-slurp/slurp/s3_ops.py b/jobbergate-slurp/slurp/s3_ops.py
index 874a6b2e..04ddb7f0 100644
--- a/jobbergate-slurp/slurp/s3_ops.py
+++ b/jobbergate-slurp/slurp/s3_ops.py
@@ -1,171 +1,21 @@
 """
 Provides a convenience class for managing calls to S3.
 """
+import contextlib
+import tarfile
+import tempfile
 from collections import namedtuple
-import re
-import typing
-from collections.abc import MutableMapping
-
-import boto3
-from botocore.exceptions import BotoCoreError
+from io import BytesIO
+from pathlib import Path
+
+from fastapi import UploadFile
+from file_storehouse import FileManager, FileManagerReadOnly, client
+from file_storehouse.engine.s3 import EngineS3
+from file_storehouse.key_mapping import KeyMappingNumeratedFolder
+from jobbergate_api.s3_manager import LIST_OF_TRANSFORMATIONS, ApplicationFileManager
 from loguru import logger
 
 from slurp.config import settings
-from slurp.exceptions import SlurpException
-
-
-class S3Manager(MutableMapping):
-    """
-    Provide a class for managing the files from an S3 client.
-
-    This class implements the MutableMapping protocol, so all interactions with
-    S3 can be done using a dict-like interface.
-
-    The files stored in the Bucket defined at settings and with a key template
-    computed internally in this class.
-    """
-
-    def __init__(
-        self,
-        s3_client,
-        folder: str,
-        filename: str,
-        *,
-        bucket_name: str = None,
-        read_only: bool = True,
-    ):
-        """
-        Initialize an s3 manager. The interaction with S3 is done with the
-        provided client, folder and filename.
-        """
-        self.s3_client = s3_client
-        self.folder_name = folder
-        self.filename = filename
-        self.bucket_name = bucket_name
-        self.read_only = read_only
-
-        self._key_template = f"{self.folder_name}/{{app_id}}/{self.filename}"
-        self._get_id_re = re.compile(r"/(?P<id>\d+)/{filename}$".format(filename=self.filename))
-
-    def read_only_protection(function):
-        """
-        A decorator used to warp key methods, aiming to protect the files from
-        been overwritten or deleted when the s3 manger is set as read-only.
-        Raise RuntimeError if any protected operation is tried.
-        """
-
-        def helper(self, *args, **kwargs):
-
-            if self.read_only:
-                message = "Illegal operation for a read-only S3 manager (folder={}, bucket={})."
-                raise RuntimeError(message.format(self.folder_name, self.bucket_name))
-            function(self, *args, **kwargs)
-
-        return helper
-
-    def __getitem__(self, key: str) -> str:
-        """
-        Get a file from the client associated to the given id.
-        """
-        logger.trace(f"Getting from S3: {key})")
-
-        try:
-            response = self.s3_client.get_object(Bucket=self.bucket_name, Key=key)
-        except self.s3_client.exceptions.NoSuchKey:
-            raise KeyError(f"No such key: {key}")
-
-        return response["Body"].read()
-
-    @read_only_protection
-    def __setitem__(self, key: str, file: str) -> None:
-        """
-        Upload a file to the client for the given id.
-        """
-        logger.trace(f"Uploading to S3: {key})")
-
-        try:
-            self.s3_client.put_object(Body=file, Bucket=self.bucket_name, Key=key)
-        except self.s3_client.exceptions.NoSuchKey:
-            raise KeyError(f"No such key: {key}")
-
-    @read_only_protection
-    def __delitem__(self, key: str) -> None:
-        """
-        Delete a file from the client associated to the given id.
-        """
-        logger.trace(f"Deleting from S3: {key})")
-
-        try:
-            self.s3_client.delete_object(Bucket=self.bucket_name, Key=key)
-        except self.s3_client.exceptions.NoSuchKey:
-            raise KeyError(f"No such key: {key}")
-
-    def __iter__(self) -> typing.Iterable[str]:
-        """
-        Yield all ids found in the work folder.
-        """
-        yield from self.get_list_of_objects()
-
-    def __len__(self) -> int:
-        """
-        Count the number of keys found in the work folder.
-        """
-        return sum(1 for _ in self.get_list_of_objects())
-
-    def get_key_from_id(self, app_id: typing.Union[int, str]) -> str:
-        """
-        Get an s3 key based upon the app_id. If app_id is empty, throw an exception.
-        """
-        if not str(app_id):
-            raise RuntimeError(f"You must supply a non-empty app_id: got ({app_id=})")
-        return self._key_template.format(app_id=app_id)
-
-    def get_app_id_from_key(self, key: str) -> str:
-        """
-        Get the app_id based upon an s3 key.
-        """
-        match = re.search(self._get_id_re, key)
-        if not match:
-            raise ValueError(f"Impossible to get id from {key=}")
-        return match.group("id")
-
-    def get_list_of_objects(self) -> typing.Iterable[str]:
-        """
-        Yield the keys found in the work folder.
-        Raise 404 when facing connection errors.
-        """
-        try:
-            paginator = self.s3_client.get_paginator("list_objects_v2")
-            for page in paginator.paginate(Bucket=self.bucket_name, Prefix=self.folder_name):
-                contents = page.get("Contents", [])
-                if not contents:
-                    break
-                for obj in contents:
-                    yield obj["Key"]
-        except BotoCoreError:
-            raise RuntimeError("Not possible to retrieve information from S3")
-
-    @read_only_protection
-    def clear(self):
-        """
-        Clear all objects from work folder in this bucket.
-        """
-        logger.debug(f"Clearing folder {self.folder_name} in the bucket {self.bucket_name}")
-        super().clear()
-
-    @read_only_protection
-    def ensure_bucket(self):
-        """
-        Ensure that the bucket exists. Skip creation if it already exists.
-        """
-        logger.debug(f"Ensuring bucket {self.bucket_name} exists.")
-        with SlurpException.handle_errors(
-            self.s3_client.exceptions.BucketAlreadyExists,
-            re_raise=False,
-            do_except=lambda *_: logger.debug(f"Bucket {self.bucket_name} already exists. Skipping creation"),
-        ):
-            self.s3_client.create_bucket(Bucket=self.bucket_name)
-            logger.debug(f"Bucket {self.bucket_name} created")
 
 
 def build_managers():
@@ -173,14 +23,14 @@ def build_managers():
     db_gen = namedtuple("db_generation", "legacy nextgen")
     s3_folder = namedtuple("Folder", "applications jobscripts")
 
-    client = db_gen(
-        legacy=boto3.client(
+    s3_client = db_gen(
+        legacy=client(
             "s3",
             endpoint_url=settings.LEGACY_S3_ENDPOINT_URL,
             aws_access_key_id=settings.LEGACY_AWS_ACCESS_KEY_ID,
             aws_secret_access_key=settings.LEGACY_AWS_SECRET_ACCESS_KEY,
         ),
-        nextgen=boto3.client(
+        nextgen=client(
             "s3",
             endpoint_url=settings.NEXTGEN_S3_ENDPOINT_URL,
             aws_access_key_id=settings.NEXTGEN_AWS_ACCESS_KEY_ID,
@@ -190,29 +40,24 @@ def build_managers():
 
     s3man = s3_folder(
         applications=db_gen(
-            legacy=S3Manager(
-                client.legacy,
-                "jobbergate-resources",
-                "jobbergate.tar.gz",
-                bucket_name=settings.LEGACY_S3_BUCKET_NAME,
-                read_only=True,
+            legacy=FileManagerReadOnly(
+                engine=EngineS3(
+                    s3_client.legacy,
+                    settings.LEGACY_S3_BUCKET_NAME,
+                    "jobbergate-resources",
+                ),
             ),
-            nextgen=S3Manager(
-                client.nextgen,
-                "applications",
-                "jobbergate.tar.gz",
+            nextgen=ApplicationFileManager(
                 bucket_name=settings.NEXTGEN_S3_BUCKET_NAME,
-                read_only=False,
+                s3_client=s3_client.nextgen,
             ),
         ),
         jobscripts=db_gen(
             legacy=None,
-            nextgen=S3Manager(
-                client.nextgen,
-                "job-scripts",
-                "jobbergate.txt",
-                bucket_name=settings.NEXTGEN_S3_BUCKET_NAME,
-                read_only=False,
+            nextgen=FileManager(
+                engine=EngineS3(s3_client.nextgen, settings.NEXTGEN_S3_BUCKET_NAME, "job-scripts"),
+                transformation_list=LIST_OF_TRANSFORMATIONS,
+                key_mapping=KeyMappingNumeratedFolder("jobbergate.txt"),
             ),
         ),
     )
@@ -220,6 +65,22 @@ def build_managers():
     return s3man
 
 
+@contextlib.contextmanager
+def get_upload_files_from_tar(s3_obj):
+
+    with tempfile.TemporaryDirectory() as tmpdir:
+        tmp_path = Path(tmpdir)
+        with tarfile.open(fileobj=BytesIO(s3_obj), mode="r:gz") as tar:
+            tar.extractall(tmp_path)
+
+        with contextlib.ExitStack() as stack:
+            yield [
+                UploadFile(path.name, stack.enter_context(open(path)), "text/plain")
+                for path in tmp_path.rglob("*")
+                if path.is_file()
+            ]
+
+
 def transfer_s3(s3man, applications_map):
     """
     Transfer data from legacy s3 bucket to nextgen s3 bucket.
@@ -229,36 +90,51 @@ def transfer_s3(s3man, applications_map):
     the object. Otherwise put the object into the nextgen s3 bucket with the application
     id mapped to the appropriate nextgen application.
     """
-    logger.info("Transferring S3 data from legacy to nextgen store")
-    s3man.nextgen.ensure_bucket()
+    logger.info(
+        "Transferring S3 data from legacy to nextgen store, "
+        f"{len(applications_map)} applications are expected, "
+        f"{len(s3man.legacy)} objects were found at the legacy bucket."
+    )
+
+    s3man.nextgen.source_files.engine.ensure_bucket()
+
+    legacy_key_mapping = KeyMappingNumeratedFolder("jobbergate.tar.gz")
+
     bad_pattern_skips = 0
     missing_id_skips = 0
+    error_when_uploading = 0
     successful_transfers = 0
     transferred_ids = []
     for legacy_key in s3man.legacy.keys():
         try:
-            legacy_application_id = s3man.legacy.get_app_id_from_key(legacy_key)
-        except ValueError:
+            legacy_application_id = legacy_key_mapping.get_dict_key_from_engine(legacy_key)
+        except (ValueError, TypeError):
             bad_pattern_skips += 1
-            logger.warning(f"Bad pattern: {legacy_key=}")
+            logger.warning(f"Bad pattern: {legacy_application_id=}")
             continue
         nextgen_application_id = applications_map.get(int(legacy_application_id))
         if not nextgen_application_id:
             missing_id_skips += 1
-            logger.warning(f"Missing id: {legacy_key=}")
+            logger.warning(f"Missing id: {legacy_application_id=}")
             continue
 
-        legacy_obj = s3man.legacy.get(legacy_key)
-        nextgen_key = s3man.nextgen.get_key_from_id(nextgen_application_id)
-        s3man.nextgen[nextgen_key] = legacy_obj
-        transferred_ids.append(nextgen_application_id)
-        successful_transfers += 1
-        logger.trace(f"Successful transfer: {legacy_key=}")
+        legacy_obj = s3man.legacy[legacy_key]
+        try:
+            with get_upload_files_from_tar(legacy_obj) as upload_files:
+                s3man.nextgen.write_to_s3(nextgen_application_id, upload_files, remove_previous_files=True)
+        except Exception:
+            error_when_uploading += 1
+            logger.warning(f"Error when uploading the files: {legacy_application_id=}")
+        else:
+            transferred_ids.append(nextgen_application_id)
+            successful_transfers += 1
+            logger.trace(f"Successful transfer: {legacy_application_id=}")
 
     logger.info(f"Skipped {bad_pattern_skips} objects due to unparsable key")
     logger.info(
         f"Skipped {missing_id_skips} objects due to missing application_id (files on S3 but id not on nextgen database)"
     )
+    logger.info(f"Skipped {error_when_uploading} unprocessable objects")
     logger.info(f"Finished transferring {successful_transfers} objects")
 
     return transferred_ids


Test patch:
diff --git a/.github/workflows/test_on_push.yaml b/.github/workflows/test_on_push.yaml
index 812d0c7f..deccda65 100644
--- a/.github/workflows/test_on_push.yaml
+++ b/.github/workflows/test_on_push.yaml
@@ -31,6 +31,8 @@ jobs:
           python-version: '3.8'
           architecture: 'x64'
       - uses: Gr1N/setup-poetry@v7
+        with:
+          poetry-version: 1.1.11
       - name: "run quality control checks"
         working-directory: jobbergate-api
         env:
@@ -53,6 +55,8 @@ jobs:
           python-version: '3.6'
           architecture: 'x64'
       - uses: Gr1N/setup-poetry@v7
+        with:
+          poetry-version: 1.1.11
       - name: "run quality control checks"
         working-directory: jobbergate-cli
         env:
diff --git a/jobbergate-api/jobbergate_api/tests/apps/applications/test_application_files.py b/jobbergate-api/jobbergate_api/tests/apps/applications/test_application_files.py
new file mode 100644
index 00000000..24236072
--- /dev/null
+++ b/jobbergate-api/jobbergate_api/tests/apps/applications/test_application_files.py
@@ -0,0 +1,298 @@
+"""
+Test application files.
+"""
+
+import contextlib
+
+import pytest
+from fastapi import UploadFile
+
+from jobbergate_api.apps.applications.application_files import (
+    APPLICATION_CONFIG_FILE_NAME,
+    APPLICATION_SOURCE_FILE_NAME,
+    APPLICATION_TEMPLATE_FOLDER,
+    APPLICATIONS_WORK_DIR,
+    ApplicationFiles,
+)
+from jobbergate_api.apps.applications.schemas import ApplicationConfig, JobbergateConfig
+
+
+class TestApplicationConfig:
+    """
+    Test the application config base model.
+    """
+
+    def test_get_from_yaml_file_without_extra_params__success(self, dummy_application_config):
+        """
+        Test the model can be loaded from a yaml file without extra params.
+        """
+        desired_application_config = ApplicationConfig(
+            application_config={"job_name": "rats", "partitions": ["debug", "partition1"]},
+            jobbergate_config=JobbergateConfig(
+                default_template="test_job_script.sh",
+                supporting_files=["test_job_script.sh"],
+                supporting_files_output_name={"test_job_script.sh": ["support_file_b.py"]},
+                template_files=["templates/test_job_script.sh"],
+                job_script_name=None,
+                output_directory=".",
+            ),
+        )
+
+        actual_application_config = ApplicationConfig.get_from_yaml_file(dummy_application_config)
+
+        assert actual_application_config == desired_application_config
+
+    def test_get_from_yaml_file_including_extra_params__success(self, dummy_application_config):
+        """
+        Test the model can be loaded from a yaml file with extra params.
+        """
+        user_supplied_parameters = dict(
+            application_config=dict(job_name="testing"),
+            jobbergate_config=dict(default_template="another_test_job_script.sh"),
+        )
+
+        desired_application_config = ApplicationConfig(
+            application_config={"job_name": "testing"},
+            jobbergate_config=JobbergateConfig(
+                default_template="another_test_job_script.sh",
+            ),
+        )
+
+        actual_application_config = ApplicationConfig.get_from_yaml_file(
+            dummy_application_config, user_supplied_parameters
+        )
+
+        assert actual_application_config == desired_application_config
+
+    def test_get_from_yaml_file_validation_error(self):
+        """
+        Test that ValueError is raised when the file does not have the necessary schema.
+        """
+        with pytest.raises(ValueError):
+            ApplicationConfig.get_from_yaml_file("key: value")
+
+
+@pytest.fixture
+def make_uploaded_files():
+    """
+    Provide a fixture to use as a context manager that builds a list of uploaded files.
+    """
+
+    @contextlib.contextmanager
+    def _helper(*file_paths):
+        """
+        Context manager that opens the file(s) and yields the ``files`` param from it.
+        """
+        with contextlib.ExitStack() as stack:
+            yield [
+                UploadFile(path.name, stack.enter_context(open(path, "rb")), "text/plain")
+                for path in file_paths
+            ]
+
+    return _helper
+
+
+@pytest.fixture(scope="function")
+def make_uploaded_files_filled(
+    make_dummy_file,
+    make_uploaded_files,
+    dummy_application_source_file,
+    dummy_template,
+    dummy_application_config,
+):
+    """
+    Fixture to make a list of uploaded files.
+    """
+    with make_uploaded_files(
+        make_dummy_file("jobbergate.py", content=dummy_application_source_file),
+        make_dummy_file("template-1.j2", content=dummy_template),
+        make_dummy_file("template-2.jinja2", content=dummy_template),
+        make_dummy_file("jobbergate.yaml", content=dummy_application_config),
+    ) as uploaded_files:
+        yield uploaded_files
+
+
+class TestApplicationFiles:
+    """
+    Test application files.
+    """
+
+    def test_get_application_files_from_upload_files(
+        self,
+        make_uploaded_files_filled,
+        dummy_application_source_file,
+        dummy_template,
+        dummy_application_config,
+    ):
+        """
+        Test that ApplicationFiles can be obtained from a list of uploaded files.
+        """
+        application_files = ApplicationFiles.get_from_upload_files(make_uploaded_files_filled)
+
+        assert isinstance(application_files, ApplicationFiles)
+
+        assert application_files.config_file == dummy_application_config
+        assert application_files.source_file == dummy_application_source_file
+        assert application_files.templates == {
+            "template-1.j2": dummy_template,
+            "template-2.jinja2": dummy_template,
+        }
+
+    def test_write_application_files_to_s3(
+        self,
+        make_uploaded_files_filled,
+        dummy_application_source_file,
+        dummy_template,
+        dummy_application_config,
+        mocked_file_manager_factory,
+    ):
+        """
+        Test if the applications files are written to S3 as expected.
+
+        To do so, a list of uploaded files is created, than  we verify if each file
+        went to the correct file manager.
+        """
+        application_id = 1
+
+        ApplicationFiles.get_from_upload_files(
+            make_uploaded_files_filled,
+        ).write_to_s3(application_id)
+
+        work_dir = mocked_file_manager_factory / APPLICATIONS_WORK_DIR / str(application_id)
+        templates_dir = work_dir / APPLICATION_TEMPLATE_FOLDER
+
+        application_source_path = work_dir / APPLICATION_SOURCE_FILE_NAME
+        assert application_source_path.is_file()
+        assert application_source_path.read_text() == dummy_application_source_file
+
+        application_config_path = work_dir / APPLICATION_CONFIG_FILE_NAME
+        assert application_config_path.is_file()
+        assert application_config_path.read_text() == dummy_application_config
+
+        assert templates_dir.is_dir()
+
+        template_path_1 = templates_dir / "template-1.j2"
+        assert template_path_1.is_file()
+        assert template_path_1.read_text() == dummy_template
+
+        template_path_2 = templates_dir / "template-2.jinja2"
+        assert template_path_2.is_file()
+        assert template_path_2.read_text() == dummy_template
+
+    def test_get_application_files_from_s3(
+        self,
+        dummy_application_source_file,
+        dummy_application_config,
+        dummy_template,
+        mocked_file_manager_factory,
+    ):
+        """
+        Test if the application files are loaded from S3 as expected.
+
+        To do so, dummy test files are added to the file managers and than loaded.
+        """
+        application_id = 1
+        work_dir = mocked_file_manager_factory / APPLICATIONS_WORK_DIR / str(application_id)
+        templates_dir = work_dir / APPLICATION_TEMPLATE_FOLDER
+        templates_dir.mkdir(parents=True, exist_ok=True)
+
+        application_path = work_dir / APPLICATION_SOURCE_FILE_NAME
+        application_path.write_text(dummy_application_source_file)
+
+        config_path = work_dir / APPLICATION_CONFIG_FILE_NAME
+        config_path.write_text(dummy_application_config)
+
+        template_path_1 = templates_dir / "template-1.j2"
+        template_path_1.write_text(dummy_template)
+
+        template_path_2 = templates_dir / "template-2.jinja2"
+        template_path_2.write_text(dummy_template)
+
+        application_files = ApplicationFiles.get_from_s3(application_id)
+
+        assert isinstance(application_files, ApplicationFiles)
+
+        assert application_files.source_file == dummy_application_source_file
+
+        assert application_files.config_file == dummy_application_config
+
+        assert application_files.templates == {
+            "template-1.j2": dummy_template,
+            "template-2.jinja2": dummy_template,
+        }
+
+    def test_delete_application_files_from_s3(
+        self,
+        dummy_application_source_file,
+        dummy_template,
+        mocked_file_manager_factory,
+    ):
+        """
+        Test if the applications files are deleted from S3 as expected.
+
+        To do so, test files are added to both file managers. Once one application id
+        is deleted, all templated files related to it are deleted as well as its source
+        file. Source files from any other id are expected to be preserved.
+        """
+        application_id = 1
+
+        work_dir = mocked_file_manager_factory / APPLICATIONS_WORK_DIR / str(application_id)
+        templates_dir = work_dir / APPLICATION_TEMPLATE_FOLDER
+        templates_dir.mkdir(parents=True, exist_ok=True)
+
+        application_path = work_dir / APPLICATION_SOURCE_FILE_NAME
+        application_path.write_text(dummy_application_source_file)
+
+        template_path_1 = templates_dir / "template-1.j2"
+        template_path_1.write_text(dummy_template)
+
+        template_path_2 = templates_dir / "template-2.jinja2"
+        template_path_2.write_text(dummy_template)
+
+        ApplicationFiles.delete_from_s3(application_id)
+
+        assert not application_path.is_file()
+        assert not template_path_1.is_file()
+        assert not template_path_2.is_file()
+
+    def test_delete_just_act_on_one_id(
+        self,
+        make_uploaded_files_filled,
+        mocked_file_manager_factory,
+    ):
+        """
+        Test that delete does not delete any file from any other application.
+        """
+        remaining_application_id = 1
+        another_application_id = 2
+
+        desired_application_files = ApplicationFiles.get_from_upload_files(
+            make_uploaded_files_filled,
+        )
+
+        desired_application_files.write_to_s3(remaining_application_id)
+        desired_application_files.write_to_s3(another_application_id)
+
+        ApplicationFiles.delete_from_s3(another_application_id)
+
+        deleted_application_files = ApplicationFiles.get_from_s3(another_application_id)
+        assert deleted_application_files.dict(exclude_unset=True, exclude_defaults=True) == {}
+
+        actual_application_files = ApplicationFiles.get_from_s3(remaining_application_id)
+        assert desired_application_files == actual_application_files
+
+    def test_complete_workflow(self, mocked_file_manager_factory, make_uploaded_files_filled):
+        """
+        End-to-end test, getting application files from uploaded files, than write/read them.
+        """
+        application_id = 1
+
+        desired_application_files = ApplicationFiles.get_from_upload_files(
+            make_uploaded_files_filled,
+        )
+
+        desired_application_files.write_to_s3(application_id)
+
+        actual_application_files = ApplicationFiles.get_from_s3(application_id)
+
+        assert desired_application_files == actual_application_files
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 a938d6a1..b8101cf4 100644
--- a/jobbergate-api/jobbergate_api/tests/apps/applications/test_routers.py
+++ b/jobbergate-api/jobbergate_api/tests/apps/applications/test_routers.py
@@ -8,10 +8,10 @@
 import pytest
 from fastapi import status
 
+from jobbergate_api.apps.applications.application_files import ApplicationFiles
 from jobbergate_api.apps.applications.models import applications_table
 from jobbergate_api.apps.applications.schemas import ApplicationPartialResponse
 from jobbergate_api.apps.permissions import Permissions
-from jobbergate_api.s3_manager import ApplicationFiles
 from jobbergate_api.storage import database, fetch_instance
 
 
@@ -108,7 +108,7 @@ async def test_create_without_application_name(
 
 
 @pytest.mark.asyncio
-@mock.patch("jobbergate_api.apps.applications.routers.delete_application_files_from_s3")
+@mock.patch("jobbergate_api.apps.applications.routers.ApplicationFiles.delete_from_s3")
 async def test_delete_application_no_file_uploaded(
     mocked_application_deleter, client, application_data, inject_security_header
 ):
@@ -137,7 +137,7 @@ async def test_delete_application_no_file_uploaded(
 
 
 @pytest.mark.asyncio
-@mock.patch("jobbergate_api.apps.applications.routers.delete_application_files_from_s3")
+@mock.patch("jobbergate_api.apps.applications.routers.ApplicationFiles.delete_from_s3")
 async def test_delete_application_with_uploaded_file(
     mocked_application_deleter, client, application_data, inject_security_header
 ):
@@ -167,7 +167,7 @@ async def test_delete_application_with_uploaded_file(
 
 
 @pytest.mark.asyncio
-@mock.patch("jobbergate_api.apps.applications.routers.delete_application_files_from_s3")
+@mock.patch("jobbergate_api.apps.applications.routers.ApplicationFiles.delete_from_s3")
 async def test_delete_application_by_identifier(
     mocked_application_deleter, client, fill_application_data, inject_security_header
 ):
@@ -310,13 +310,12 @@ async def test_get_application_by_id__files_not_uploaded(
     assert data["id"] == inserted_id
     assert data["application_identifier"] == "app1"
     assert data["application_uploaded"] is False
-    assert data["application_config"] is None
     assert data["application_source_file"] is None
     assert data["application_templates"] is None
 
 
 @pytest.mark.asyncio
-@mock.patch("jobbergate_api.apps.applications.routers.get_application_files_from_s3")
+@mock.patch("jobbergate_api.apps.applications.routers.ApplicationFiles.get_from_s3")
 async def test_get_application_by_id__files_uploaded(
     mocked_get_application_files_from_s3,
     client,
@@ -339,7 +338,6 @@ async def test_get_application_by_id__files_uploaded(
         values=fill_application_data(
             application_identifier="app1",
             application_uploaded=True,
-            application_config=dummy_application_config,
         ),
     )
     await database.execute(
@@ -350,6 +348,7 @@ async def test_get_application_by_id__files_uploaded(
     mocked_get_application_files_from_s3.return_value = ApplicationFiles(
         templates={"test_job_script.sh": dummy_template},
         source_file=dummy_application_source_file,
+        config_file=dummy_application_config,
     )
 
     count = await database.fetch_all("SELECT COUNT(*) FROM applications")
@@ -609,7 +608,7 @@ async def test_get_applications__with_search_param(
     This test proves that the user making the request will be shown applications that match the search string.
     We show this by creating applications and using various search queries to match against them.
 
-    Assert that the response to GET /applications?search=<search temrms> includes correct matches.
+    Assert that the response to GET /applications?search=<search terms> includes correct matches.
     """
     await database.execute_many(
         query=applications_table.insert(),
@@ -890,9 +889,14 @@ async def test_update_application_bad_permission(
 
 
 @pytest.mark.asyncio
-@mock.patch("jobbergate_api.apps.applications.routers.write_application_files_to_s3")
+@mock.patch("jobbergate_api.apps.applications.routers.ApplicationFiles.write_to_s3")
+@mock.patch(
+    "jobbergate_api.apps.applications.routers.ApplicationFiles.get_from_upload_files",
+    return_value=ApplicationFiles(),
+)
 async def test_upload_file__works_with_small_file(
     mocked_application_writer,
+    mocked_application_get_upload,
     client,
     inject_security_header,
     fill_application_data,
@@ -927,13 +931,14 @@ async def test_upload_file__works_with_small_file(
 
     assert response.status_code == status.HTTP_201_CREATED
     mocked_application_writer.assert_called_once()
+    mocked_application_get_upload.assert_called_once()
 
     application = await fetch_instance(inserted_id, applications_table, ApplicationPartialResponse)
     assert application.application_uploaded
 
 
 @pytest.mark.asyncio
-@mock.patch("jobbergate_api.apps.applications.routers.write_application_files_to_s3")
+@mock.patch("jobbergate_api.apps.applications.routers.ApplicationFiles.write_to_s3")
 async def test_upload_file__fails_with_413_on_large_file(
     mocked_application_writer,
     client,
@@ -960,7 +965,7 @@ async def test_upload_file__fails_with_413_on_large_file(
 
 
 @pytest.mark.asyncio
-@mock.patch("jobbergate_api.apps.applications.routers.delete_application_files_from_s3")
+@mock.patch("jobbergate_api.apps.applications.routers.ApplicationFiles.delete_from_s3")
 async def test_delete_file(mocked_application_deleter, client, inject_security_header, fill_application_data):
     """
     Test that a file is deleted.
diff --git a/jobbergate-api/jobbergate_api/tests/apps/applications/test_schemas.py b/jobbergate-api/jobbergate_api/tests/apps/applications/test_schemas.py
new file mode 100644
index 00000000..f9a4e182
--- /dev/null
+++ b/jobbergate-api/jobbergate_api/tests/apps/applications/test_schemas.py
@@ -0,0 +1,45 @@
+"""
+Test schemas for Jobbergate applications.
+"""
+
+from jobbergate_api.apps.applications.schemas import JobbergateConfig
+
+
+class TestJobbergateConfig:
+    """
+    Test jobbergate configuration.
+    """
+
+    def test_get_supporting_files_from_supporting_files_output_name__success(self):
+        """
+        Test that supporting_files is obtained from the keys of supporting_files_output_name.
+        """
+        config = JobbergateConfig(supporting_files_output_name=dict(foo="string", bar="string"))
+
+        assert config.supporting_files == ["foo", "bar"]
+
+    def test_get_supporting_files_from_supporting_files_output_name__no_replacement(self):
+        """
+        Test that supporting_files is not overwritten when provided initially.
+        """
+        config = JobbergateConfig(
+            supporting_files_output_name=dict(foo="string", bar="string"), supporting_files=["baz"]
+        )
+
+        assert config.supporting_files == ["baz"]
+
+    def test_get_supporting_files_from_supporting_files_output_name__missing_value(self):
+        """
+        Test that supporting_files is still None if supporting_files_output_name is not provided.
+        """
+        config = JobbergateConfig()
+
+        assert config.supporting_files is None
+
+    def test_supporting_files_output_name_provided_as_string(self):
+        """
+        Test that string values at supporting_files_output_name are converted to lists of strings.
+        """
+        config = JobbergateConfig(supporting_files_output_name=dict(foo="bar", baz=["qux"]))
+
+        assert config.supporting_files_output_name == dict(foo=["bar"], baz=["qux"])
diff --git a/jobbergate-api/jobbergate_api/tests/apps/job_scripts/test_job_script_files.py b/jobbergate-api/jobbergate_api/tests/apps/job_scripts/test_job_script_files.py
new file mode 100644
index 00000000..98aefd98
--- /dev/null
+++ b/jobbergate-api/jobbergate_api/tests/apps/job_scripts/test_job_script_files.py
@@ -0,0 +1,283 @@
+"""
+Test job-script files.
+"""
+
+from pathlib import Path
+from textwrap import dedent
+
+import pytest
+
+from jobbergate_api.apps.applications.application_files import ApplicationFiles
+from jobbergate_api.apps.job_scripts.job_script_files import (
+    JOBSCRIPTS_MAIN_FILE_FOLDER,
+    JOBSCRIPTS_SUPPORTING_FILES_FOLDER,
+    JOBSCRIPTS_WORK_DIR,
+    JobScriptFiles,
+    inject_sbatch_params,
+)
+
+
+@pytest.fixture
+def job_script_data_as_string():
+    """
+    Provide a fixture that returns an example of a default application script.
+    """
+    content = dedent(
+        """
+        #!/bin/bash
+
+        #SBATCH --job-name=rats
+        #SBATCH --partition=debug
+        #SBATCH --output=sample-%j.out
+
+
+        echo $SLURM_TASKS_PER_NODE
+        echo $SLURM_SUBMIT_DIR
+        """
+    ).strip()
+    return content
+
+
+@pytest.fixture
+def new_job_script_data_as_string():
+    """
+    Provide a fixture that returns an application script after the injection of the sbatch params.
+    """
+    content = dedent(
+        """
+        #!/bin/bash
+
+        #SBATCH --job-name=rats
+        #SBATCH --comment=some_comment
+        #SBATCH --nice=-1
+        #SBATCH -N 10
+        #SBATCH --partition=debug
+        #SBATCH --output=sample-%j.out
+
+
+        echo $SLURM_TASKS_PER_NODE
+        echo $SLURM_SUBMIT_DIR
+        """
+    ).strip()
+    return content
+
+
+@pytest.fixture
+def sbatch_params():
+    """
+    Provide a fixture that returns string content of the argument --sbatch-params.
+    """
+    return ["--comment=some_comment", "--nice=-1", "-N 10"]
+
+
+def test_inject_sbatch_params(job_script_data_as_string, sbatch_params, new_job_script_data_as_string):
+    """
+    Test the injection of sbatch params in a default application script.
+    """
+    injected_string = inject_sbatch_params(job_script_data_as_string, sbatch_params)
+    assert injected_string == new_job_script_data_as_string
+
+
+@pytest.fixture
+def dummy_application_files(
+    dummy_application_source_file,
+    dummy_application_config,
+    dummy_template,
+):
+    """
+    Test fixture with dummy application files.
+    """
+    return ApplicationFiles(
+        templates={"test_job_script.sh": dummy_template},
+        source_file=dummy_application_source_file,
+        config_file=dummy_application_config,
+    )
+
+
+class TestJobScriptFiles:
+    """
+    Test JobScriptFiles.
+    """
+
+    def test_check_main_file_path_is_in_files_keys__success(self):
+        """
+        Test a case were JobScriptFiles is created with success.
+        """
+        jobscript_files = JobScriptFiles(
+            main_file_path="jobbergate.sh", files={"jobbergate.sh": "dummy-file-content"}
+        )
+
+        assert isinstance(jobscript_files, JobScriptFiles)
+
+    def test_check_main_file_path_is_in_files_keys__error_main_file_path(self):
+        """
+        Test that ValueError is raised when the main file is not included in the files.
+        """
+        with pytest.raises(ValueError):
+            JobScriptFiles(
+                main_file_path="wrong-filename",
+                files={"jobbergate.sh": "dummy-file-content"},
+            )
+
+    def test_get_from_s3(self, mocked_file_manager_factory):
+        """
+        Test that JobScriptFiles can be obtained from the file manager.
+        """
+        job_script_id = 1
+
+        work_dir = mocked_file_manager_factory / JOBSCRIPTS_WORK_DIR / str(job_script_id)
+        output_dir = Path("test-output-dir")
+
+        main_file_name = output_dir / "jobbergate.sh"
+        main_file_content = "test-main-file"
+        main_file_path = work_dir / JOBSCRIPTS_MAIN_FILE_FOLDER / main_file_name
+        main_file_path.parent.mkdir(parents=True, exist_ok=True)
+        main_file_path.write_text(main_file_content)
+
+        supporting_file_name = output_dir / "support.sh"
+        supporting_file_content = "test-support-file"
+        supporting_file_path = work_dir / JOBSCRIPTS_SUPPORTING_FILES_FOLDER / supporting_file_name
+        supporting_file_path.parent.mkdir(parents=True, exist_ok=True)
+        supporting_file_path.write_text(supporting_file_content)
+
+        desired_jobscript_files = JobScriptFiles(
+            main_file_path=main_file_name,
+            files={
+                main_file_name: main_file_content,
+                supporting_file_name: supporting_file_content,
+            },
+        )
+
+        actual_jobscript_files = JobScriptFiles.get_from_s3(job_script_id)
+
+        assert desired_jobscript_files == actual_jobscript_files
+
+    def test_get_from_s3__no_main_file(self, mocked_file_manager_factory):
+        """
+        Test that ValueError is raised when no main file is found.
+        """
+        job_script_id = 1
+        with pytest.raises(
+            ValueError,
+            match="One and only one main file is expected for a job-script, found 0",
+        ):
+            JobScriptFiles.get_from_s3(job_script_id)
+
+    def test_delete_from_s3(self, mocked_file_manager_factory):
+        """
+        Test that JobScriptFiles are deleted as expected.
+        """
+        job_script_id = 1
+
+        work_dir = mocked_file_manager_factory / JOBSCRIPTS_WORK_DIR / str(job_script_id)
+        output_dir = Path("test-output-dir")
+
+        main_file_name = output_dir / "jobbergate.sh"
+        main_file_content = "test-main-file"
+        main_file_path = work_dir / JOBSCRIPTS_MAIN_FILE_FOLDER / main_file_name
+        main_file_path.parent.mkdir(parents=True, exist_ok=True)
+        main_file_path.write_text(main_file_content)
+
+        supporting_file_name = output_dir / "support.sh"
+        supporting_file_content = "test-support-file"
+        supporting_file_path = work_dir / JOBSCRIPTS_SUPPORTING_FILES_FOLDER / supporting_file_name
+        supporting_file_path.parent.mkdir(parents=True, exist_ok=True)
+        supporting_file_path.write_text(supporting_file_content)
+
+        assert main_file_path.is_file()
+        assert supporting_file_path.is_file()
+
+        JobScriptFiles.delete_from_s3(job_script_id)
+
+        assert not main_file_path.is_file()
+        assert not supporting_file_path.is_file()
+
+    def test_write_to_s3(self, mocked_file_manager_factory):
+        """
+        Test that JobScriptFiles are written as expected.
+        """
+        job_script_id = 1
+
+        work_dir = mocked_file_manager_factory / JOBSCRIPTS_WORK_DIR / str(job_script_id)
+        output_dir = Path("test-output-dir")
+
+        main_file_name = output_dir / "jobbergate.sh"
+        main_file_content = "test-main-file"
+        main_file_path = work_dir / JOBSCRIPTS_MAIN_FILE_FOLDER / main_file_name
+
+        supporting_file_name = output_dir / "support.sh"
+        supporting_file_content = "test-support-file"
+        supporting_file_path = work_dir / JOBSCRIPTS_SUPPORTING_FILES_FOLDER / supporting_file_name
+
+        JobScriptFiles(
+            main_file_path=main_file_name,
+            files={
+                main_file_name: main_file_content,
+                supporting_file_name: supporting_file_content,
+            },
+        ).write_to_s3(job_script_id)
+
+        assert main_file_path.is_file()
+        assert main_file_path.read_text() == main_file_content
+
+        assert supporting_file_path.is_file()
+        assert supporting_file_path.read_text() == supporting_file_content
+
+    def test_io_integration_with_s3(self, mocked_file_manager_factory):
+        """
+        End-to-end test where the JobScriptFiles are written and read from s3.
+        """
+        job_script_id = 1
+        output_dir = Path("test-output-dir")
+
+        main_file_name = output_dir / "jobbergate.sh"
+        main_file_content = "test-main-file"
+
+        supporting_file_name = output_dir / "support.sh"
+        supporting_file_content = "test-support-file"
+
+        actual_jobscript_files = JobScriptFiles(
+            main_file_path=main_file_name,
+            files={
+                main_file_name: main_file_content,
+                supporting_file_name: supporting_file_content,
+            },
+        )
+
+        actual_jobscript_files.write_to_s3(job_script_id)
+
+        desired_jobscript_files = JobScriptFiles.get_from_s3(job_script_id)
+
+        assert desired_jobscript_files == actual_jobscript_files
+
+        JobScriptFiles.delete_from_s3(job_script_id)
+
+        with pytest.raises(
+            ValueError,
+            match="One and only one main file is expected for a job-script, found 0",
+        ):
+            JobScriptFiles.get_from_s3(job_script_id)
+
+    def test_render_from_application__success(
+        self,
+        param_dict,
+        job_script_data_as_string,
+        dummy_application_files,
+    ):
+        """
+        Test that JobScriptFiles can be obtained when rendering ApplicationFiles.
+        """
+        desired_job_script_files = JobScriptFiles(
+            main_file_path="test_job_script.sh",
+            files={
+                "test_job_script.sh": job_script_data_as_string,
+                "support_file_b.py": job_script_data_as_string,
+            },
+        )
+
+        actual_job_script_files = JobScriptFiles.render_from_application(
+            dummy_application_files,
+            param_dict,
+        )
+
+        assert actual_job_script_files == desired_job_script_files
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 9a566d5d..b7be2175 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
@@ -2,25 +2,20 @@
 Tests for the /job-scripts/ endpoint.
 """
 import json
+import pathlib
 from textwrap import dedent
 from unittest import mock
 
 import asyncpg
 import pytest
-from botocore.stub import Stubber
 from fastapi import status
 
+from jobbergate_api.apps.applications.application_files import ApplicationFiles
 from jobbergate_api.apps.applications.models import applications_table
-from jobbergate_api.apps.applications.schemas import ApplicationConfig
+from jobbergate_api.apps.job_scripts.job_script_files import JobScriptFiles
 from jobbergate_api.apps.job_scripts.models import job_scripts_table
-from jobbergate_api.apps.job_scripts.routers import (
-    build_job_script_data_as_string,
-    inject_sbatch_params,
-    render_template,
-)
 from jobbergate_api.apps.job_scripts.schemas import JobScriptPartialResponse, JobScriptResponse
 from jobbergate_api.apps.permissions import Permissions
-from jobbergate_api.s3_manager import ApplicationFiles, s3man_jobscripts
 from jobbergate_api.storage import database
 
 
@@ -29,10 +24,8 @@ def job_script_data_as_string():
     """
     Provide a fixture that returns an example of a default application script.
     """
-    content = json.dumps(
-        {
-            "application.sh": dedent(
-                """
+    content = dedent(
+        """
                 #!/bin/bash
 
                 #SBATCH --job-name=rats
@@ -43,9 +36,7 @@ def job_script_data_as_string():
                 echo $SLURM_TASKS_PER_NODE
                 echo $SLURM_SUBMIT_DIR
                 """
-            ).strip(),
-        }
-    )
+    ).strip()
     return content
 
 
@@ -54,10 +45,8 @@ def new_job_script_data_as_string():
     """
     Provide a fixture that returns an application script after the injection of the sbatch params.
     """
-    content = json.dumps(
-        {
-            "application.sh": dedent(
-                """
+    content = dedent(
+        """
                 #!/bin/bash
 
                 #SBATCH --comment=some_comment
@@ -71,9 +60,7 @@ def new_job_script_data_as_string():
                 echo $SLURM_TASKS_PER_NODE
                 echo $SLURM_SUBMIT_DIR
                 """
-            ).strip(),
-        }
-    )
+    ).strip()
     return content
 
 
@@ -85,22 +72,13 @@ def sbatch_params():
     return ["--comment=some_comment", "--nice=-1", "-N 10"]
 
 
-def test_inject_sbatch_params(job_script_data_as_string, sbatch_params, new_job_script_data_as_string):
-    """
-    Test the injection of sbatch params in a default application script.
-    """
-    injected_string = inject_sbatch_params(job_script_data_as_string, sbatch_params)
-    assert injected_string == new_job_script_data_as_string
-
-
 @pytest.mark.asyncio
 @database.transaction(force_rollback=True)
-@mock.patch("jobbergate_api.apps.job_scripts.routers.get_application_files_from_s3")
+@mock.patch("jobbergate_api.apps.job_scripts.job_script_files.JobScriptFiles.write_to_s3")
 async def test_create_job_script(
-    mocked_get_application_files_from_s3,
+    mocked_write_job_script_files_to_s3,
     fill_application_data,
     job_script_data,
-    job_script_data_as_string,
     fill_job_script_data,
     param_dict,
     client,
@@ -109,6 +87,8 @@ async def test_create_job_script(
     dummy_application_config,
     dummy_application_source_file,
     dummy_template,
+    job_script_data_as_string,
+    mocked_file_manager_factory,
 ):
     """
     Test POST /job_scripts/ correctly creates a job_script.
@@ -121,46 +101,44 @@ async def test_create_job_script(
         query=applications_table.insert(),
         values=fill_application_data(
             application_owner_email="owner1@org.com",
-            application_config=dummy_application_config,
             application_uploaded=True,
         ),
     )
 
-    mocked_get_application_files_from_s3.return_value = ApplicationFiles(
+    ApplicationFiles(
         templates={"test_job_script.sh": dummy_template},
         source_file=dummy_application_source_file,
-    )
+        config_file=dummy_application_config,
+    ).write_to_s3(inserted_application_id)
 
     inject_security_header("owner1@org.com", Permissions.JOB_SCRIPTS_EDIT)
-    with mock.patch("jobbergate_api.apps.job_scripts.routers.s3man_jobscripts", {}) as s3:
-        with time_frame() as window:
-            response = await client.post(
-                "/jobbergate/job-scripts/",
-                json=fill_job_script_data(
-                    application_id=inserted_application_id,
-                    param_dict=param_dict,
-                ),
-            )
+    with time_frame() as window:
+        response = await client.post(
+            "/jobbergate/job-scripts/",
+            json=fill_job_script_data(
+                application_id=inserted_application_id,
+                param_dict=param_dict,
+            ),
+        )
 
     assert response.status_code == status.HTTP_201_CREATED
-    mocked_get_application_files_from_s3.assert_called_once_with(inserted_application_id)
 
     id_rows = await database.fetch_all("SELECT id FROM job_scripts")
     assert len(id_rows) == 1
-    assert len(s3) == 1
 
     job_script = JobScriptResponse(**response.json())
 
+    mocked_write_job_script_files_to_s3.assert_called_once_with(job_script.id)
+
     assert job_script.id == id_rows[0][0]
     assert job_script.job_script_name == job_script_data["job_script_name"]
     assert job_script.job_script_owner_email == "owner1@org.com"
     assert job_script.job_script_description is None
-    assert job_script.job_script_data_as_string
+    assert job_script.job_script_files.main_file
     assert job_script.application_id == inserted_application_id
     assert job_script.created_at in window
     assert job_script.updated_at in window
-    assert job_script.job_script_data_as_string == job_script_data_as_string
-    assert s3.get(job_script.id) == job_script_data_as_string
+    assert job_script.job_script_files.main_file == job_script_data_as_string
 
 
 @pytest.mark.asyncio
@@ -184,20 +162,18 @@ async def test_create_job_script_application_not_uploaded(
     )
 
     inject_security_header("owner1@org.com", Permissions.JOB_SCRIPTS_EDIT)
-    with mock.patch("jobbergate_api.apps.job_scripts.routers.s3man_jobscripts", {}) as s3:
-        response = await client.post(
-            "/jobbergate/job-scripts/",
-            json=fill_job_script_data(
-                application_id=inserted_application_id,
-                param_dict=param_dict,
-            ),
-        )
+    response = await client.post(
+        "/jobbergate/job-scripts/",
+        json=fill_job_script_data(
+            application_id=inserted_application_id,
+            param_dict=param_dict,
+        ),
+    )
 
     assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
 
     count = await database.fetch_all("SELECT COUNT(*) FROM job_scripts")
     assert count[0][0] == 0
-    assert len(s3) == 0
 
 
 @pytest.mark.asyncio
@@ -223,20 +199,18 @@ async def test_create_job_script_bad_permission(
     )
 
     inject_security_header("owner1@org.com", "INVALID_PERMISSION")
-    with mock.patch("jobbergate_api.apps.job_scripts.routers.s3man_jobscripts", {}) as s3:
-        response = await client.post(
-            "/jobbergate/job-scripts/",
-            json=fill_job_script_data(
-                application_id=inserted_application_id,
-                param_dict=param_dict,
-            ),
-        )
+    response = await client.post(
+        "/jobbergate/job-scripts/",
+        json=fill_job_script_data(
+            application_id=inserted_application_id,
+            param_dict=param_dict,
+        ),
+    )
 
     assert response.status_code == status.HTTP_403_FORBIDDEN
 
     count = await database.fetch_all("SELECT COUNT(*) FROM job_scripts")
     assert count[0][0] == 0
-    assert len(s3) == 0
 
 
 @pytest.mark.asyncio
@@ -255,21 +229,21 @@ async def test_create_job_script_without_application(
     job_script still does not exists in the database and the correct status code (404) is returned.
     """
     inject_security_header("owner1@org.com", Permissions.JOB_SCRIPTS_EDIT)
-    with mock.patch("jobbergate_api.apps.job_scripts.routers.s3man_jobscripts", {}) as s3:
-        response = await client.post(
-            "/jobbergate/job-scripts/",
-            json=fill_job_script_data(application_id=9999, param_dict=param_dict),
-        )
+
+    response = await client.post(
+        "/jobbergate/job-scripts/",
+        json=fill_job_script_data(application_id=9999, param_dict=param_dict),
+    )
+
     assert response.status_code == status.HTTP_404_NOT_FOUND
 
     count = await database.fetch_all("SELECT COUNT(*) FROM job_scripts")
     assert count[0][0] == 0
-    assert len(s3) == 0
 
 
 @database.transaction(force_rollback=True)
 @pytest.mark.asyncio
-@mock.patch("jobbergate_api.apps.job_scripts.routers.get_application_files_from_s3")
+@mock.patch("jobbergate_api.apps.applications.application_files.ApplicationFiles.get_from_s3")
 async def test_create_job_script_file_not_found(
     mocked_get_application_files_from_s3,
     fill_application_data,
@@ -277,7 +251,6 @@ async def test_create_job_script_file_not_found(
     param_dict,
     client,
     inject_security_header,
-    dummy_application_config,
 ):
     """
     Test that is not possible to create a job_script if the application is in the database but not in S3.
@@ -292,15 +265,11 @@ async def test_create_job_script_file_not_found(
         query=applications_table.insert(),
         values=fill_application_data(
             application_owner_email="owner1@org.com",
-            application_config=dummy_application_config,
             application_uploaded=True,
         ),
     )
 
-    mocked_get_application_files_from_s3.return_value = ApplicationFiles(
-        templates={},
-        source_file="",
-    )
+    mocked_get_application_files_from_s3.return_value = ApplicationFiles()
 
     inject_security_header("owner1@org.com", Permissions.JOB_SCRIPTS_EDIT)
     response = await client.post(
@@ -311,7 +280,7 @@ async def test_create_job_script_file_not_found(
         ),
     )
 
-    assert response.status_code == status.HTTP_404_NOT_FOUND
+    assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
 
     count = await database.fetch_all("SELECT COUNT(*) FROM job_scripts")
     assert count[0][0] == 0
@@ -319,9 +288,7 @@ async def test_create_job_script_file_not_found(
 
 @database.transaction(force_rollback=True)
 @pytest.mark.asyncio
-@mock.patch("jobbergate_api.apps.job_scripts.routers.get_application_files_from_s3")
 async def test_create_job_script_unable_to_write_file_to_s3(
-    mocked_get_application_files_from_s3,
     fill_application_data,
     fill_job_script_data,
     param_dict,
@@ -330,6 +297,7 @@ async def test_create_job_script_unable_to_write_file_to_s3(
     dummy_application_config,
     dummy_application_source_file,
     dummy_template,
+    mocked_file_manager_factory,
 ):
     """
     Test that a job script is not added to the database when S3 manager gets an error.
@@ -338,23 +306,23 @@ async def test_create_job_script_unable_to_write_file_to_s3(
         query=applications_table.insert(),
         values=fill_application_data(
             application_owner_email="owner1@org.com",
-            application_config=dummy_application_config,
             application_uploaded=True,
         ),
     )
 
-    mocked_get_application_files_from_s3.return_value = ApplicationFiles(
+    ApplicationFiles(
         templates={"test_job_script.sh": dummy_template},
         source_file=dummy_application_source_file,
-    )
+        config_file=dummy_application_config,
+    ).write_to_s3(inserted_application_id)
 
     actual_id_rows = await database.fetch_all("SELECT id FROM job_scripts")
 
     inject_security_header("owner1@org.com", Permissions.JOB_SCRIPTS_EDIT)
-    with Stubber(s3man_jobscripts.engine.s3_client) as stubber:
-
-        stubber.add_client_error("put_object", "NoSuchKey")
-
+    with mock.patch(
+        "jobbergate_api.apps.job_scripts.job_script_files.JobScriptFiles.write_to_s3",
+        side_effect=KeyError,
+    ):
         response = await client.post(
             "/jobbergate/job-scripts/",
             json=fill_job_script_data(
@@ -370,36 +338,6 @@ async def test_create_job_script_unable_to_write_file_to_s3(
     assert actual_id_rows == desired_id_rows
 
 
-def test_render_template(param_dict_flat, template_files, job_script_data_as_string):
-    """
-    Test correctly rendered template for job_script template.
-    """
-    job_script_rendered = render_template(template_files, param_dict_flat)
-    assert json.loads(job_script_rendered) == json.loads(job_script_data_as_string)
-
-
-@mock.patch("jobbergate_api.apps.job_scripts.routers.get_application_files_from_s3")
-def test_build_job_script_data_as_string(
-    mocked_get_application_files_from_s3,
-    param_dict,
-    job_script_data_as_string,
-    dummy_application_source_file,
-    dummy_template,
-):
-    """
-    Test build_job_script_data_as_string function correct output.
-    """
-    mocked_get_application_files_from_s3.return_value = ApplicationFiles(
-        templates={"test_job_script.sh": dummy_template},
-        source_file=dummy_application_source_file,
-    )
-
-    application_config = ApplicationConfig(**param_dict)
-
-    data_as_string = build_job_script_data_as_string(1, application_config)
-    assert json.loads(data_as_string) == json.loads(job_script_data_as_string)
-
-
 @pytest.mark.asyncio
 @database.transaction(force_rollback=True)
 async def test_get_job_script_by_id(
@@ -408,6 +346,7 @@ async def test_get_job_script_by_id(
     job_script_data,
     fill_job_script_data,
     inject_security_header,
+    mocked_file_manager_factory,
 ):
     """
     Test GET /job-scripts/<id>.
@@ -426,27 +365,27 @@ async def test_get_job_script_by_id(
         values=fill_job_script_data(application_id=inserted_application_id),
     )
 
+    main_file_path = pathlib.Path("jobbergate.py")
+    dummy_job_script_files = JobScriptFiles(
+        main_file_path=main_file_path, files={main_file_path: "print(__name__)"}
+    )
+    dummy_job_script_files.write_to_s3(inserted_job_script_id)
+
     count = await database.fetch_all("SELECT COUNT(*) FROM job_scripts")
     assert count[0][0] == 1
 
     inject_security_header("owner1@org.com", Permissions.JOB_SCRIPTS_VIEW)
 
-    job_script_data_as_string = "the job script"
-
-    with mock.patch(
-        "jobbergate_api.apps.job_scripts.routers.s3man_jobscripts",
-        {inserted_job_script_id: job_script_data_as_string},
-    ):
-        response = await client.get(f"/jobbergate/job-scripts/{inserted_job_script_id}")
+    response = await client.get(f"/jobbergate/job-scripts/{inserted_job_script_id}")
 
     assert response.status_code == status.HTTP_200_OK
 
     data = response.json()
     assert data["id"] == inserted_job_script_id
     assert data["job_script_name"] == job_script_data["job_script_name"]
-    assert data["job_script_data_as_string"] == job_script_data_as_string
     assert data["job_script_owner_email"] == "owner1@org.com"
     assert data["application_id"] == inserted_application_id
+    assert JobScriptFiles(**data["job_script_files"]) == dummy_job_script_files
 
 
 @pytest.mark.asyncio
@@ -456,6 +395,7 @@ async def test_get_job_script_by_id_file_not_found_at_s3(
     fill_application_data,
     fill_job_script_data,
     inject_security_header,
+    mocked_file_manager_factory,
 ):
     """
     Test if 404 is returned if a jobscript exists in the database but the file was not found in S3.
@@ -471,11 +411,7 @@ async def test_get_job_script_by_id_file_not_found_at_s3(
 
     inject_security_header("owner1@org.com", Permissions.JOB_SCRIPTS_VIEW)
 
-    with Stubber(s3man_jobscripts.engine.s3_client) as stubber:
-
-        stubber.add_client_error("get_object", "NoSuchKey")
-
-        response = await client.get(f"/jobbergate/job-scripts/{inserted_job_script_id}")
+    response = await client.get(f"/jobbergate/job-scripts/{inserted_job_script_id}")
 
     assert response.status_code == status.HTTP_404_NOT_FOUND
     assert "JobScript file not found for id=" in response.text
@@ -893,6 +829,7 @@ async def test_update_job_script(
     fill_job_script_data,
     inject_security_header,
     time_frame,
+    mocked_file_manager_factory,
 ):
     """
     Test update job_script via PUT.
@@ -910,24 +847,33 @@ async def test_update_job_script(
         values=fill_job_script_data(application_id=inserted_application_id),
     )
 
+    main_file_path = "jobbergate.py"
+    main_file_content = "print(__name__)"
+    dummy_job_script_files = JobScriptFiles(
+        main_file_path=main_file_path, files={main_file_path: main_file_content}
+    )
+
     inject_security_header("owner1@org.com", Permissions.JOB_SCRIPTS_EDIT)
     with time_frame() as window:
-        with mock.patch("jobbergate_api.apps.job_scripts.routers.s3man_jobscripts", {}):
-            response = await client.put(
-                f"/jobbergate/job-scripts/{inserted_job_script_id}",
-                json={
-                    "job_script_name": "new name",
-                    "job_script_description": "new description",
-                    "job_script_data_as_string": "new value",
+        response = await client.put(
+            f"/jobbergate/job-scripts/{inserted_job_script_id}",
+            json={
+                "job_script_name": "new name",
+                "job_script_description": "new description",
+                "job_script_files": {
+                    "main_file_path": main_file_path,
+                    "files": {main_file_path: main_file_content},
                 },
-            )
+            },
+        )
 
     assert response.status_code == status.HTTP_200_OK
     data = response.json()
 
     assert data["job_script_name"] == "new name"
     assert data["job_script_description"] == "new description"
-    assert data["job_script_data_as_string"] == "new value"
+    assert JobScriptFiles(**data["job_script_files"]) == dummy_job_script_files
+    assert JobScriptFiles.get_from_s3(inserted_job_script_id) == dummy_job_script_files
     assert data["id"] == inserted_job_script_id
 
     query = job_scripts_table.select(job_scripts_table.c.id == inserted_job_script_id)
@@ -964,7 +910,7 @@ async def test_update_job_script_unable_to_write_file_to_s3(
     fill_job_script_data,
     client,
     inject_security_header,
-    time_frame,
+    mocked_file_manager_factory,
 ):
     """
     Test that a job script is not updated to the database when S3 manager gets an error.
@@ -983,10 +929,12 @@ async def test_update_job_script_unable_to_write_file_to_s3(
 
     desired_jobscript_data = JobScriptPartialResponse.parse_obj(await database.fetch_one(query))
 
-    with Stubber(s3man_jobscripts.engine.s3_client) as stubber:
-        stubber.add_client_error("put_object", "NoSuchKey")
+    inject_security_header("owner1@org.com", Permissions.JOB_SCRIPTS_EDIT)
 
-        inject_security_header("owner1@org.com", Permissions.JOB_SCRIPTS_EDIT)
+    with mock.patch(
+        "jobbergate_api.apps.job_scripts.job_script_files.JobScriptFiles.write_to_s3",
+        side_effect=KeyError,
+    ):
         response = await client.put(
             f"/jobbergate/job-scripts/{inserted_job_script_id}",
             json={
@@ -1068,14 +1016,15 @@ async def test_delete_job_script(
     assert count[0][0] == 1
 
     with mock.patch(
-        "jobbergate_api.apps.job_scripts.routers.s3man_jobscripts",
-        {inserted_job_script_id: "the job script"},
-    ):
+        "jobbergate_api.apps.job_scripts.job_script_files.JobScriptFiles.delete_from_s3",
+    ) as mocked_delete:
         inject_security_header("owner1@org.com", Permissions.JOB_SCRIPTS_EDIT)
         response = await client.delete(f"/jobbergate/job-scripts/{inserted_job_script_id}")
 
     assert response.status_code == status.HTTP_204_NO_CONTENT
 
+    mocked_delete.assert_called_once()
+
     count = await database.fetch_all("SELECT COUNT(*) FROM job_scripts")
     assert count[0][0] == 0
 
@@ -1165,11 +1114,7 @@ async def test_delete_job_script__fk_error(
             """
         ),
     ):
-        with mock.patch(
-            "jobbergate_api.apps.job_scripts.routers.s3man_jobscripts",
-            {inserted_job_script_id: "the job script"},
-        ):
-            response = await client.delete(f"/jobbergate/job-scripts/{inserted_job_script_id}")
+        response = await client.delete(f"/jobbergate/job-scripts/{inserted_job_script_id}")
     assert response.status_code == status.HTTP_409_CONFLICT
     error_data = json.loads(response.text)["detail"]
     assert error_data["message"] == "Delete failed due to foreign-key constraint"
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 c219c1ee..a3c04ba8 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
@@ -2,12 +2,12 @@
 Tests for the /job-submissions/ endpoint.
 """
 import pathlib
-from unittest import mock
 
 import pytest
 from fastapi import status
 
 from jobbergate_api.apps.applications.models import applications_table
+from jobbergate_api.apps.job_scripts.job_script_files import JobScriptFiles
 from jobbergate_api.apps.job_scripts.models import job_scripts_table
 from jobbergate_api.apps.job_submissions.constants import JobSubmissionStatus
 from jobbergate_api.apps.job_submissions.models import job_submissions_table
@@ -1299,6 +1299,7 @@ async def test_job_submissions_agent_pending__success(
     fill_job_script_data,
     fill_all_job_submission_data,
     inject_security_header,
+    mocked_file_manager_factory,
 ):
     """
     Test GET /job-submissions/agent/pending returns only job_submissions owned by the requesting agent.
@@ -1317,7 +1318,12 @@ async def test_job_submissions_agent_pending__success(
         query=job_scripts_table.insert(),
         values=fill_job_script_data(application_id=inserted_application_id),
     )
-    DUMMY_JOB_SCRIPT = "print(__name__)"
+
+    main_file_path = pathlib.Path("jobbergate.py")
+    dummy_job_script_files = JobScriptFiles(
+        main_file_path=main_file_path, files={main_file_path: "print(__name__)"}
+    )
+    dummy_job_script_files.write_to_s3(inserted_job_script_id)
 
     await database.execute_many(
         query=job_submissions_table.insert(),
@@ -1361,19 +1367,14 @@ async def test_job_submissions_agent_pending__success(
         Permissions.JOB_SUBMISSIONS_VIEW,
         client_id="dummy-client",
     )
-    with mock.patch(
-        "jobbergate_api.apps.job_submissions.routers.s3man_jobscripts",
-        {str(inserted_job_script_id): DUMMY_JOB_SCRIPT},
-    ) as mocked_s3:
-        response = await client.get("/jobbergate/job-submissions/agent/pending")
+    response = await client.get("/jobbergate/job-submissions/agent/pending")
 
     assert response.status_code == status.HTTP_200_OK
-    assert str(inserted_job_script_id) in mocked_s3
 
     data = response.json()
     assert sorted(d["job_submission_name"] for d in data) == ["sub1", "sub4"]
     assert sorted(d["job_submission_owner_email"] for d in data) == ["email1@dummy.com", "email4@dummy.com"]
-    assert [d["job_script_data_as_string"] for d in data] == [DUMMY_JOB_SCRIPT] * 2
+    assert [JobScriptFiles(**d["job_script_files"]) for d in data] == [dummy_job_script_files] * 2
 
 
 @pytest.mark.asyncio
@@ -1383,6 +1384,7 @@ async def test_job_submissions_agent_pending__missing_job_script_file(
     fill_job_script_data,
     fill_all_job_submission_data,
     inject_security_header,
+    mocked_file_manager_factory,
 ):
     """
     Test GET /job-submissions/agent/pending returns a 404.
@@ -1442,13 +1444,8 @@ async def test_job_submissions_agent_pending__missing_job_script_file(
         Permissions.JOB_SUBMISSIONS_VIEW,
         client_id="dummy-client",
     )
-    with mock.patch(
-        "jobbergate_api.apps.job_submissions.routers.s3man_jobscripts",
-        {},
-    ) as mocked_s3:
-        response = await client.get("/jobbergate/job-submissions/agent/pending")
+    response = await client.get("/jobbergate/job-submissions/agent/pending")
 
-    assert str(inserted_job_script_id) not in mocked_s3
     assert response.status_code == status.HTTP_404_NOT_FOUND
     assert f"JobScript file(s) not found, the missing ids are: {inserted_job_script_id}" in response.text
 
diff --git a/jobbergate-api/jobbergate_api/tests/conftest.py b/jobbergate-api/jobbergate_api/tests/conftest.py
index 90dbea7f..bc77a0fd 100644
--- a/jobbergate-api/jobbergate_api/tests/conftest.py
+++ b/jobbergate-api/jobbergate_api/tests/conftest.py
@@ -8,11 +8,14 @@
 import random
 import string
 import typing
+from pathlib import Path
 from textwrap import dedent
+from unittest.mock import patch
 
 import pytest
 import sqlalchemy
 from asgi_lifespan import LifespanManager
+from file_storehouse.engine import EngineLocal
 from httpx import AsyncClient
 
 from jobbergate_api.apps.applications.models import applications_table
@@ -21,6 +24,7 @@
 from jobbergate_api.config import settings
 from jobbergate_api.main import app
 from jobbergate_api.metadata import metadata
+from jobbergate_api.s3_manager import file_manager_factory
 from jobbergate_api.storage import build_db_url, database
 
 # Charset for producing random strings
@@ -277,3 +281,26 @@ def _helper(*file_paths):
             ]
 
     return _helper
+
+
+@pytest.fixture(scope="function")
+def mocked_file_manager_factory(tmp_path):
+    """
+    Fixture to replace the default file engine (EngineS3) by a local one.
+
+    In this way, all objects are stored in a temporary directory ``tmp_path``,
+    that is yield for reference. Files and directories can than be managed
+    normally with pathlib.Path.
+
+    It also clears the cache from file_manager_factory.
+    """
+
+    def local_engine_factory(*, work_directory: Path, **kwargs):
+        return EngineLocal(base_path=tmp_path / work_directory)
+
+    file_manager_factory.cache_clear()
+
+    with patch("jobbergate_api.s3_manager.engine_factory", wraps=local_engine_factory):
+        yield tmp_path
+
+    file_manager_factory.cache_clear()
diff --git a/jobbergate-api/jobbergate_api/tests/test_pagination.py b/jobbergate-api/jobbergate_api/tests/test_pagination.py
index a8236f21..8b24d7ff 100644
--- a/jobbergate-api/jobbergate_api/tests/test_pagination.py
+++ b/jobbergate-api/jobbergate_api/tests/test_pagination.py
@@ -56,7 +56,6 @@ async def test_package_response__without_pagination():
             dict(
                 application_owner_email=f"owner{i}@org.com",
                 application_name=f"app{i}",
-                application_config="the configuration is here",
             )
             for i in range(1, 6)
         ],
@@ -97,7 +96,6 @@ async def test_package_response__with_pagination(start, limit, total):
                 id=i,
                 application_owner_email=f"owner{i}@org.com",
                 application_name=f"app{i}",
-                application_config="the configuration is here",
             )
             for i in range(1, total + 1)
         ],
diff --git a/jobbergate-api/jobbergate_api/tests/test_s3_manager.py b/jobbergate-api/jobbergate_api/tests/test_s3_manager.py
index 07fb71dd..be873f4f 100644
--- a/jobbergate-api/jobbergate_api/tests/test_s3_manager.py
+++ b/jobbergate-api/jobbergate_api/tests/test_s3_manager.py
@@ -2,195 +2,51 @@
 Test s3 manager.
 """
 
-import contextlib
-from unittest.mock import patch
+from pathlib import Path
 
 import pytest
-from fastapi import UploadFile
-from file_storehouse import FileManager, FileManagerReadOnly  # type: ignore
+from boto3 import client
 
-from jobbergate_api.s3_manager import (
-    ApplicationFiles,
-    application_template_manager_factory,
-    delete_application_files_from_s3,
-    get_application_files_from_s3,
-    s3man_applications_source_files,
-    s3man_jobscripts,
-    write_application_files_to_s3,
-)
+from jobbergate_api.s3_manager import engine_factory
 
 
-@pytest.mark.parametrize(
-    "s3_manager, template",
-    [
-        (s3man_applications_source_files, "applications/{}/jobbergate.py"),
-        (s3man_jobscripts, "job-scripts/{}/jobbergate.txt"),
-    ],
-)
-@pytest.mark.parametrize("id", [0, 1, 2, 10, 100, 9999])
-class TestS3ManagerKeyIdTwoWayMapping:
+class TestEngineFactory:
     """
-    Test the conversions from id number to S3 key and vice versa.
+    Test the engine factory.
     """
 
-    @pytest.mark.parametrize("input_type", [int, str])
-    def test_s3_manager__get_key_from_id_str(self, s3_manager, template, id, input_type):
+    @pytest.fixture(scope="class")
+    def client(self):
         """
-        Test the conversions from id number to S3 key.
-
-        Notice both int and str are valid types for id and are tested.
+        Fixture with a dummy client for testing.
         """
-        key = template.format(id)
-        assert s3_manager._get_engine_key(input_type(id)) == key
+        return client("s3")
 
-    def test_s3_manager__get_app_id_from_key(self, s3_manager, template, id):
+    @pytest.fixture(scope="class")
+    def s3_engine(self, client):
         """
-        Test the conversions from S3 key to id number.
+        Fixture with a dummy s3 engine factory for testing.
         """
-        key = template.format(id)
-        assert s3_manager._get_dict_key(key) == id
-
-
-@pytest.mark.parametrize(
-    "ManagerClass, is_read_only",
-    [
-        (FileManager, False),
-        (FileManagerReadOnly, True),
-    ],
-)
-def test_application_template_manager_factory_returning_type(ManagerClass, is_read_only):
-    """
-    Test if the object returned by application_template_manager_factory is the expected.
-    """
-    assert isinstance(
-        application_template_manager_factory(0, is_read_only=is_read_only),
-        ManagerClass,
-    )
-
+        return engine_factory(
+            s3_client=client,
+            bucket_name="test-bucket",
+            work_directory=Path("test-dir"),
+        )
 
-@pytest.fixture
-def make_uploaded_files():
-    """
-    Provide a fixture to use as a context manager that builds a list of uploaded files.
-    """
-
-    @contextlib.contextmanager
-    def _helper(*file_paths):
+    def test_client(self, s3_engine, client):
         """
-        Context manager that opens the file(s) and yields the ``files`` param from it.
+        Assert the client has the expected value.
         """
-        with contextlib.ExitStack() as stack:
-            yield [
-                UploadFile(path.name, stack.enter_context(open(path)), "text/plain") for path in file_paths
-            ]
-
-    return _helper
-
-
-@pytest.fixture(scope="function")
-def mocked_application_template_manager():
-    """
-    Mock the template manager factory in order to make it return an empty dictionary.
-
-    :yield dict: The dictionary representing the file manager.
-    """
-    mock_result_as_dict = {}
-    with patch(
-        "jobbergate_api.s3_manager.application_template_manager_factory",
-        wraps=lambda *args, **kwargs: mock_result_as_dict,
-    ):
-        yield mock_result_as_dict
-    mock_result_as_dict.clear()
-
-
-@pytest.fixture(scope="function")
-def mocked_applications_source_file_manager():
-    """
-    Mock the application source file manager in order to make it return an empty dictionary.
-
-    :yield dict: The dictionary representing the file manager.
-    """
-    mock_result_as_dict = {}
-    with patch("jobbergate_api.s3_manager.s3man_applications_source_files", mock_result_as_dict):
-        yield mock_result_as_dict
-    mock_result_as_dict.clear()
-
-
-def test_write_application_files_to_s3(
-    mocked_application_template_manager,
-    mocked_applications_source_file_manager,
-    make_dummy_file,
-    make_uploaded_files,
-    dummy_application_source_file,
-    dummy_template,
-    dummy_application_config,
-):
-    """
-    Test if the applications files are written to S3 as expected.
-
-    To do so, a list of uploaded files is created, than  we verify if each file
-    went to the correct file manager.
-    """
-    application_id = 1
-
-    with make_uploaded_files(
-        make_dummy_file("jobbergate.py", content=dummy_application_source_file),
-        make_dummy_file("template-1.j2", content=dummy_template),
-        make_dummy_file("template-2.jinja2", content=dummy_template),
-        make_dummy_file("jobbergate.yaml", content=dummy_application_config),
-    ) as uploaded_files:
-        write_application_files_to_s3(application_id, uploaded_files, remove_previous_files=True)
-
-    expected_templates = {"template-1.j2", "template-2.jinja2"}
-    assert mocked_application_template_manager.keys() == expected_templates
-
-    expected_source_files = {application_id}
-    assert mocked_applications_source_file_manager.keys() == expected_source_files
-
-
-def test_get_application_files_from_s3(
-    mocked_application_template_manager,
-    mocked_applications_source_file_manager,
-    dummy_application_source_file,
-    dummy_template,
-):
-    """
-    Test if the application files are loaded from S3 as expected.
-
-    To do so, dummy test files are added to the file managers and than loaded.
-    """
-    application_id = 1
-
-    mocked_applications_source_file_manager[application_id] = dummy_application_source_file
-    mocked_application_template_manager["template-1.j2"] = dummy_template
-    mocked_application_template_manager["template-2.jinja2"] = dummy_template
-
-    application_files = get_application_files_from_s3(application_id)
-
-    assert isinstance(application_files, ApplicationFiles)
+        assert s3_engine.s3_client == client
 
-    assert application_files.source_file == dummy_application_source_file
-
-    assert application_files.templates == mocked_application_template_manager
-
-
-def test_delete_application_files_from_s3(
-    mocked_application_template_manager,
-    mocked_applications_source_file_manager,
-):
-    """
-    Test if the applications files are deleted from S3 as expected.
-
-    To do so, test files are added to both file managers. Once one application id
-    is deleted, all templated files related to it are deleted as well as its source
-    file. Source files from any other id are expected to be preserved.
-    """
-    FILES_CONTENT = "test-file"
-
-    mocked_applications_source_file_manager.update((i, FILES_CONTENT) for i in range(3))
-    mocked_application_template_manager.update((f"{i}.j2", FILES_CONTENT) for i in range(3))
-
-    delete_application_files_from_s3(application_id=1)
+    def test_bucket_name(self, s3_engine):
+        """
+        Assert the bucket_name has the expected value.
+        """
+        assert s3_engine.bucket_name == "test-bucket"
 
-    assert mocked_applications_source_file_manager.keys() == {0, 2}
-    assert len(mocked_application_template_manager) == 0
+    def test_prefix(self, s3_engine):
+        """
+        Assert the prefix has the expected value.
+        """
+        assert s3_engine.prefix == "test-dir"
diff --git a/jobbergate-cli/tests/subapps/applications/test_app.py b/jobbergate-cli/tests/subapps/applications/test_app.py
index b0b43d19..b57256d2 100644
--- a/jobbergate-cli/tests/subapps/applications/test_app.py
+++ b/jobbergate-cli/tests/subapps/applications/test_app.py
@@ -214,7 +214,7 @@ def test_create__warns_but_does_not_abort_if_upload_fails(
     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
+    assert "application files could not be uploaded" in result.stdout
 
     mocked_render.assert_called_once_with(
         dummy_context,
@@ -372,7 +372,7 @@ def test_update__warns_but_does_not_abort_if_upload_fails(
     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
+    assert "application files could not be uploaded" in result.stdout
 
     mocked_render.assert_called_once_with(
         dummy_context,
diff --git a/jobbergate-cli/tests/subapps/applications/test_tools.py b/jobbergate-cli/tests/subapps/applications/test_tools.py
index 7caee899..005770e9 100644
--- a/jobbergate-cli/tests/subapps/applications/test_tools.py
+++ b/jobbergate-cli/tests/subapps/applications/test_tools.py
@@ -1,162 +1,33 @@
 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.constants import JOBBERGATE_APPLICATION_CONFIG
 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,
+    get_upload_files,
     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):
+def test_get_upload_files__fails_if_application_directory_does_not_exist(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",
-        ),
-    )
+    with pytest.raises(
+        Abort,
+        match=f"Application directory {application_path} does not exist",
+    ):
+        with get_upload_files(application_path):
+            pass
 
 
 def test_load_default_config():
@@ -166,54 +37,6 @@ def test_load_default_config():
     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,
@@ -272,7 +95,7 @@ def test_load_application_data__success(dummy_module_source, dummy_config_source
         application_name="dummy",
         application_owner_email="dummy@dummy.org",
         application_uploaded=True,
-        application_file=dummy_module_source,
+        application_source_file=dummy_module_source,
         application_config=dummy_config_source,
     )
     (app_config, app_module) = load_application_data(app_data)
@@ -286,7 +109,7 @@ def test_load_application_data__fails_if_application_module_cannot_be_loaded_fro
         application_name="dummy",
         application_owner_email="dummy@dummy.org",
         application_uploaded=True,
-        application_file="Not python at all",
+        application_source_file="Not python at all",
         application_config=dummy_config_source,
     )
 
@@ -300,7 +123,7 @@ def test_load_application_data__fails_if_application_config_is_not_valid_YAML(du
         application_name="dummy",
         application_owner_email="dummy@dummy.org",
         application_uploaded=True,
-        application_file=dummy_module_source,
+        application_source_file=dummy_module_source,
         application_config=":",
     )
 
diff --git a/jobbergate-cli/tests/subapps/conftest.py b/jobbergate-cli/tests/subapps/conftest.py
index edda18a3..93d259fd 100644
--- a/jobbergate-cli/tests/subapps/conftest.py
+++ b/jobbergate-cli/tests/subapps/conftest.py
@@ -1,4 +1,3 @@
-import json
 from typing import Any, Callable, Dict
 
 import httpx
@@ -76,7 +75,7 @@ def dummy_application_data(dummy_module_source, dummy_config_source):
             application_identifier="test-app-1",
             application_description="Test Application Number 1",
             application_owner_email="tucker.beck@omnivector.solutions",
-            application_file=dummy_module_source,
+            application_source_file=dummy_module_source,
             application_config=dummy_config_source,
             application_uploaded=True,
             created_at="2022-03-01 17:31:00",
@@ -88,7 +87,7 @@ def dummy_application_data(dummy_module_source, dummy_config_source):
             application_identifier="test-app-2",
             application_description="Test Application Number 2",
             application_owner_email="tucker.beck@omnivector.solutions",
-            application_file="print('test2')",
+            application_source_file="print('test2')",
             application_config="config2",
             application_uploaded=True,
             created_at="2022-03-01 17:31:00",
@@ -100,7 +99,7 @@ def dummy_application_data(dummy_module_source, dummy_config_source):
             application_identifier="test-app-3",
             application_description="Test Application Number 3",
             application_owner_email="tucker.beck@omnivector.solutions",
-            application_file="print('test3')",
+            application_source_file="print('test3')",
             application_config="config3",
             application_uploaded=True,
             created_at="2022-03-01 17:31:00",
@@ -110,7 +109,15 @@ def dummy_application_data(dummy_module_source, dummy_config_source):
 
 
 @pytest.fixture
-def dummy_job_script_data(dummy_application_data, dummy_template_source):
+def dummy_job_script_files(dummy_template_source):
+    return {
+        "main_file_path": "application.sh",
+        "files": {"application.sh": dummy_template_source},
+    }
+
+
+@pytest.fixture
+def dummy_job_script_data(dummy_application_data, dummy_job_script_files):
     return [
         dict(
             id=1,
@@ -118,7 +125,7 @@ def dummy_job_script_data(dummy_application_data, dummy_template_source):
             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_files=dummy_job_script_files,
             job_script_owner_email="tucker@omnivector.solutions",
             application_id=dummy_application_data[0]["id"],
         ),
@@ -128,7 +135,7 @@ def dummy_job_script_data(dummy_application_data, dummy_template_source):
             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_files=dummy_job_script_files,
             job_script_owner_email="tucker@omnivector.solutions",
             application_id=1,
         ),
@@ -138,7 +145,7 @@ def dummy_job_script_data(dummy_application_data, dummy_template_source):
             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_files=dummy_job_script_files,
             job_script_owner_email="james@omnivector.solutions",
             application_id=1,
         ),
diff --git a/jobbergate-cli/tests/subapps/job_scripts/test_app.py b/jobbergate-cli/tests/subapps/job_scripts/test_app.py
index 8460e7be..5d1eecc3 100644
--- a/jobbergate-cli/tests/subapps/job_scripts/test_app.py
+++ b/jobbergate-cli/tests/subapps/job_scripts/test_app.py
@@ -189,6 +189,7 @@ def test_create__non_fast_mode_and_job_submission(
         ),
         application_id=application_response.id,
         job_script_name="dummy-name",
+        job_script_description=None,
         sbatch_params=["1", "2", "3"],
     )
 
@@ -295,6 +296,7 @@ def test_create__with_fast_mode_and_no_job_submission(
             ),
         ),
         application_id=application_response.id,
+        job_script_description=None,
         job_script_name="dummy-name",
         sbatch_params=["1", "2", "3"],
     )
@@ -319,10 +321,10 @@ def test_update__makes_request_and_renders_results(
     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,
+        "name": "new-test-name",
+        "description": "new-test-description",
     }
     respx_mock.put(f"{dummy_domain}/jobbergate/job-scripts/{job_script_id}").mock(
         return_value=httpx.Response(httpx.codes.OK, json=new_job_script_data),
@@ -335,7 +337,8 @@ def test_update__makes_request_and_renders_results(
             unwrap(
                 f"""
                 update --id={job_script_id}
-                       --job-script='{new_job_script_data_as_string}'
+                       --name='new-test-name'
+                       --description='new-test-description'
                 """
             )
         ),